diff --git a/.gitignore b/.gitignore index 3901b73..3c58246 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea +.vscode .wechaty/ __pycache__/ venv* diff --git a/app.py b/app.py index 8aaabf5..3e561ae 100644 --- a/app.py +++ b/app.py @@ -36,7 +36,7 @@ def run(): # os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' channel = channel_factory.create_channel(channel_name) - if channel_name in ['wx','wxy','wechatmp','terminal']: + if channel_name in ['wx','wxy','terminal','wechatmp','wechatmp_service']: PluginManager().load_plugins() # startup channel diff --git a/channel/channel_factory.py b/channel/channel_factory.py index 3303ded..e272206 100644 --- a/channel/channel_factory.py +++ b/channel/channel_factory.py @@ -19,5 +19,8 @@ def create_channel(channel_type): return TerminalChannel() elif channel_type == 'wechatmp': from channel.wechatmp.wechatmp_channel import WechatMPChannel - return WechatMPChannel() + return WechatMPChannel(passive_reply = True) + elif channel_type == 'wechatmp_service': + from channel.wechatmp.wechatmp_channel import WechatMPChannel + return WechatMPChannel(passive_reply = False) raise RuntimeError diff --git a/channel/wechatmp/README.md b/channel/wechatmp/README.md index d157ecd..5fb2eda 100644 --- a/channel/wechatmp/README.md +++ b/channel/wechatmp/README.md @@ -1,10 +1,11 @@ -# 个人微信公众号channel +# 微信公众号channel -鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了个人微信公众号channel,提供无风险的服务。 -但是由于个人微信公众号的众多接口限制,目前支持的功能有限,实现简陋,提供了一个最基本的文本对话服务,支持加载插件,优化了命令格式,支持私有api_key。暂未实现图片输入输出、语音输入输出等交互形式。 -如有公众号是企业主体且可以通过微信认证,即可获得更多接口,解除大多数限制。欢迎大家提供更多的支持。 +鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。 +目前支持订阅号(个人)和服务号(企业)两种类型的公众号,它们的主要区别就是被动回复和主动回复。 +个人微信订阅号有许多接口限制,目前仅支持最基本的文本对话和语音输入,支持加载插件,支持私有api_key。 +暂未实现图片输入输出、语音输出等交互形式。 -## 使用方法 +## 使用方法(订阅号,服务号类似) 在开始部署前,你需要一个拥有公网IP的服务器,以提供微信服务器和我们自己服务器的连接。或者你需要进行内网穿透,否则微信服务器无法将消息发送给我们的服务器。 @@ -21,8 +22,10 @@ pip3 install web.py 相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加 ``` "channel_type": "wechatmp", -"wechatmp_token": "your Token", -"wechatmp_port": 8080, +"wechatmp_token": "Token", # 微信公众平台的Token +"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 +"wechatmp_app_id": "", # 微信公众平台的appID,仅服务号需要 +"wechatmp_app_secret": "", # 微信公众平台的appsecret,仅服务号需要 ``` 然后运行`python3 app.py`启动web服务器。这里会默认监听8080端口,但是微信公众号的服务器配置只支持80/443端口,有两种方法来解决这个问题。第一个是推荐的方法,使用端口转发命令将80端口转发到8080端口(443同理,注意需要支持SSL,也就是https的访问,在`wechatmp_channel.py`需要修改相应的证书路径): ``` @@ -35,7 +38,7 @@ sudo iptables-save > /etc/iptables/rules.v4 随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。 ## 个人微信公众号的限制 -由于目前测试的公众号不是企业主体,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 +由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 另外,由于微信官方的限制,自动回复有长度限制。因此这里将ChatGPT的回答拆分,分成每段600字回复(限制大约在700字)。 @@ -43,4 +46,9 @@ sudo iptables-save > /etc/iptables/rules.v4 公共api有访问频率限制(免费账号每分钟最多20次ChatGPT的API调用),这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。 ## 测试范围 -目前在`RoboStyle`这个公众号上进行了测试,感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件,其他的插件还没有测试。百度的接口暂未测试。语音对话没有测试。图片直接以链接形式回复(没有临时素材上传接口的权限)。 \ No newline at end of file +目前在`RoboStyle`这个公众号上进行了测试(基于[wechatmp-stable分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp-stable),而[master分支](https://github.com/zhayujie/chatgpt-on-wechat)含有最新功能,但是稳定性有待测试),感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件,其他的插件还没有测试。百度的接口暂未测试。语音对话没有测试。图片直接以链接形式回复(没有临时素材上传接口的权限)。 + +## TODO +* 服务号交互完善 +* 服务号使用临时素材接口,提供图片回复能力 +* 插件测试 diff --git a/channel/wechatmp/ServiceAccount.py b/channel/wechatmp/ServiceAccount.py new file mode 100644 index 0000000..db9dff3 --- /dev/null +++ b/channel/wechatmp/ServiceAccount.py @@ -0,0 +1,51 @@ +import web +import time +import channel.wechatmp.reply as reply +import channel.wechatmp.receive as receive +from config import conf +from common.log import logger +from bridge.context import * +from channel.wechatmp.common import * +from channel.wechatmp.wechatmp_channel import WechatMPChannel + +# This class is instantiated once per query +class Query(): + + def GET(self): + return verify_server(web.input()) + + def POST(self): + # Make sure to return the instance that first created, @singleton will do that. + channel_instance = WechatMPChannel() + try: + webData = web.data() + # logger.debug("[wechatmp] Receive request:\n" + webData.decode("utf-8")) + wechatmp_msg = receive.parse_xml(webData) + if wechatmp_msg.msg_type == 'text': + from_user = wechatmp_msg.from_user_id + message = wechatmp_msg.content.decode("utf-8") + message_id = wechatmp_msg.msg_id + + logger.info("[wechatmp] {}:{} Receive post query {} {}: {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), from_user, message_id, message)) + context = channel_instance._compose_context(ContextType.TEXT, message, isgroup=False, msg=wechatmp_msg) + if context: + # set private openai_api_key + # if from_user is not changed in itchat, this can be placed at chat_channel + user_data = conf().get_user_data(from_user) + context['openai_api_key'] = user_data.get('openai_api_key') # None or user openai_api_key + channel_instance.produce(context) + # The reply will be sent by channel_instance.send() in another thread + return "success" + + elif wechatmp_msg.msg_type == 'event': + logger.info("[wechatmp] Event {} from {}".format(wechatmp_msg.Event, wechatmp_msg.from_user_id)) + content = subscribe_msg() + replyMsg = reply.TextMsg(wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content) + return replyMsg.send() + else: + logger.info("暂且不处理") + return "success" + except Exception as exc: + logger.exception(exc) + return exc + diff --git a/channel/wechatmp/SubscribeAccount.py b/channel/wechatmp/SubscribeAccount.py new file mode 100644 index 0000000..745ef0e --- /dev/null +++ b/channel/wechatmp/SubscribeAccount.py @@ -0,0 +1,165 @@ +import web +import time +import channel.wechatmp.reply as reply +import channel.wechatmp.receive as receive +from config import conf +from common.log import logger +from bridge.context import * +from channel.wechatmp.common import * +from channel.wechatmp.wechatmp_channel import WechatMPChannel + +# This class is instantiated once per query +class Query(): + + def GET(self): + return verify_server(web.input()) + + def POST(self): + # Make sure to return the instance that first created, @singleton will do that. + channel = WechatMPChannel() + try: + query_time = time.time() + webData = web.data() + logger.debug("[wechatmp] Receive request:\n" + webData.decode("utf-8")) + wechatmp_msg = receive.parse_xml(webData) + if wechatmp_msg.msg_type == 'text': + from_user = wechatmp_msg.from_user_id + to_user = wechatmp_msg.to_user_id + message = wechatmp_msg.content.decode("utf-8") + message_id = wechatmp_msg.msg_id + + logger.info("[wechatmp] {}:{} Receive post query {} {}: {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), from_user, message_id, message)) + supported = True + if "【收到不支持的消息类型,暂无法显示】" in message: + supported = False # not supported, used to refresh + cache_key = from_user + + reply_text = "" + # New request + if cache_key not in channel.cache_dict and cache_key not in channel.running: + # The first query begin, reset the cache + context = channel._compose_context(ContextType.TEXT, message, isgroup=False, msg=wechatmp_msg) + logger.debug("[wechatmp] context: {} {}".format(context, wechatmp_msg)) + if message_id in channel.received_msgs: # received and finished + return + if supported and context: + # set private openai_api_key + # if from_user is not changed in itchat, this can be placed at chat_channel + user_data = conf().get_user_data(from_user) + context['openai_api_key'] = user_data.get('openai_api_key') # None or user openai_api_key + channel.received_msgs[message_id] = wechatmp_msg + channel.running.add(cache_key) + channel.produce(context) + else: + trigger_prefix = conf().get('single_chat_prefix',[''])[0] + if trigger_prefix or not supported: + if trigger_prefix: + content = textwrap.dedent(f"""\ + 请输入'{trigger_prefix}'接你想说的话跟我说话。 + 例如: + {trigger_prefix}你好,很高兴见到你。""") + else: + content = textwrap.dedent("""\ + 你好,很高兴见到你。 + 请跟我说话吧。""") + else: + logger.error(f"[wechatmp] unknown error") + content = textwrap.dedent("""\ + 未知错误,请稍后再试""") + replyMsg = reply.TextMsg(wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content) + return replyMsg.send() + channel.query1[cache_key] = False + channel.query2[cache_key] = False + channel.query3[cache_key] = False + # Request again + elif cache_key in channel.running and channel.query1.get(cache_key) == True and channel.query2.get(cache_key) == True and channel.query3.get(cache_key) == True: + channel.query1[cache_key] = False #To improve waiting experience, this can be set to True. + channel.query2[cache_key] = False #To improve waiting experience, this can be set to True. + channel.query3[cache_key] = False + elif cache_key in channel.cache_dict: + # Skip the waiting phase + channel.query1[cache_key] = True + channel.query2[cache_key] = True + channel.query3[cache_key] = True + + assert not (cache_key in channel.cache_dict and cache_key in channel.running) + + if channel.query1.get(cache_key) == False: + # The first query from wechat official server + logger.debug("[wechatmp] query1 {}".format(cache_key)) + channel.query1[cache_key] = True + cnt = 0 + while cache_key not in channel.cache_dict and cnt < 45: + cnt = cnt + 1 + time.sleep(0.1) + if cnt == 45: + # waiting for timeout (the POST query will be closed by wechat official server) + time.sleep(1) + # and do nothing + return + else: + pass + elif channel.query2.get(cache_key) == False: + # The second query from wechat official server + logger.debug("[wechatmp] query2 {}".format(cache_key)) + channel.query2[cache_key] = True + cnt = 0 + while cache_key not in channel.cache_dict and cnt < 45: + cnt = cnt + 1 + time.sleep(0.1) + if cnt == 45: + # waiting for timeout (the POST query will be closed by wechat official server) + time.sleep(1) + # and do nothing + return + else: + pass + elif channel.query3.get(cache_key) == False: + # The third query from wechat official server + logger.debug("[wechatmp] query3 {}".format(cache_key)) + channel.query3[cache_key] = True + cnt = 0 + while cache_key not in channel.cache_dict and cnt < 40: + cnt = cnt + 1 + time.sleep(0.1) + if cnt == 40: + # Have waiting for 3x5 seconds + # return timeout message + reply_text = "【正在思考中,回复任意文字尝试获取回复】" + logger.info("[wechatmp] Three queries has finished For {}: {}".format(from_user, message_id)) + replyPost = reply.TextMsg(from_user, to_user, reply_text).send() + return replyPost + else: + pass + + if float(time.time()) - float(query_time) > 4.8: + reply_text = "【正在思考中,回复任意文字尝试获取回复】" + logger.info("[wechatmp] Timeout for {} {}, return".format(from_user, message_id)) + replyPost = reply.TextMsg(from_user, to_user, reply_text).send() + return replyPost + + if cache_key in channel.cache_dict: + content = channel.cache_dict[cache_key] + if len(content.encode('utf8'))<=MAX_UTF8_LEN: + reply_text = channel.cache_dict[cache_key] + channel.cache_dict.pop(cache_key) + else: + continue_text = "\n【未完待续,回复任意文字以继续】" + splits = split_string_by_utf8_length(content, MAX_UTF8_LEN - len(continue_text.encode('utf-8')), max_split= 1) + reply_text = splits[0] + continue_text + channel.cache_dict[cache_key] = splits[1] + logger.info("[wechatmp] {}:{} Do send {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), reply_text)) + replyPost = reply.TextMsg(from_user, to_user, reply_text).send() + return replyPost + + elif wechatmp_msg.msg_type == 'event': + logger.info("[wechatmp] Event {} from {}".format(wechatmp_msg.content, wechatmp_msg.from_user_id)) + content = subscribe_msg() + replyMsg = reply.TextMsg(wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content) + return replyMsg.send() + else: + logger.info("暂且不处理") + return "success" + except Exception as exc: + logger.exception(exc) + return exc \ No newline at end of file diff --git a/channel/wechatmp/common.py b/channel/wechatmp/common.py new file mode 100644 index 0000000..27609e0 --- /dev/null +++ b/channel/wechatmp/common.py @@ -0,0 +1,63 @@ +from config import conf +import hashlib +import textwrap + +MAX_UTF8_LEN = 2048 + +class WeChatAPIException(Exception): + pass + + +def verify_server(data): + try: + if len(data) == 0: + return "None" + signature = data.signature + timestamp = data.timestamp + nonce = data.nonce + echostr = data.echostr + token = conf().get('wechatmp_token') #请按照公众平台官网\基本配置中信息填写 + + data_list = [token, timestamp, nonce] + data_list.sort() + sha1 = hashlib.sha1() + # map(sha1.update, data_list) #python2 + sha1.update("".join(data_list).encode('utf-8')) + hashcode = sha1.hexdigest() + print("handle/GET func: hashcode, signature: ", hashcode, signature) + if hashcode == signature: + return echostr + else: + return "" + except Exception as Argument: + return Argument + +def subscribe_msg(): + trigger_prefix = conf().get('single_chat_prefix',[''])[0] + msg = textwrap.dedent(f"""\ + 感谢您的关注! + 这里是ChatGPT,可以自由对话。 + 资源有限,回复较慢,请勿着急。 + 支持通用表情输入。 + 暂时不支持图片输入。 + 支持图片输出,画字开头的问题将回复图片链接。 + 支持角色扮演和文字冒险两种定制模式对话。 + 输入'{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 = start + max_length + # 如果当前字节不是 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 \ No newline at end of file diff --git a/channel/wechatmp/wechatmp_channel.py b/channel/wechatmp/wechatmp_channel.py index fb2b7fa..49f45e0 100644 --- a/channel/wechatmp/wechatmp_channel.py +++ b/channel/wechatmp/wechatmp_channel.py @@ -1,20 +1,17 @@ # -*- coding: utf-8 -*- import web import time -import math -import hashlib -import textwrap -from channel.chat_channel import ChatChannel -import channel.wechatmp.reply as reply -import channel.wechatmp.receive as receive -from common.expired_dict import ExpiredDict +import json +import requests +import threading from common.singleton import singleton from common.log import logger +from common.expired_dict import ExpiredDict from config import conf from bridge.reply import * from bridge.context import * -from plugins import * -import traceback +from channel.chat_channel import ChatChannel +from channel.wechatmp.common import * # If using SSL, uncomment the following lines, and modify the certificate path. # from cheroot.server import HTTPServer @@ -23,37 +20,101 @@ import traceback # certificate='/ssl/cert.pem', # private_key='/ssl/cert.key') - -# from concurrent.futures import ThreadPoolExecutor -# thread_pool = ThreadPoolExecutor(max_workers=8) - -MAX_UTF8_LEN = 2048 @singleton class WechatMPChannel(ChatChannel): - NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE, ReplyType.VOICE] - def __init__(self): + def __init__(self, passive_reply = True): super().__init__() - self.cache_dict = dict() + self.passive_reply = passive_reply self.running = set() - self.query1 = dict() - self.query2 = dict() - self.query3 = dict() - self.received_msgs = ExpiredDict(60*60*24) + self.received_msgs = ExpiredDict(60*60*24) + if self.passive_reply: + self.NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE, ReplyType.VOICE] + self.cache_dict = dict() + self.query1 = dict() + self.query2 = dict() + self.query3 = dict() + else: + # TODO support image + self.NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE, ReplyType.VOICE] + self.app_id = conf().get('wechatmp_app_id') + self.app_secret = conf().get('wechatmp_app_secret') + self.access_token = None + self.access_token_expires_time = 0 + self.access_token_lock = threading.Lock() + self.get_access_token() def startup(self): - urls = ( - '/wx', 'SubsribeAccountQuery', - ) + if self.passive_reply: + urls = ('/wx', 'channel.wechatmp.SubscribeAccount.Query') + else: + urls = ('/wx', 'channel.wechatmp.ServiceAccount.Query') app = web.application(urls, globals(), autoreload=False) port = conf().get('wechatmp_port', 8080) web.httpserver.runsimple(app.wsgifunc(), ('0.0.0.0', port)) + def wechatmp_request(self, method, url, **kwargs): + r = requests.request(method=method, url=url, **kwargs) + r.raise_for_status() + r.encoding = "utf-8" + ret = r.json() + if "errcode" in ret and ret["errcode"] != 0: + raise WeChatAPIException("{}".format(ret)) + return ret + + def get_access_token(self): + + # return the access_token + if self.access_token: + if self.access_token_expires_time - time.time() > 60: + return self.access_token + + # Get new access_token + # Do not request access_token in parallel! Only the last obtained is valid. + if self.access_token_lock.acquire(blocking=False): + # Wait for other threads that have previously obtained access_token to complete the request + # This happens every 2 hours, so it doesn't affect the experience very much + time.sleep(1) + self.access_token = None + url="https://api.weixin.qq.com/cgi-bin/token" + params={ + "grant_type": "client_credential", + "appid": self.app_id, + "secret": self.app_secret + } + data = self.wechatmp_request(method='get', url=url, params=params) + self.access_token = data['access_token'] + self.access_token_expires_time = int(time.time()) + data['expires_in'] + logger.info("[wechatmp] access_token: {}".format(self.access_token)) + self.access_token_lock.release() + else: + # Wait for token update + while self.access_token_lock.locked(): + time.sleep(0.1) + return self.access_token + def send(self, reply: Reply, context: Context): - receiver = context["receiver"] - self.cache_dict[receiver] = reply.content - self.running.remove(receiver) - logger.debug("[send] reply to {} saved to cache: {}".format(receiver, reply)) + if self.passive_reply: + receiver = context["receiver"] + self.cache_dict[receiver] = reply.content + self.running.remove(receiver) + logger.debug("[send] reply to {} saved to cache: {}".format(receiver, reply)) + else: + receiver = context["receiver"] + reply_text = reply.content + url="https://api.weixin.qq.com/cgi-bin/message/custom/send" + params = { + "access_token": self.get_access_token() + } + json_data = { + "touser": receiver, + "msgtype": "text", + "text": {"content": reply_text} + } + self.wechatmp_request(method='post', url=url, params=params, data=json.dumps(json_data, ensure_ascii=False).encode('utf8')) + logger.info("[send] Do send to {}: {}".format(receiver, reply_text)) + return + def _fail_callback(self, session_id, exception, context, **kwargs): logger.exception("[wechatmp] Fail to generation message to user, msgId={}, exception={}".format(context['msg'].msg_id, exception)) @@ -61,208 +122,4 @@ class WechatMPChannel(ChatChannel): self.running.remove(session_id) -def verify_server(): - try: - data = web.input() - if len(data) == 0: - return "None" - signature = data.signature - timestamp = data.timestamp - nonce = data.nonce - echostr = data.echostr - token = conf().get('wechatmp_token') #请按照公众平台官网\基本配置中信息填写 - - data_list = [token, timestamp, nonce] - data_list.sort() - sha1 = hashlib.sha1() - # map(sha1.update, data_list) #python2 - sha1.update("".join(data_list).encode('utf-8')) - hashcode = sha1.hexdigest() - print("handle/GET func: hashcode, signature: ", hashcode, signature) - if hashcode == signature: - return echostr - else: - return "" - except Exception as Argument: - return Argument - - -# This class is instantiated once per query -class SubsribeAccountQuery(): - - def GET(self): - return verify_server() - - def POST(self): - channel = WechatMPChannel() - try: - query_time = time.time() - webData = web.data() - logger.debug("[wechatmp] Receive request:\n" + webData.decode("utf-8")) - wechat_msg = receive.parse_xml(webData) - if wechat_msg.msg_type == 'text': - from_user = wechat_msg.from_user_id - to_user = wechat_msg.to_user_id - message = wechat_msg.content.decode("utf-8") - message_id = wechat_msg.msg_id - - logger.info("[wechatmp] {}:{} Receive post query {} {}: {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), from_user, message_id, message)) - supported = True - if "【收到不支持的消息类型,暂无法显示】" in message: - supported = False # not supported, used to refresh - cache_key = from_user - - reply_text = "" - # New request - if cache_key not in channel.cache_dict and cache_key not in channel.running: - # The first query begin, reset the cache - context = channel._compose_context(ContextType.TEXT, message, isgroup=False, msg=wechat_msg) - logger.debug("[wechatmp] context: {} {}".format(context, wechat_msg)) - if message_id in channel.received_msgs: # received and finished - return - if supported and context: - # set private openai_api_key - # if from_user is not changed in itchat, this can be placed at chat_channel - user_data = conf().get_user_data(from_user) - context['openai_api_key'] = user_data.get('openai_api_key') # None or user openai_api_key - channel.received_msgs[message_id] = wechat_msg - channel.running.add(cache_key) - channel.produce(context) - else: - trigger_prefix = conf().get('single_chat_prefix',[''])[0] - if trigger_prefix or not supported: - if trigger_prefix: - content = textwrap.dedent(f"""\ - 请输入'{trigger_prefix}'接你想说的话跟我说话。 - 例如: - {trigger_prefix}你好,很高兴见到你。""") - else: - content = textwrap.dedent("""\ - 你好,很高兴见到你。 - 请跟我说话吧。""") - else: - logger.error(f"[wechatmp] unknown error") - content = textwrap.dedent("""\ - 未知错误,请稍后再试""") - replyMsg = reply.TextMsg(wechat_msg.from_user_id, wechat_msg.to_user_id, content) - return replyMsg.send() - channel.query1[cache_key] = False - channel.query2[cache_key] = False - channel.query3[cache_key] = False - # Request again - elif cache_key in channel.running and channel.query1.get(cache_key) == True and channel.query2.get(cache_key) == True and channel.query3.get(cache_key) == True: - channel.query1[cache_key] = False #To improve waiting experience, this can be set to True. - channel.query2[cache_key] = False #To improve waiting experience, this can be set to True. - channel.query3[cache_key] = False - elif cache_key in channel.cache_dict: - # Skip the waiting phase - channel.query1[cache_key] = True - channel.query2[cache_key] = True - channel.query3[cache_key] = True - - assert not (cache_key in channel.cache_dict and cache_key in channel.running) - - if channel.query1.get(cache_key) == False: - # The first query from wechat official server - logger.debug("[wechatmp] query1 {}".format(cache_key)) - channel.query1[cache_key] = True - cnt = 0 - while cache_key not in channel.cache_dict and cnt < 45: - cnt = cnt + 1 - time.sleep(0.1) - if cnt == 45: - # waiting for timeout (the POST query will be closed by wechat official server) - time.sleep(1) - # and do nothing - return - else: - pass - elif channel.query2.get(cache_key) == False: - # The second query from wechat official server - logger.debug("[wechatmp] query2 {}".format(cache_key)) - channel.query2[cache_key] = True - cnt = 0 - while cache_key not in channel.cache_dict and cnt < 45: - cnt = cnt + 1 - time.sleep(0.1) - if cnt == 45: - # waiting for timeout (the POST query will be closed by wechat official server) - time.sleep(1) - # and do nothing - return - else: - pass - elif channel.query3.get(cache_key) == False: - # The third query from wechat official server - logger.debug("[wechatmp] query3 {}".format(cache_key)) - channel.query3[cache_key] = True - cnt = 0 - while cache_key not in channel.cache_dict and cnt < 40: - cnt = cnt + 1 - time.sleep(0.1) - if cnt == 40: - # Have waiting for 3x5 seconds - # return timeout message - reply_text = "【正在思考中,回复任意文字尝试获取回复】" - logger.info("[wechatmp] Three queries has finished For {}: {}".format(from_user, message_id)) - replyPost = reply.TextMsg(from_user, to_user, reply_text).send() - return replyPost - else: - pass - - if float(time.time()) - float(query_time) > 4.8: - reply_text = "【正在思考中,回复任意文字尝试获取回复】" - logger.info("[wechatmp] Timeout for {} {}, return".format(from_user, message_id)) - replyPost = reply.TextMsg(from_user, to_user, reply_text).send() - return replyPost - - if cache_key in channel.cache_dict: - content = channel.cache_dict[cache_key] - if len(content.encode('utf8'))<=MAX_UTF8_LEN: - reply_text = channel.cache_dict[cache_key] - channel.cache_dict.pop(cache_key) - else: - continue_text = "\n【未完待续,回复任意文字以继续】" - splits = split_string_by_utf8_length(content, MAX_UTF8_LEN - len(continue_text.encode('utf-8')), max_split= 1) - reply_text = splits[0] + continue_text - channel.cache_dict[cache_key] = splits[1] - logger.info("[wechatmp] {}:{} Do send {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), reply_text)) - replyPost = reply.TextMsg(from_user, to_user, reply_text).send() - return replyPost - - elif wechat_msg.msg_type == 'event': - logger.info("[wechatmp] Event {} from {}".format(wechat_msg.content, wechat_msg.from_user_id)) - trigger_prefix = conf().get('single_chat_prefix',[''])[0] - content = textwrap.dedent(f"""\ - 感谢您的关注! - 这里是ChatGPT,可以自由对话。 - 资源有限,回复较慢,请勿着急。 - 支持通用表情输入。 - 暂时不支持图片输入。 - 支持图片输出,画字开头的问题将回复图片链接。 - 支持角色扮演和文字冒险两种定制模式对话。 - 输入'{trigger_prefix}#帮助' 查看详细指令。""") - replyMsg = reply.TextMsg(wechat_msg.from_user_id, wechat_msg.to_user_id, content) - return replyMsg.send() - else: - logger.info("暂且不处理") - return "success" - except Exception as exc: - logger.exception(exc) - return exc -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 = start + max_length - # 如果当前字节不是 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 \ No newline at end of file diff --git a/config.py b/config.py index 958b061..56bff7e 100644 --- a/config.py +++ b/config.py @@ -79,14 +79,16 @@ available_setting = { "wechaty_puppet_service_token": "", # wechaty的token # wechatmp的配置 - "wechatmp_token": "", # 微信公众平台的Token - "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 + "wechatmp_token": "", # 微信公众平台的Token + "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 + "wechatmp_app_id": "", # 微信公众平台的appID,仅服务号需要 + "wechatmp_app_secret": "", # 微信公众平台的appsecret,仅服务号需要 # chatgpt指令自定义触发词 "clear_memory_commands": ['#清除记忆'], # 重置会话指令,必须以#开头 # channel配置 - "channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp} + "channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service} "debug": False, # 是否开启debug模式,开启后会打印更多日志