From 4cbf46fd4d0b5f175e15a55f71398bf629172bfd Mon Sep 17 00:00:00 2001 From: lanvent Date: Thu, 20 Apr 2023 01:03:04 +0800 Subject: [PATCH 01/12] feat: add support for wechatcom channel --- app.py | 9 ++- channel/channel_factory.py | 4 + channel/wechat/wechat_channel.py | 4 +- channel/wechat/wechat_message.py | 2 +- channel/wechatcom/wechatcom_channel.py | 105 +++++++++++++++++++++++++ channel/wechatcom/wechatcom_message.py | 54 +++++++++++++ channel/wechatmp/wechatmp_channel.py | 6 +- config.py | 7 ++ requirements-optional.txt | 3 +- 9 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 channel/wechatcom/wechatcom_channel.py create mode 100644 channel/wechatcom/wechatcom_message.py diff --git a/app.py b/app.py index 637b6e4..1a1bb5e 100644 --- a/app.py +++ b/app.py @@ -43,7 +43,14 @@ 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"]: + if channel_name in [ + "wx", + "wxy", + "terminal", + "wechatmp", + "wechatmp_service", + "wechatcom", + ]: PluginManager().load_plugins() # startup channel diff --git a/channel/channel_factory.py b/channel/channel_factory.py index ebd9732..fed3234 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -29,4 +29,8 @@ def create_channel(channel_type): from channel.wechatmp.wechatmp_channel import WechatMPChannel return WechatMPChannel(passive_reply=False) + elif channel_type == "wechatcom": + from channel.wechatcom.wechatcom_channel import WechatComChannel + + return WechatComChannel() raise RuntimeError diff --git a/channel/wechat/wechat_channel.py b/channel/wechat/wechat_channel.py index cf200b1..1a1e5ea 100644 --- a/channel/wechat/wechat_channel.py +++ b/channel/wechat/wechat_channel.py @@ -29,7 +29,7 @@ from plugins import * @itchat.msg_register([TEXT, VOICE, PICTURE, NOTE]) def handler_single_msg(msg): try: - cmsg = WeChatMessage(msg, False) + cmsg = WechatMessage(msg, False) except NotImplementedError as e: logger.debug("[WX]single message {} skipped: {}".format(msg["MsgId"], e)) return None @@ -40,7 +40,7 @@ def handler_single_msg(msg): @itchat.msg_register([TEXT, VOICE, PICTURE, NOTE], isGroupChat=True) def handler_group_msg(msg): try: - cmsg = WeChatMessage(msg, True) + cmsg = WechatMessage(msg, True) except NotImplementedError as e: logger.debug("[WX]group message {} skipped: {}".format(msg["MsgId"], e)) return None diff --git a/channel/wechat/wechat_message.py b/channel/wechat/wechat_message.py index 1888425..526b24f 100644 --- a/channel/wechat/wechat_message.py +++ b/channel/wechat/wechat_message.py @@ -8,7 +8,7 @@ from lib import itchat from lib.itchat.content import * -class WeChatMessage(ChatMessage): +class WechatMessage(ChatMessage): def __init__(self, itchat_msg, is_group=False): super().__init__(itchat_msg) self.msg_id = itchat_msg["MsgId"] diff --git a/channel/wechatcom/wechatcom_channel.py b/channel/wechatcom/wechatcom_channel.py new file mode 100644 index 0000000..1fbeccd --- /dev/null +++ b/channel/wechatcom/wechatcom_channel.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding=utf-8 -*- +import web +from wechatpy.enterprise import WeChatClient, create_reply, parse_message +from wechatpy.enterprise.crypto import WeChatCrypto +from wechatpy.enterprise.exceptions import InvalidCorpIdException +from wechatpy.exceptions import InvalidSignatureException + +from bridge.context import Context +from bridge.reply import Reply, ReplyType +from channel.chat_channel import ChatChannel +from channel.wechatcom.wechatcom_message import WechatComMessage +from common.log import logger +from common.singleton import singleton +from config import conf + + +@singleton +class WechatComChannel(ChatChannel): + NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE, ReplyType.VOICE] + + def __init__(self): + super().__init__() + self.corp_id = conf().get("wechatcom_corp_id") + self.secret = conf().get("wechatcom_secret") + self.agent_id = conf().get("wechatcom_agent_id") + self.token = conf().get("wechatcom_token") + self.aes_key = conf().get("wechatcom_aes_key") + print(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) + logger.info( + "[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format( + self.corp_id, self.secret, self.agent_id, self.token, self.aes_key + ) + ) + self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id) + self.client = WeChatClient(self.corp_id, self.secret) # todo: 这里可能有线程安全问题 + + def startup(self): + # start message listener + urls = ("/wxcom", "channel.wechatcom.wechatcom_channel.Query") + app = web.application(urls, globals(), autoreload=False) + port = conf().get("wechatcom_port", 8080) + web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) + + def send(self, reply: Reply, context: Context): + print("send reply: ", reply.content, context["receiver"]) + receiver = context["receiver"] + reply_text = reply.content + self.client.message.send_text(self.agent_id, receiver, reply_text) + logger.info("[send] Do send to {}: {}".format(receiver, reply_text)) + + +class Query: + def GET(self): + channel = WechatComChannel() + params = web.input() + signature = params.msg_signature + timestamp = params.timestamp + nonce = params.nonce + echostr = params.echostr + print(params) + try: + echostr = channel.crypto.check_signature( + signature, timestamp, nonce, echostr + ) + except InvalidSignatureException: + raise web.Forbidden() + return echostr + + def POST(self): + channel = WechatComChannel() + params = web.input() + signature = params.msg_signature + timestamp = params.timestamp + nonce = params.nonce + try: + message = channel.crypto.decrypt_message( + web.data(), signature, timestamp, nonce + ) + except (InvalidSignatureException, InvalidCorpIdException): + raise web.Forbidden() + print(message) + msg = parse_message(message) + + print(msg) + if msg.type == "event": + if msg.event == "subscribe": + reply = create_reply("感谢关注", msg).render() + res = channel.crypto.encrypt_message(reply, nonce, timestamp) + return res + else: + try: + wechatcom_msg = WechatComMessage(msg, client=channel.client) + 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: + channel.produce(context) + return "success" diff --git a/channel/wechatcom/wechatcom_message.py b/channel/wechatcom/wechatcom_message.py new file mode 100644 index 0000000..42de66e --- /dev/null +++ b/channel/wechatcom/wechatcom_message.py @@ -0,0 +1,54 @@ +import re + +import requests +from wechatpy.enterprise import WeChatClient + +from bridge.context import ContextType +from channel.chat_message import ChatMessage +from common.log import logger +from common.tmp_dir import TmpDir +from lib import itchat +from lib.itchat.content import * + + +class WechatComMessage(ChatMessage): + def __init__(self, msg, client: WeChatClient, is_group=False): + super().__init__(msg) + self.msg_id = msg.id + self.create_time = msg.time + self.is_group = is_group + + if msg.type == "text": + self.ctype = ContextType.TEXT + self.content = msg.content + 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 = msg.image # content直接存临时目录路径 + print(self.content) + # self._prepare_fn = lambda: itchat_msg.download(self.content) # TODO: download image + else: + raise NotImplementedError( + "Unsupported message type: Type:{} ".format(msg.type) + ) + + self.from_user_id = msg.source + self.to_user_id = msg.target + self.other_user_id = msg.source diff --git a/channel/wechatmp/wechatmp_channel.py b/channel/wechatmp/wechatmp_channel.py index ac3c3ac..3152124 100644 --- a/channel/wechatmp/wechatmp_channel.py +++ b/channel/wechatmp/wechatmp_channel.py @@ -98,7 +98,9 @@ class WechatMPChannel(ChatChannel): if self.passive_reply: receiver = context["receiver"] self.cache_dict[receiver] = reply.content - logger.info("[send] reply to {} saved to cache: {}".format(receiver, reply)) + logger.info( + "[wechatmp] reply to {} saved to cache: {}".format(receiver, reply) + ) else: receiver = context["receiver"] reply_text = reply.content @@ -115,7 +117,7 @@ class WechatMPChannel(ChatChannel): params=params, data=json.dumps(json_data, ensure_ascii=False).encode("utf8"), ) - logger.info("[send] Do send to {}: {}".format(receiver, reply_text)) + logger.info("[wechatmp] Do send to {}: {}".format(receiver, reply_text)) return def _success_callback(self, session_id, context, **kwargs): # 线程异常结束时的回调函数 diff --git a/config.py b/config.py index 8f5d2ca..f6c3983 100644 --- a/config.py +++ b/config.py @@ -75,6 +75,13 @@ available_setting = { "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 "wechatmp_app_id": "", # 微信公众平台的appID,仅服务号需要 "wechatmp_app_secret": "", # 微信公众平台的appsecret,仅服务号需要 + # wechatcom的配置 + "wechatcom_token": "", # 企业微信的token + "wechatcom_port": 9898, # 企业微信的服务端口,不需要端口转发 + "wechatcom_corp_id": "", # 企业微信的corpID + "wechatcom_secret": "", # 企业微信的secret + "wechatcom_agent_id": "", # 企业微信的appID + "wechatcom_aes_key": "", # 企业微信的aes_key # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 diff --git a/requirements-optional.txt b/requirements-optional.txt index cfb52c9..d7c48ac 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -16,8 +16,9 @@ wechaty>=0.10.7 wechaty_puppet>=0.4.23 pysilk_mod>=1.6.0 # needed by send voice -# wechatmp +# wechatmp wechatcom web.py +wechatpy # chatgpt-tool-hub plugin From ab83dacb761c1ff2e718ef093ba6b9b75c8720fb Mon Sep 17 00:00:00 2001 From: lanvent Date: Thu, 20 Apr 2023 01:46:23 +0800 Subject: [PATCH 02/12] feat(wechatcom): add support for sending voice messages --- .pre-commit-config.yaml | 1 + channel/wechatcom/wechatcom_channel.py | 36 +++++++++++++++++++++----- voice/audio_convert.py | 18 +++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5dd0d7d..0c39abf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,7 @@ repos: hooks: - id: isort exclude: '(\/|^)lib\/' + args: [ -l, '88'] - repo: https://github.com/psf/black rev: 23.3.0 hooks: diff --git a/channel/wechatcom/wechatcom_channel.py b/channel/wechatcom/wechatcom_channel.py index 1fbeccd..6bb4f1f 100644 --- a/channel/wechatcom/wechatcom_channel.py +++ b/channel/wechatcom/wechatcom_channel.py @@ -1,10 +1,12 @@ #!/usr/bin/env python # -*- coding=utf-8 -*- +import os + import web from wechatpy.enterprise import WeChatClient, create_reply, parse_message from wechatpy.enterprise.crypto import WeChatCrypto from wechatpy.enterprise.exceptions import InvalidCorpIdException -from wechatpy.exceptions import InvalidSignatureException +from wechatpy.exceptions import InvalidSignatureException, WeChatClientException from bridge.context import Context from bridge.reply import Reply, ReplyType @@ -13,11 +15,12 @@ from channel.wechatcom.wechatcom_message import WechatComMessage from common.log import logger from common.singleton import singleton from config import conf +from voice.audio_convert import any_to_amr @singleton class WechatComChannel(ChatChannel): - NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE, ReplyType.VOICE] + NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE] def __init__(self): super().__init__() @@ -43,11 +46,32 @@ class WechatComChannel(ChatChannel): web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) def send(self, reply: Reply, context: Context): - print("send reply: ", reply.content, context["receiver"]) receiver = context["receiver"] - reply_text = reply.content - self.client.message.send_text(self.agent_id, receiver, reply_text) - logger.info("[send] Do send to {}: {}".format(receiver, reply_text)) + if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]: + self.client.message.send_text(self.agent_id, receiver, reply.content) + logger.info("[wechatcom] sendMsg={}, receiver={}".format(reply, receiver)) + elif reply.type == ReplyType.VOICE: + try: + file_path = reply.content + amr_file = os.path.splitext(file_path)[0] + ".amr" + any_to_amr(file_path, amr_file) + response = self.client.media.upload("voice", open(amr_file, "rb")) + logger.debug("[wechatcom] upload voice response: {}".format(response)) + except WeChatClientException as e: + logger.error("[wechatcom] upload voice failed: {}".format(e)) + return + try: + os.remove(file_path) + if amr_file != file_path: + os.remove(amr_file) + except Exception: + pass + self.client.message.send_voice( + self.agent_id, receiver, response["media_id"] + ) + logger.info( + "[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver) + ) class Query: diff --git a/voice/audio_convert.py b/voice/audio_convert.py index 241a3a6..4819b49 100644 --- a/voice/audio_convert.py +++ b/voice/audio_convert.py @@ -69,6 +69,24 @@ def any_to_sil(any_path, sil_path): raise NotImplementedError("Not support file type: {}".format(any_path)) +def any_to_amr(any_path, amr_path): + """ + 把任意格式转成amr文件 + """ + if any_path.endswith(".amr"): + shutil.copy2(any_path, amr_path) + return + if ( + any_path.endswith(".sil") + or any_path.endswith(".silk") + or any_path.endswith(".slk") + ): + raise NotImplementedError("Not support file type: {}".format(any_path)) + audio = AudioSegment.from_file(any_path) + audio = audio.set_frame_rate(8000) # only support 8000 + audio.export(amr_path, format="amr") + + def mp3_to_wav(mp3_path, wav_path): """ 把mp3格式转成pcm文件 From 3ea87813810e59922b654f8ebb4070ea878e7c29 Mon Sep 17 00:00:00 2001 From: lanvent Date: Thu, 20 Apr 2023 02:14:52 +0800 Subject: [PATCH 03/12] feat(wechatcom): add support for sending image --- channel/wechatcom/wechatcom_channel.py | 54 +++++++++++++++++++++++--- channel/wechatcom/wechatcom_message.py | 17 ++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/channel/wechatcom/wechatcom_channel.py b/channel/wechatcom/wechatcom_channel.py index 6bb4f1f..90c7b22 100644 --- a/channel/wechatcom/wechatcom_channel.py +++ b/channel/wechatcom/wechatcom_channel.py @@ -1,7 +1,10 @@ #!/usr/bin/env python # -*- coding=utf-8 -*- +import io import os +import textwrap +import requests import web from wechatpy.enterprise import WeChatClient, create_reply, parse_message from wechatpy.enterprise.crypto import WeChatCrypto @@ -20,7 +23,7 @@ from voice.audio_convert import any_to_amr @singleton class WechatComChannel(ChatChannel): - NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE] + NOT_SUPPORT_REPLYTYPE = [] def __init__(self): super().__init__() @@ -72,6 +75,38 @@ class WechatComChannel(ChatChannel): logger.info( "[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver) ) + elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片 + img_url = reply.content + pic_res = requests.get(img_url, stream=True) + image_storage = io.BytesIO() + for block in pic_res.iter_content(1024): + image_storage.write(block) + image_storage.seek(0) + try: + response = self.client.media.upload("image", image_storage) + logger.debug("[wechatcom] upload image response: {}".format(response)) + 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"] + ) + logger.info( + "[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver) + ) + elif reply.type == ReplyType.IMAGE: # 从文件读取图片 + image_storage = reply.content + image_storage.seek(0) + try: + response = self.client.media.upload("image", image_storage) + logger.debug("[wechatcom] upload image response: {}".format(response)) + 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"] + ) + logger.info("[wechatcom] sendImage, receiver={}".format(receiver)) class Query: @@ -103,13 +138,22 @@ class Query: ) except (InvalidSignatureException, InvalidCorpIdException): raise web.Forbidden() - print(message) msg = parse_message(message) - - print(msg) + logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg)) if msg.type == "event": if msg.event == "subscribe": - reply = create_reply("感谢关注", msg).render() + trigger_prefix = conf().get("single_chat_prefix", [""])[0] + reply_content = textwrap.dedent( + f"""\ + 感谢您的关注! + 这里是ChatGPT,可以自由对话。 + 支持语音对话。 + 支持通用表情输入。 + 支持图片输入输出。 + 支持角色扮演和文字冒险两种定制模式对话。 + 输入'{trigger_prefix}#help' 查看详细指令。""" + ) + reply = create_reply(reply_content, msg).render() res = channel.crypto.encrypt_message(reply, nonce, timestamp) return res else: diff --git a/channel/wechatcom/wechatcom_message.py b/channel/wechatcom/wechatcom_message.py index 42de66e..9b34f11 100644 --- a/channel/wechatcom/wechatcom_message.py +++ b/channel/wechatcom/wechatcom_message.py @@ -41,9 +41,20 @@ class WechatComMessage(ChatMessage): self._prepare_fn = download_voice elif msg.type == "image": self.ctype = ContextType.IMAGE - self.content = msg.image # content直接存临时目录路径 - print(self.content) - # self._prepare_fn = lambda: itchat_msg.download(self.content) # TODO: download 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) From d2bf90c6c713f729e437c646c4b0a63c19d3a9a1 Mon Sep 17 00:00:00 2001 From: lanvent Date: Thu, 20 Apr 2023 08:31:42 +0800 Subject: [PATCH 04/12] refactor: rename WechatComChannel to WechatComAppChannel --- channel/channel_factory.py | 4 ++-- ...com_channel.py => wechatcomapp_channel.py} | 22 +++++++++---------- ...com_message.py => wechatcomapp_message.py} | 2 +- config.py | 15 +++++++------ 4 files changed, 22 insertions(+), 21 deletions(-) rename channel/wechatcom/{wechatcom_channel.py => wechatcomapp_channel.py} (91%) rename channel/wechatcom/{wechatcom_message.py => wechatcomapp_message.py} (98%) diff --git a/channel/channel_factory.py b/channel/channel_factory.py index fed3234..d6625d4 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -30,7 +30,7 @@ def create_channel(channel_type): return WechatMPChannel(passive_reply=False) elif channel_type == "wechatcom": - from channel.wechatcom.wechatcom_channel import WechatComChannel + from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel - return WechatComChannel() + return WechatComAppChannel() raise RuntimeError diff --git a/channel/wechatcom/wechatcom_channel.py b/channel/wechatcom/wechatcomapp_channel.py similarity index 91% rename from channel/wechatcom/wechatcom_channel.py rename to channel/wechatcom/wechatcomapp_channel.py index 90c7b22..a791e41 100644 --- a/channel/wechatcom/wechatcom_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -14,7 +14,7 @@ from wechatpy.exceptions import InvalidSignatureException, WeChatClientException from bridge.context import Context from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel -from channel.wechatcom.wechatcom_message import WechatComMessage +from channel.wechatcom.wechatcomapp_message import WechatComAppMessage from common.log import logger from common.singleton import singleton from config import conf @@ -22,16 +22,16 @@ from voice.audio_convert import any_to_amr @singleton -class WechatComChannel(ChatChannel): +class WechatComAppChannel(ChatChannel): NOT_SUPPORT_REPLYTYPE = [] def __init__(self): super().__init__() self.corp_id = conf().get("wechatcom_corp_id") - self.secret = conf().get("wechatcom_secret") - self.agent_id = conf().get("wechatcom_agent_id") - self.token = conf().get("wechatcom_token") - self.aes_key = conf().get("wechatcom_aes_key") + self.secret = conf().get("wechatcomapp_secret") + self.agent_id = conf().get("wechatcomapp_agent_id") + self.token = conf().get("wechatcomapp_token") + self.aes_key = conf().get("wechatcomapp_aes_key") print(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) logger.info( "[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format( @@ -43,9 +43,9 @@ class WechatComChannel(ChatChannel): def startup(self): # start message listener - urls = ("/wxcom", "channel.wechatcom.wechatcom_channel.Query") + urls = ("/wxcom", "channel.wechatcom.wechatcomapp_channel.Query") app = web.application(urls, globals(), autoreload=False) - port = conf().get("wechatcom_port", 8080) + port = conf().get("wechatcomapp_port", 8080) web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) def send(self, reply: Reply, context: Context): @@ -111,7 +111,7 @@ class WechatComChannel(ChatChannel): class Query: def GET(self): - channel = WechatComChannel() + channel = WechatComAppChannel() params = web.input() signature = params.msg_signature timestamp = params.timestamp @@ -127,7 +127,7 @@ class Query: return echostr def POST(self): - channel = WechatComChannel() + channel = WechatComAppChannel() params = web.input() signature = params.msg_signature timestamp = params.timestamp @@ -158,7 +158,7 @@ class Query: return res else: try: - wechatcom_msg = WechatComMessage(msg, client=channel.client) + wechatcom_msg = WechatComAppMessage(msg, client=channel.client) except NotImplementedError as e: logger.debug("[wechatcom] " + str(e)) return "success" diff --git a/channel/wechatcom/wechatcom_message.py b/channel/wechatcom/wechatcomapp_message.py similarity index 98% rename from channel/wechatcom/wechatcom_message.py rename to channel/wechatcom/wechatcomapp_message.py index 9b34f11..f441a68 100644 --- a/channel/wechatcom/wechatcom_message.py +++ b/channel/wechatcom/wechatcomapp_message.py @@ -11,7 +11,7 @@ from lib import itchat from lib.itchat.content import * -class WechatComMessage(ChatMessage): +class WechatComAppMessage(ChatMessage): def __init__(self, msg, client: WeChatClient, is_group=False): super().__init__(msg) self.msg_id = msg.id diff --git a/config.py b/config.py index f6c3983..c5f09ab 100644 --- a/config.py +++ b/config.py @@ -75,13 +75,14 @@ available_setting = { "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 "wechatmp_app_id": "", # 微信公众平台的appID,仅服务号需要 "wechatmp_app_secret": "", # 微信公众平台的appsecret,仅服务号需要 - # wechatcom的配置 - "wechatcom_token": "", # 企业微信的token - "wechatcom_port": 9898, # 企业微信的服务端口,不需要端口转发 - "wechatcom_corp_id": "", # 企业微信的corpID - "wechatcom_secret": "", # 企业微信的secret - "wechatcom_agent_id": "", # 企业微信的appID - "wechatcom_aes_key": "", # 企业微信的aes_key + # wechatcom的通用配置 + "wechatcom_corp_id": "", # 企业微信公司的corpID + # wechatcomapp的配置 + "wechatcomapp_token": "", # 企业微信app的token + "wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发 + "wechatcomapp_secret": "", # 企业微信app的secret + "wechatcomapp_agent_id": "", # 企业微信app的agent_id + "wechatcomapp_aes_key": "", # 企业微信app的aes_key # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 From 3e9e8d442ac76630bc118facfc6ae57736847c6c Mon Sep 17 00:00:00 2001 From: lanvent Date: Thu, 20 Apr 2023 08:43:17 +0800 Subject: [PATCH 05/12] docs: add README.md for wechatcomapp channel --- channel/wechatcom/README.md | 33 +++++++++++++++++++++++ channel/wechatcom/wechatcomapp_channel.py | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 channel/wechatcom/README.md diff --git a/channel/wechatcom/README.md b/channel/wechatcom/README.md new file mode 100644 index 0000000..7e1d77a --- /dev/null +++ b/channel/wechatcom/README.md @@ -0,0 +1,33 @@ + +> 详细文档暂无 + +## 自建应用 + +- 在企业微信工作台自建应用 + +建立应用后点击通过API接收消息,设置服务器地址,服务器地址是`http://url:port/wxcomapp`的形式,也可以不用域名,比如 `http://ip:port/wxcomapp` + +- 修改配置 + +在主目录下的`config.json`中填写以下配置项 + +```python + # wechatcom的通用配置 + "wechatcom_corp_id": "", # 企业微信公司的corpID + # wechatcomapp的配置 + "wechatcomapp_token": "", # 企业微信app的token + "wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发 + "wechatcomapp_secret": "", # 企业微信app的secret + "wechatcomapp_agent_id": "", # 企业微信app的agent_id + "wechatcomapp_aes_key": "", # 企业微信app的aes_key +``` + +- 运行程序 + +```python app.py``` + +在设置服务器页面点击保存 + +- 添加可信IP + +在自建应用管理页下方,将服务器的IP添加到可信IP diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index a791e41..0dad142 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -43,7 +43,7 @@ class WechatComAppChannel(ChatChannel): def startup(self): # start message listener - urls = ("/wxcom", "channel.wechatcom.wechatcomapp_channel.Query") + urls = ("/wxcomapp", "channel.wechatcom.wechatcomapp_channel.Query") app = web.application(urls, globals(), autoreload=False) port = conf().get("wechatcomapp_port", 8080) web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) From 665001732b4bd6a167088a99bd5703c422eefde5 Mon Sep 17 00:00:00 2001 From: lanvent Date: Fri, 21 Apr 2023 15:29:59 +0800 Subject: [PATCH 06/12] feat: add image compression Add image compression feature to WechatComAppChannel to compress images larger than 10MB before uploading to WeChat server. The compression is done using the `compress_imgfile` function in `utils.py`. The `fsize` function is also added to `utils.py` to calculate the size of a file or buffer. --- channel/wechatcom/wechatcomapp_channel.py | 18 ++++++++++++ common/utils.py | 34 +++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 common/utils.py diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index 0dad142..f62ab00 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -17,6 +17,7 @@ from channel.chat_channel import ChatChannel from channel.wechatcom.wechatcomapp_message import WechatComAppMessage from common.log import logger from common.singleton import singleton +from common.utils import compress_imgfile, fsize from config import conf from voice.audio_convert import any_to_amr @@ -81,6 +82,14 @@ class WechatComAppChannel(ChatChannel): image_storage = io.BytesIO() for block in pic_res.iter_content(1024): image_storage.write(block) + if (sz := fsize(image_storage)) >= 10 * 1024 * 1024: + logger.info( + "[wechatcom] image too large, ready to compress, sz={}".format(sz) + ) + image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) + logger.info( + "[wechatcom] image compressed, sz={}".format(fsize(image_storage)) + ) image_storage.seek(0) try: response = self.client.media.upload("image", image_storage) @@ -88,6 +97,7 @@ class WechatComAppChannel(ChatChannel): 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"] ) @@ -96,6 +106,14 @@ class WechatComAppChannel(ChatChannel): ) elif reply.type == ReplyType.IMAGE: # 从文件读取图片 image_storage = reply.content + if (sz := fsize(image_storage)) >= 10 * 1024 * 1024: + logger.info( + "[wechatcom] image too large, ready to compress, sz={}".format(sz) + ) + image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) + logger.info( + "[wechatcom] image compressed, sz={}".format(fsize(image_storage)) + ) image_storage.seek(0) try: response = self.client.media.upload("image", image_storage) diff --git a/common/utils.py b/common/utils.py new file mode 100644 index 0000000..4d055f3 --- /dev/null +++ b/common/utils.py @@ -0,0 +1,34 @@ +import io +import os + +from PIL import Image + + +def fsize(file): + if isinstance(file, io.BytesIO): + return file.getbuffer().nbytes + elif isinstance(file, str): + return os.path.getsize(file) + elif hasattr(file, "seek") and hasattr(file, "tell"): + pos = file.tell() + file.seek(0, os.SEEK_END) + size = file.tell() + file.seek(pos) + return size + else: + raise TypeError("Unsupported type") + + +def compress_imgfile(file, max_size): + if fsize(file) <= max_size: + return file + file.seek(0) + img = Image.open(file) + rgb_image = img.convert("RGB") + quality = 95 + while True: + out_buf = io.BytesIO() + rgb_image.save(out_buf, "JPEG", quality=quality) + if fsize(out_buf) <= max_size: + return out_buf + quality -= 5 From 2ec53747658ecb9a5ee54b4bb77b08f5e5c0f052 Mon Sep 17 00:00:00 2001 From: lanvent Date: Sun, 23 Apr 2023 15:34:25 +0800 Subject: [PATCH 07/12] feat:modify wechatcom to wechatcom_app --- channel/channel_factory.py | 2 +- channel/wechatcom/wechatcomapp_channel.py | 17 +++++++++-------- config.py | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/channel/channel_factory.py b/channel/channel_factory.py index d6625d4..96f3e5f 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -29,7 +29,7 @@ def create_channel(channel_type): from channel.wechatmp.wechatmp_channel import WechatMPChannel return WechatMPChannel(passive_reply=False) - elif channel_type == "wechatcom": + elif channel_type == "wechatcom_app": from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel return WechatComAppChannel() diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index f62ab00..c655a3d 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -131,12 +131,12 @@ class Query: def GET(self): channel = WechatComAppChannel() params = web.input() - signature = params.msg_signature - timestamp = params.timestamp - nonce = params.nonce - echostr = params.echostr - print(params) + logger.info("[wechatcom] receive params: {}".format(params)) try: + signature = params.msg_signature + timestamp = params.timestamp + nonce = params.nonce + echostr = params.echostr echostr = channel.crypto.check_signature( signature, timestamp, nonce, echostr ) @@ -147,10 +147,11 @@ class Query: def POST(self): channel = WechatComAppChannel() params = web.input() - signature = params.msg_signature - timestamp = params.timestamp - nonce = params.nonce + logger.info("[wechatcom] receive params: {}".format(params)) try: + signature = params.msg_signature + timestamp = params.timestamp + nonce = params.nonce message = channel.crypto.decrypt_message( web.data(), signature, timestamp, nonce ) diff --git a/config.py b/config.py index c5f09ab..576432a 100644 --- a/config.py +++ b/config.py @@ -86,7 +86,7 @@ available_setting = { # chatgpt指令自定义触发词 "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 - "channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service} + "channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service,wechatcom_app} "debug": False, # 是否开启debug模式,开启后会打印更多日志 "appdata_dir": "", # 数据目录 # 插件配置 From 9163ce71fd52566a716e258812411fc21c3589a3 Mon Sep 17 00:00:00 2001 From: lanvent Date: Sun, 23 Apr 2023 16:51:16 +0800 Subject: [PATCH 08/12] fix: enable plugins for wechatcom_app --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index 1a1bb5e..ee4df44 100644 --- a/app.py +++ b/app.py @@ -49,7 +49,7 @@ def run(): "terminal", "wechatmp", "wechatmp_service", - "wechatcom", + "wechatcom_app", ]: PluginManager().load_plugins() From 6e14fce1fe6cf462a4976f461d54d2c10013f54f Mon Sep 17 00:00:00 2001 From: lanvent Date: Tue, 25 Apr 2023 00:44:16 +0800 Subject: [PATCH 09/12] docs: update README.md for wechatcom_app --- channel/wechatcom/README.md | 52 ++++++++++++++++------ channel/wechatcom/wechatcomapp_channel.py | 50 ++++++--------------- docs/images/aigcopen.png | Bin 0 -> 52543 bytes 3 files changed, 51 insertions(+), 51 deletions(-) create mode 100644 docs/images/aigcopen.png diff --git a/channel/wechatcom/README.md b/channel/wechatcom/README.md index 7e1d77a..5eea688 100644 --- a/channel/wechatcom/README.md +++ b/channel/wechatcom/README.md @@ -1,33 +1,57 @@ +# 企业微信应用号channel -> 详细文档暂无 +企业微信官方提供了客服、应用等API,本channel使用的是企业微信的应用API的能力。因为未来可能还会开发客服能力,所以本channel的类型名叫作`wechatcom_app`。 -## 自建应用 +`wechatcom_app` channel支持插件系统和图片声音交互等能力,除了无法加入群聊,作为个人使用的私人助理已绰绰有余。 -- 在企业微信工作台自建应用 +## 开始之前 -建立应用后点击通过API接收消息,设置服务器地址,服务器地址是`http://url:port/wxcomapp`的形式,也可以不用域名,比如 `http://ip:port/wxcomapp` +- 在企业中确认自己拥有在企业内自建应用的权限。 +- 如果没有权限或者是个人用户,也可创建未认证的企业。操作方式:登录手机企业微信,选择`创建/加入企业`来创建企业,类型请选择企业,企业名称可随意填写。 + 未认证的企业有100人的服务人数上限,其他功能与认证企业没有差异。 -- 修改配置 +本channel需安装的依赖与公众号一致,需要安装`wechatpy`和`web.py`,它们包含在`requirements-optional.txt`中。 -在主目录下的`config.json`中填写以下配置项 +## 使用方法 + +1.查看企业ID + +- 扫码登陆[企业微信后台](https://work.weixin.qq.com) +- 选择`我的企业`,点击`企业信息`,记住该`企业ID` + +2.创建自建应用 + +- 选择应用管理, 在自建区选创建应用来创建企业自建应用 +- 上传应用logo,填写应用名称等项 +- 创建应用后进入应用详情页面,记住`AgentId`和`Secert` + +3.配置应用 + +- 在详情页如果点击`企业可信IP`的配置(没看到可以不管),填入你服务器的公网IP +- 点击`接收消息`下的启用API接收消息 +- `URL`填写格式为`http://url:port/wxcomapp`,是程序监听的端口,默认是9898 + 如果是未认证的企业,url可直接使用服务器的IP。如果是认证企业,需要使用备案的域名,可使用二级域名。 +- `Token`可随意填写,停留在这个页面 +- 在程序根目录`config.json`中增加配置(**去掉注释**),`wechatcomapp_aes_key`是当前页面的`wechatcomapp_aes_key` ```python - # wechatcom的通用配置 + "channel_type": "wechatcom_app", "wechatcom_corp_id": "", # 企业微信公司的corpID - # wechatcomapp的配置 "wechatcomapp_token": "", # 企业微信app的token - "wechatcomapp_port": 9898, # 企业微信app的服务端口,不需要端口转发 + "wechatcomapp_port": 9898, # 企业微信app的服务端口, 不需要端口转发 "wechatcomapp_secret": "", # 企业微信app的secret "wechatcomapp_agent_id": "", # 企业微信app的agent_id "wechatcomapp_aes_key": "", # 企业微信app的aes_key ``` -- 运行程序 +- 运行程序,在页面中点击保存,保存成功说明验证成功 + +4.连接个人微信 -```python app.py``` +选择`我的企业`,点击`微信插件`,下面有个邀请关注的二维码。微信扫码后,即可在微信中看到对应企业,在这里你便可以和机器人沟通。 -在设置服务器页面点击保存 +## 测试体验 -- 添加可信IP +AIGC开放社区中已经部署了多个可免费使用的Bot,扫描下方的二维码会自动邀请你来体验。 -在自建应用管理页下方,将服务器的IP添加到可信IP + \ No newline at end of file diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index c655a3d..6959b9e 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -35,9 +35,7 @@ class WechatComAppChannel(ChatChannel): self.aes_key = conf().get("wechatcomapp_aes_key") print(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) logger.info( - "[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format( - self.corp_id, self.secret, self.agent_id, self.token, self.aes_key - ) + "[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) ) self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id) self.client = WeChatClient(self.corp_id, self.secret) # todo: 这里可能有线程安全问题 @@ -46,7 +44,7 @@ class WechatComAppChannel(ChatChannel): # start message listener urls = ("/wxcomapp", "channel.wechatcom.wechatcomapp_channel.Query") app = web.application(urls, globals(), autoreload=False) - port = conf().get("wechatcomapp_port", 8080) + port = conf().get("wechatcomapp_port", 9898) web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) def send(self, reply: Reply, context: Context): @@ -70,12 +68,8 @@ class WechatComAppChannel(ChatChannel): os.remove(amr_file) except Exception: pass - self.client.message.send_voice( - self.agent_id, receiver, response["media_id"] - ) - logger.info( - "[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver) - ) + self.client.message.send_voice(self.agent_id, receiver, response["media_id"]) + logger.info("[wechatcom] sendVoice={}, receiver={}".format(reply.content, receiver)) elif reply.type == ReplyType.IMAGE_URL: # 从网络下载图片 img_url = reply.content pic_res = requests.get(img_url, stream=True) @@ -83,13 +77,9 @@ class WechatComAppChannel(ChatChannel): for block in pic_res.iter_content(1024): image_storage.write(block) if (sz := fsize(image_storage)) >= 10 * 1024 * 1024: - logger.info( - "[wechatcom] image too large, ready to compress, sz={}".format(sz) - ) + logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz)) image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) - logger.info( - "[wechatcom] image compressed, sz={}".format(fsize(image_storage)) - ) + logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage))) image_storage.seek(0) try: response = self.client.media.upload("image", image_storage) @@ -98,22 +88,14 @@ class WechatComAppChannel(ChatChannel): logger.error("[wechatcom] upload image failed: {}".format(e)) return - self.client.message.send_image( - self.agent_id, receiver, response["media_id"] - ) - logger.info( - "[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver) - ) + self.client.message.send_image(self.agent_id, receiver, response["media_id"]) + logger.info("[wechatcom] sendImage url={}, receiver={}".format(img_url, receiver)) elif reply.type == ReplyType.IMAGE: # 从文件读取图片 image_storage = reply.content if (sz := fsize(image_storage)) >= 10 * 1024 * 1024: - logger.info( - "[wechatcom] image too large, ready to compress, sz={}".format(sz) - ) + logger.info("[wechatcom] image too large, ready to compress, sz={}".format(sz)) image_storage = compress_imgfile(image_storage, 10 * 1024 * 1024 - 1) - logger.info( - "[wechatcom] image compressed, sz={}".format(fsize(image_storage)) - ) + logger.info("[wechatcom] image compressed, sz={}".format(fsize(image_storage))) image_storage.seek(0) try: response = self.client.media.upload("image", image_storage) @@ -121,9 +103,7 @@ class WechatComAppChannel(ChatChannel): 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.client.message.send_image(self.agent_id, receiver, response["media_id"]) logger.info("[wechatcom] sendImage, receiver={}".format(receiver)) @@ -137,9 +117,7 @@ class Query: timestamp = params.timestamp nonce = params.nonce echostr = params.echostr - echostr = channel.crypto.check_signature( - signature, timestamp, nonce, echostr - ) + echostr = channel.crypto.check_signature(signature, timestamp, nonce, echostr) except InvalidSignatureException: raise web.Forbidden() return echostr @@ -152,9 +130,7 @@ class Query: signature = params.msg_signature timestamp = params.timestamp nonce = params.nonce - message = channel.crypto.decrypt_message( - web.data(), signature, timestamp, nonce - ) + message = channel.crypto.decrypt_message(web.data(), signature, timestamp, nonce) except (InvalidSignatureException, InvalidCorpIdException): raise web.Forbidden() msg = parse_message(message) diff --git a/docs/images/aigcopen.png b/docs/images/aigcopen.png new file mode 100644 index 0000000000000000000000000000000000000000..76a20c6206b802088b7a2d5f06194169e2c08eb9 GIT binary patch literal 52543 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 Date: Tue, 25 Apr 2023 01:04:03 +0800 Subject: [PATCH 10/12] fix: ensure get access_token thread-safe --- channel/wechatcom/README.md | 2 +- channel/wechatcom/wechatcomapp_channel.py | 6 +++--- channel/wechatcom/wechatcomapp_client.py | 21 +++++++++++++++++++++ channel/wechatcom/wechatcomapp_message.py | 21 ++++----------------- 4 files changed, 29 insertions(+), 21 deletions(-) create mode 100644 channel/wechatcom/wechatcomapp_client.py diff --git a/channel/wechatcom/README.md b/channel/wechatcom/README.md index 5eea688..1728678 100644 --- a/channel/wechatcom/README.md +++ b/channel/wechatcom/README.md @@ -54,4 +54,4 @@ AIGC开放社区中已经部署了多个可免费使用的Bot,扫描下方的二维码会自动邀请你来体验。 - \ No newline at end of file + \ No newline at end of file diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index 6959b9e..bd51c5f 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding=utf-8 -*- import io import os @@ -6,7 +5,7 @@ import textwrap import requests import web -from wechatpy.enterprise import WeChatClient, create_reply, parse_message +from wechatpy.enterprise import create_reply, parse_message from wechatpy.enterprise.crypto import WeChatCrypto from wechatpy.enterprise.exceptions import InvalidCorpIdException from wechatpy.exceptions import InvalidSignatureException, WeChatClientException @@ -14,6 +13,7 @@ from wechatpy.exceptions import InvalidSignatureException, WeChatClientException from bridge.context import Context from bridge.reply import Reply, ReplyType from channel.chat_channel import ChatChannel +from channel.wechatcom.wechatcomapp_client import WechatComAppClient from channel.wechatcom.wechatcomapp_message import WechatComAppMessage from common.log import logger from common.singleton import singleton @@ -38,7 +38,7 @@ class WechatComAppChannel(ChatChannel): "[wechatcom] init: corp_id: {}, secret: {}, agent_id: {}, token: {}, aes_key: {}".format(self.corp_id, self.secret, self.agent_id, self.token, self.aes_key) ) self.crypto = WeChatCrypto(self.token, self.aes_key, self.corp_id) - self.client = WeChatClient(self.corp_id, self.secret) # todo: 这里可能有线程安全问题 + self.client = WechatComAppClient(self.corp_id, self.secret) def startup(self): # start message listener diff --git a/channel/wechatcom/wechatcomapp_client.py b/channel/wechatcom/wechatcomapp_client.py new file mode 100644 index 0000000..c0feb7a --- /dev/null +++ b/channel/wechatcom/wechatcomapp_client.py @@ -0,0 +1,21 @@ +import threading +import time + +from wechatpy.enterprise import WeChatClient + + +class WechatComAppClient(WeChatClient): + def __init__(self, corp_id, secret, access_token=None, session=None, timeout=None, auto_retry=True): + super(WechatComAppClient, self).__init__(corp_id, secret, access_token, session, timeout, auto_retry) + self.fetch_access_token_lock = threading.Lock() + + def fetch_access_token(self): # 重载父类方法,加锁避免多线程重复获取access_token + with self.fetch_access_token_lock: + access_token = self.session.get(self.access_token_key) + if access_token: + if not self.expires_at: + return access_token + timestamp = time.time() + if self.expires_at - timestamp > 60: + return access_token + return super().fetch_access_token() diff --git a/channel/wechatcom/wechatcomapp_message.py b/channel/wechatcom/wechatcomapp_message.py index f441a68..a70f755 100644 --- a/channel/wechatcom/wechatcomapp_message.py +++ b/channel/wechatcom/wechatcomapp_message.py @@ -1,14 +1,9 @@ -import re - -import requests from wechatpy.enterprise import WeChatClient from bridge.context import ContextType from channel.chat_message import ChatMessage from common.log import logger from common.tmp_dir import TmpDir -from lib import itchat -from lib.itchat.content import * class WechatComAppMessage(ChatMessage): @@ -23,9 +18,7 @@ class WechatComAppMessage(ChatMessage): self.content = msg.content elif msg.type == "voice": self.ctype = ContextType.VOICE - self.content = ( - TmpDir().path() + msg.media_id + "." + msg.format - ) # content直接存临时目录路径 + self.content = TmpDir().path() + msg.media_id + "." + msg.format # content直接存临时目录路径 def download_voice(): # 如果响应状态码是200,则将响应内容写入本地文件 @@ -34,9 +27,7 @@ class WechatComAppMessage(ChatMessage): with open(self.content, "wb") as f: f.write(response.content) else: - logger.info( - f"[wechatcom] Failed to download voice file, {response.content}" - ) + logger.info(f"[wechatcom] Failed to download voice file, {response.content}") self._prepare_fn = download_voice elif msg.type == "image": @@ -50,15 +41,11 @@ class WechatComAppMessage(ChatMessage): with open(self.content, "wb") as f: f.write(response.content) else: - logger.info( - f"[wechatcom] Failed to download image file, {response.content}" - ) + 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) - ) + raise NotImplementedError("Unsupported message type: Type:{} ".format(msg.type)) self.from_user_id = msg.source self.to_user_id = msg.target From 73c87d59597270c08e5d7e5bf52a474ccb5014d4 Mon Sep 17 00:00:00 2001 From: lanvent Date: Tue, 25 Apr 2023 01:48:15 +0800 Subject: [PATCH 11/12] fix(wechatcomapp): split long text messages into multiple parts --- channel/wechatcom/README.md | 6 ++++-- channel/wechatcom/wechatcomapp_channel.py | 16 +++++++++++++--- channel/wechatmp/common.py | 17 ----------------- channel/wechatmp/passive_reply.py | 1 + channel/wechatmp/wechatmp_channel.py | 5 ++++- common/utils.py | 17 +++++++++++++++++ 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/channel/wechatcom/README.md b/channel/wechatcom/README.md index 1728678..e3b4843 100644 --- a/channel/wechatcom/README.md +++ b/channel/wechatcom/README.md @@ -1,6 +1,8 @@ # 企业微信应用号channel -企业微信官方提供了客服、应用等API,本channel使用的是企业微信的应用API的能力。因为未来可能还会开发客服能力,所以本channel的类型名叫作`wechatcom_app`。 +企业微信官方提供了客服、应用等API,本channel使用的是企业微信的应用API的能力。 + +因为未来可能还会开发客服能力,所以本channel的类型名叫作`wechatcom_app`。 `wechatcom_app` channel支持插件系统和图片声音交互等能力,除了无法加入群聊,作为个人使用的私人助理已绰绰有余。 @@ -29,7 +31,7 @@ - 在详情页如果点击`企业可信IP`的配置(没看到可以不管),填入你服务器的公网IP - 点击`接收消息`下的启用API接收消息 -- `URL`填写格式为`http://url:port/wxcomapp`,是程序监听的端口,默认是9898 +- `URL`填写格式为`http://url:port/wxcomapp`,`port`是程序监听的端口,默认是9898 如果是未认证的企业,url可直接使用服务器的IP。如果是认证企业,需要使用备案的域名,可使用二级域名。 - `Token`可随意填写,停留在这个页面 - 在程序根目录`config.json`中增加配置(**去掉注释**),`wechatcomapp_aes_key`是当前页面的`wechatcomapp_aes_key` diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index bd51c5f..4d686eb 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -2,6 +2,7 @@ import io import os import textwrap +import time import requests import web @@ -17,10 +18,12 @@ from channel.wechatcom.wechatcomapp_client import WechatComAppClient from channel.wechatcom.wechatcomapp_message import WechatComAppMessage from common.log import logger from common.singleton import singleton -from common.utils import compress_imgfile, fsize +from common.utils import compress_imgfile, fsize, split_string_by_utf8_length from config import conf from voice.audio_convert import any_to_amr +MAX_UTF8_LEN = 2048 + @singleton class WechatComAppChannel(ChatChannel): @@ -50,8 +53,15 @@ class WechatComAppChannel(ChatChannel): def send(self, reply: Reply, context: Context): receiver = context["receiver"] if reply.type in [ReplyType.TEXT, ReplyType.ERROR, ReplyType.INFO]: - self.client.message.send_text(self.agent_id, receiver, reply.content) - logger.info("[wechatcom] sendMsg={}, receiver={}".format(reply, receiver)) + 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) + if i != len(texts) - 1: + time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序 + logger.info("[wechatcom] Do send text to {}: {}".format(receiver, reply_text)) elif reply.type == ReplyType.VOICE: try: file_path = reply.content diff --git a/channel/wechatmp/common.py b/channel/wechatmp/common.py index b6f206c..be0b800 100644 --- a/channel/wechatmp/common.py +++ b/channel/wechatmp/common.py @@ -43,20 +43,3 @@ def subscribe_msg(): 输入'{trigger_prefix}#帮助' 查看详细指令。""" ) return msg - - -def split_string_by_utf8_length(string, max_length, max_split=0): - encoded = string.encode("utf-8") - start, end = 0, 0 - result = [] - while end < len(encoded): - if max_split > 0 and len(result) >= max_split: - result.append(encoded[start:].decode("utf-8")) - break - end = min(start + max_length, len(encoded)) - # 如果当前字节不是 UTF-8 编码的开始字节,则向前查找直到找到开始字节为止 - while end < len(encoded) and (encoded[end] & 0b11000000) == 0b10000000: - end -= 1 - result.append(encoded[start:end].decode("utf-8")) - start = end - return result diff --git a/channel/wechatmp/passive_reply.py b/channel/wechatmp/passive_reply.py index cd0f012..38fee6d 100644 --- a/channel/wechatmp/passive_reply.py +++ b/channel/wechatmp/passive_reply.py @@ -11,6 +11,7 @@ from channel.wechatmp.common import * from channel.wechatmp.wechatmp_channel import WechatMPChannel from channel.wechatmp.wechatmp_message import WeChatMPMessage from common.log import logger +from common.utils import split_string_by_utf8_length from config import conf diff --git a/channel/wechatmp/wechatmp_channel.py b/channel/wechatmp/wechatmp_channel.py index aa1fc74..0c54a1d 100644 --- a/channel/wechatmp/wechatmp_channel.py +++ b/channel/wechatmp/wechatmp_channel.py @@ -18,6 +18,7 @@ from channel.wechatmp.common import * from channel.wechatmp.wechatmp_client import WechatMPClient from common.log import logger from common.singleton import singleton +from common.utils import split_string_by_utf8_length from config import conf from voice.audio_convert import any_to_mp3 @@ -140,8 +141,10 @@ class WechatMPChannel(ChatChannel): texts = split_string_by_utf8_length(reply_text, MAX_UTF8_LEN) if len(texts) > 1: logger.info("[wechatmp] text too long, split into {} parts".format(len(texts))) - for text in texts: + for i, text in enumerate(texts): self.client.message.send_text(receiver, text) + if i != len(texts) - 1: + time.sleep(0.5) # 休眠0.5秒,防止发送过快乱序 logger.info("[wechatmp] Do send text to {}: {}".format(receiver, reply_text)) elif reply.type == ReplyType.VOICE: try: diff --git a/common/utils.py b/common/utils.py index 4d055f3..966a7cf 100644 --- a/common/utils.py +++ b/common/utils.py @@ -32,3 +32,20 @@ def compress_imgfile(file, max_size): if fsize(out_buf) <= max_size: return out_buf quality -= 5 + + +def split_string_by_utf8_length(string, max_length, max_split=0): + encoded = string.encode("utf-8") + start, end = 0, 0 + result = [] + while end < len(encoded): + if max_split > 0 and len(result) >= max_split: + result.append(encoded[start:].decode("utf-8")) + break + end = min(start + max_length, len(encoded)) + # 如果当前字节不是 UTF-8 编码的开始字节,则向前查找直到找到开始字节为止 + while end < len(encoded) and (encoded[end] & 0b11000000) == 0b10000000: + end -= 1 + result.append(encoded[start:end].decode("utf-8")) + start = end + return result From 20b71f206b79ddd9e752229116c6737ec85a6dd0 Mon Sep 17 00:00:00 2001 From: lanvent Date: Tue, 25 Apr 2023 23:58:46 +0800 Subject: [PATCH 12/12] feat: add subscribe_msg option for wechatmp, wechatmp_service, and wechatcom_app channels --- README.md | 7 +++++-- channel/wechatcom/wechatcomapp_channel.py | 22 ++++++---------------- channel/wechatmp/active_reply.py | 9 +++++---- channel/wechatmp/common.py | 18 ------------------ channel/wechatmp/passive_reply.py | 10 +++++----- config-template.json | 3 ++- config.py | 16 ++++++++++++++-- 7 files changed, 37 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 2fad81b..80004df 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ pip3 install azure-cognitiveservices-speech cp config-template.json config.json ``` -然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改: +然后在`config.json`中填入配置,以下是对默认配置的说明,可根据需要进行自定义修改(请去掉注释): ```bash # config.json文件内容示例 @@ -115,7 +115,9 @@ pip3 install azure-cognitiveservices-speech "speech_recognition": false, # 是否开启语音识别 "group_speech_recognition": false, # 是否开启群组语音识别 "use_azure_chatgpt": false, # 是否使用Azure ChatGPT service代替openai ChatGPT service. 当设置为true时需要设置 open_ai_api_base,如 https://xxx.openai.azure.com/ - "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述, + "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", # 人格描述 + # 订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复,可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。 + "subscribe_msg": "感谢您的关注!\n这里是ChatGPT,可以自由对话。\n支持语音对话。\n支持图片输出,画字开头的消息将按要求创作图片。\n支持角色扮演和文字冒险等丰富插件。\n输入{trigger_prefix}#help 查看详细指令。" } ``` **配置说明:** @@ -150,6 +152,7 @@ pip3 install azure-cognitiveservices-speech + `clear_memory_commands`: 对话内指令,主动清空前文记忆,字符串数组可自定义指令别名。 + `hot_reload`: 程序退出后,暂存微信扫码状态,默认关闭。 + `character_desc` 配置中保存着你对机器人说的一段话,他会记住这段话并作为他的设定,你可以为他定制任何人格 (关于会话上下文的更多内容参考该 [issue](https://github.com/zhayujie/chatgpt-on-wechat/issues/43)) ++ `subscribe_msg`:订阅消息,公众号和企业微信channel中请填写,当被订阅时会自动回复, 可使用特殊占位符。目前支持的占位符有{trigger_prefix},在程序中它会自动替换成bot的触发词。 **所有可选的配置项均在该[文件](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py)中列出。** diff --git a/channel/wechatcom/wechatcomapp_channel.py b/channel/wechatcom/wechatcomapp_channel.py index 4d686eb..d4c090d 100644 --- a/channel/wechatcom/wechatcomapp_channel.py +++ b/channel/wechatcom/wechatcomapp_channel.py @@ -1,7 +1,6 @@ # -*- coding=utf-8 -*- import io import os -import textwrap import time import requests @@ -19,7 +18,7 @@ from channel.wechatcom.wechatcomapp_message import WechatComAppMessage from common.log import logger from common.singleton import singleton from common.utils import compress_imgfile, fsize, split_string_by_utf8_length -from config import conf +from config import conf, subscribe_msg from voice.audio_convert import any_to_amr MAX_UTF8_LEN = 2048 @@ -147,20 +146,11 @@ class Query: logger.debug("[wechatcom] receive message: {}, msg= {}".format(message, msg)) if msg.type == "event": if msg.event == "subscribe": - trigger_prefix = conf().get("single_chat_prefix", [""])[0] - reply_content = textwrap.dedent( - f"""\ - 感谢您的关注! - 这里是ChatGPT,可以自由对话。 - 支持语音对话。 - 支持通用表情输入。 - 支持图片输入输出。 - 支持角色扮演和文字冒险两种定制模式对话。 - 输入'{trigger_prefix}#help' 查看详细指令。""" - ) - reply = create_reply(reply_content, msg).render() - res = channel.crypto.encrypt_message(reply, nonce, timestamp) - return res + 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) diff --git a/channel/wechatmp/active_reply.py b/channel/wechatmp/active_reply.py index 12975a5..10649cd 100644 --- a/channel/wechatmp/active_reply.py +++ b/channel/wechatmp/active_reply.py @@ -10,7 +10,7 @@ from channel.wechatmp.common import * from channel.wechatmp.wechatmp_channel import WechatMPChannel from channel.wechatmp.wechatmp_message import WeChatMPMessage from common.log import logger -from config import conf +from config import conf, subscribe_msg # This class is instantiated once per query @@ -66,13 +66,14 @@ class Query: logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source)) if msg.event in ["subscribe", "subscribe_scan"]: reply_text = subscribe_msg() - replyPost = create_reply(reply_text, msg) - return encrypt_func(replyPost.render()) + if reply_text: + replyPost = create_reply(reply_text, msg) + return encrypt_func(replyPost.render()) else: return "success" else: logger.info("暂且不处理") - return "success" + return "success" except Exception as exc: logger.exception(exc) return exc diff --git a/channel/wechatmp/common.py b/channel/wechatmp/common.py index be0b800..e1cbe7b 100644 --- a/channel/wechatmp/common.py +++ b/channel/wechatmp/common.py @@ -1,5 +1,3 @@ -import textwrap - import web from wechatpy.crypto import WeChatCrypto from wechatpy.exceptions import InvalidSignatureException @@ -27,19 +25,3 @@ def verify_server(data): raise web.Forbidden("Invalid signature") except Exception as e: raise web.Forbidden(str(e)) - - -def subscribe_msg(): - trigger_prefix = conf().get("single_chat_prefix", [""])[0] - msg = textwrap.dedent( - f"""\ - 感谢您的关注! - 这里是ChatGPT,可以自由对话。 - 资源有限,回复较慢,请勿着急。 - 支持语音对话。 - 支持图片输入。 - 支持图片输出,画字开头的消息将按要求创作图片。 - 支持tool、角色扮演和文字冒险等丰富的插件。 - 输入'{trigger_prefix}#帮助' 查看详细指令。""" - ) - return msg diff --git a/channel/wechatmp/passive_reply.py b/channel/wechatmp/passive_reply.py index 38fee6d..d926b2a 100644 --- a/channel/wechatmp/passive_reply.py +++ b/channel/wechatmp/passive_reply.py @@ -12,7 +12,7 @@ from channel.wechatmp.wechatmp_channel import WechatMPChannel from channel.wechatmp.wechatmp_message import WeChatMPMessage from common.log import logger from common.utils import split_string_by_utf8_length -from config import conf +from config import conf, subscribe_msg # This class is instantiated once per query @@ -200,14 +200,14 @@ class Query: logger.info("[wechatmp] Event {} from {}".format(msg.event, msg.source)) if msg.event in ["subscribe", "subscribe_scan"]: reply_text = subscribe_msg() - replyPost = create_reply(reply_text, msg) - return encrypt_func(replyPost.render()) + if reply_text: + replyPost = create_reply(reply_text, msg) + return encrypt_func(replyPost.render()) else: return "success" - else: logger.info("暂且不处理") - return "success" + return "success" except Exception as exc: logger.exception(exc) return exc diff --git a/config-template.json b/config-template.json index 864aa03..51187c4 100644 --- a/config-template.json +++ b/config-template.json @@ -27,5 +27,6 @@ "voice_reply_voice": false, "conversation_max_tokens": 1000, "expires_in_seconds": 3600, - "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。" + "character_desc": "你是ChatGPT, 一个由OpenAI训练的大型语言模型, 你旨在回答并解决人们的任何问题,并且可以使用多种语言与人交流。", + "subcribe_msg": "感谢您的关注!\n这里是ChatGPT,可以自由对话。\n支持语音对话。\n支持图片输入。\n支持图片输出,画字开头的消息将按要求创作图片。\n支持tool、角色扮演和文字冒险等丰富的插件。\n输入{trigger_prefix}#help 查看详细指令。" } diff --git a/config.py b/config.py index b645ed3..ae1cfd7 100644 --- a/config.py +++ b/config.py @@ -8,6 +8,7 @@ import pickle from common.log import logger # 将所有可用的配置项写在字典里, 请使用小写字母 +# 此处的配置值无实际意义,程序不会读取此处的配置,仅用于提示格式,请将配置加入到config.json中 available_setting = { # openai api配置 "open_ai_api_key": "", # openai api key @@ -93,6 +94,7 @@ available_setting = { "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 # channel配置 "channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service,wechatcom_app} + "subscribe_msg": "", # 订阅消息, 支持: wechatmp, wechatmp_service, wechatcom_app "debug": False, # 是否开启debug模式,开启后会打印更多日志 "appdata_dir": "", # 数据目录 # 插件配置 @@ -101,8 +103,12 @@ available_setting = { class Config(dict): - def __init__(self, d: dict = {}): - super().__init__(d) + def __init__(self, d=None): + super().__init__() + if d is None: + d = {} + for k, v in d.items(): + self[k] = v # user_datas: 用户数据,key为用户名,value为用户数据,也是dict self.user_datas = {} @@ -210,3 +216,9 @@ def get_appdata_dir(): logger.info("[INIT] data path not exists, create it: {}".format(data_path)) os.makedirs(data_path) return data_path + + +def subscribe_msg(): + trigger_prefix = conf().get("single_chat_prefix", [""])[0] + msg = conf().get("subscribe_msg", "") + return msg.format(trigger_prefix=trigger_prefix)