@@ -1,5 +1,6 @@ | |||||
.DS_Store | .DS_Store | ||||
.idea | .idea | ||||
.vscode | |||||
.wechaty/ | .wechaty/ | ||||
__pycache__/ | __pycache__/ | ||||
venv* | venv* | ||||
@@ -36,7 +36,7 @@ def run(): | |||||
# os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | # os.environ['WECHATY_PUPPET_SERVICE_ENDPOINT'] = '127.0.0.1:9001' | ||||
channel = channel_factory.create_channel(channel_name) | channel = channel_factory.create_channel(channel_name) | ||||
if channel_name in ['wx','wxy','wechatmp','terminal']: | |||||
if channel_name in ['wx','wxy','terminal','wechatmp','wechatmp_service']: | |||||
PluginManager().load_plugins() | PluginManager().load_plugins() | ||||
# startup channel | # startup channel | ||||
@@ -19,5 +19,8 @@ def create_channel(channel_type): | |||||
return TerminalChannel() | return TerminalChannel() | ||||
elif channel_type == 'wechatmp': | elif channel_type == 'wechatmp': | ||||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | 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 | raise RuntimeError |
@@ -1,10 +1,11 @@ | |||||
# 个人微信公众号channel | |||||
# 微信公众号channel | |||||
鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了个人微信公众号channel,提供无风险的服务。 | |||||
但是由于个人微信公众号的众多接口限制,目前支持的功能有限,实现简陋,提供了一个最基本的文本对话服务,支持加载插件,优化了命令格式,支持私有api_key。暂未实现图片输入输出、语音输入输出等交互形式。 | |||||
如有公众号是企业主体且可以通过微信认证,即可获得更多接口,解除大多数限制。欢迎大家提供更多的支持。 | |||||
鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。 | |||||
目前支持订阅号(个人)和服务号(企业)两种类型的公众号,它们的主要区别就是被动回复和主动回复。 | |||||
个人微信订阅号有许多接口限制,目前仅支持最基本的文本对话和语音输入,支持加载插件,支持私有api_key。 | |||||
暂未实现图片输入输出、语音输出等交互形式。 | |||||
## 使用方法 | |||||
## 使用方法(订阅号,服务号类似) | |||||
在开始部署前,你需要一个拥有公网IP的服务器,以提供微信服务器和我们自己服务器的连接。或者你需要进行内网穿透,否则微信服务器无法将消息发送给我们的服务器。 | 在开始部署前,你需要一个拥有公网IP的服务器,以提供微信服务器和我们自己服务器的连接。或者你需要进行内网穿透,否则微信服务器无法将消息发送给我们的服务器。 | ||||
@@ -21,8 +22,10 @@ pip3 install web.py | |||||
相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加 | 相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加 | ||||
``` | ``` | ||||
"channel_type": "wechatmp", | "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`需要修改相应的证书路径): | 然后运行`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的自动回复。 | 随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。 | ||||
## 个人微信公众号的限制 | ## 个人微信公众号的限制 | ||||
由于目前测试的公众号不是企业主体,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 | |||||
由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 | |||||
另外,由于微信官方的限制,自动回复有长度限制。因此这里将ChatGPT的回答拆分,分成每段600字回复(限制大约在700字)。 | 另外,由于微信官方的限制,自动回复有长度限制。因此这里将ChatGPT的回答拆分,分成每段600字回复(限制大约在700字)。 | ||||
@@ -43,4 +46,9 @@ sudo iptables-save > /etc/iptables/rules.v4 | |||||
公共api有访问频率限制(免费账号每分钟最多20次ChatGPT的API调用),这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。 | 公共api有访问频率限制(免费账号每分钟最多20次ChatGPT的API调用),这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。 | ||||
## 测试范围 | ## 测试范围 | ||||
目前在`RoboStyle`这个公众号上进行了测试,感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件,其他的插件还没有测试。百度的接口暂未测试。语音对话没有测试。图片直接以链接形式回复(没有临时素材上传接口的权限)。 | |||||
目前在`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 | |||||
* 服务号交互完善 | |||||
* 服务号使用临时素材接口,提供图片回复能力 | |||||
* 插件测试 |
@@ -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 | |||||
@@ -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 |
@@ -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 |
@@ -1,20 +1,17 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
import web | import web | ||||
import time | 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.singleton import singleton | ||||
from common.log import logger | from common.log import logger | ||||
from common.expired_dict import ExpiredDict | |||||
from config import conf | from config import conf | ||||
from bridge.reply import * | from bridge.reply import * | ||||
from bridge.context 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. | # If using SSL, uncomment the following lines, and modify the certificate path. | ||||
# from cheroot.server import HTTPServer | # from cheroot.server import HTTPServer | ||||
@@ -23,37 +20,101 @@ import traceback | |||||
# certificate='/ssl/cert.pem', | # certificate='/ssl/cert.pem', | ||||
# private_key='/ssl/cert.key') | # private_key='/ssl/cert.key') | ||||
# from concurrent.futures import ThreadPoolExecutor | |||||
# thread_pool = ThreadPoolExecutor(max_workers=8) | |||||
MAX_UTF8_LEN = 2048 | |||||
@singleton | @singleton | ||||
class WechatMPChannel(ChatChannel): | class WechatMPChannel(ChatChannel): | ||||
NOT_SUPPORT_REPLYTYPE = [ReplyType.IMAGE, ReplyType.VOICE] | |||||
def __init__(self): | |||||
def __init__(self, passive_reply = True): | |||||
super().__init__() | super().__init__() | ||||
self.cache_dict = dict() | |||||
self.passive_reply = passive_reply | |||||
self.running = set() | 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): | 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) | app = web.application(urls, globals(), autoreload=False) | ||||
port = conf().get('wechatmp_port', 8080) | port = conf().get('wechatmp_port', 8080) | ||||
web.httpserver.runsimple(app.wsgifunc(), ('0.0.0.0', port)) | 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): | 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): | 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)) | 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) | 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 |
@@ -79,14 +79,16 @@ available_setting = { | |||||
"wechaty_puppet_service_token": "", # wechaty的token | "wechaty_puppet_service_token": "", # wechaty的token | ||||
# wechatmp的配置 | # 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指令自定义触发词 | # chatgpt指令自定义触发词 | ||||
"clear_memory_commands": ['#清除记忆'], # 重置会话指令,必须以#开头 | "clear_memory_commands": ['#清除记忆'], # 重置会话指令,必须以#开头 | ||||
# channel配置 | # channel配置 | ||||
"channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp} | |||||
"channel_type": "wx", # 通道类型,支持:{wx,wxy,terminal,wechatmp,wechatmp_service} | |||||
"debug": False, # 是否开启debug模式,开启后会打印更多日志 | "debug": False, # 是否开启debug模式,开启后会打印更多日志 | ||||