- Add support for message encryption in WeChat MP channel. - Add `wechatmp_aes_key` configuration item to `config.json`.master
@@ -1,7 +1,7 @@ | |||||
# 微信公众号channel | # 微信公众号channel | ||||
鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。 | 鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。 | ||||
目前支持订阅号和服务号两种类型的公众号。个人主体的微信订阅号由于无法通过微信认证,接口存在限制,目前仅支持最基本的文本交互和语音输入。通过微信认证的订阅号或者服务号可以回复图片和语音。 | |||||
目前支持订阅号和服务号两种类型的公众号,它们都支持文本交互,语音和图片输入。其中个人主体的微信订阅号由于无法通过微信认证,存在回复时间限制,每天的图片和声音回复次数也有限制。 | |||||
## 使用方法(订阅号,服务号类似) | ## 使用方法(订阅号,服务号类似) | ||||
@@ -15,7 +15,7 @@ pip3 install web.py | |||||
然后在[微信公众平台](https://mp.weixin.qq.com)注册一个自己的公众号,类型选择订阅号,主体为个人即可。 | 然后在[微信公众平台](https://mp.weixin.qq.com)注册一个自己的公众号,类型选择订阅号,主体为个人即可。 | ||||
然后根据[接入指南](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html)的说明,在[微信公众平台](https://mp.weixin.qq.com)的“设置与开发”-“基本配置”-“服务器配置”中填写服务器地址`URL`和令牌`Token`。这里的`URL`是`example.com/wx`的形式,不可以使用IP,`Token`是你自己编的一个特定的令牌。消息加解密方式目前选择的是明文模式。 | |||||
然后根据[接入指南](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html)的说明,在[微信公众平台](https://mp.weixin.qq.com)的“设置与开发”-“基本配置”-“服务器配置”中填写服务器地址`URL`和令牌`Token`。这里的`URL`是`example.com/wx`的形式,不可以使用IP,`Token`是你自己编的一个特定的令牌。消息加解密方式如果选择了需要加密的模式,需要在配置中填写`wechatmp_aes_key`。 | |||||
相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加 | 相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加 | ||||
``` | ``` | ||||
@@ -24,6 +24,7 @@ pip3 install web.py | |||||
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 | "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 | ||||
"wechatmp_app_id": "xxxx", # 微信公众平台的appID | "wechatmp_app_id": "xxxx", # 微信公众平台的appID | ||||
"wechatmp_app_secret": "xxxx", # 微信公众平台的appsecret | "wechatmp_app_secret": "xxxx", # 微信公众平台的appsecret | ||||
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要 | |||||
"single_chat_prefix": [""], # 推荐设置,任意对话都可以触发回复,不添加前缀 | "single_chat_prefix": [""], # 推荐设置,任意对话都可以触发回复,不添加前缀 | ||||
"single_chat_reply_prefix": "", # 推荐设置,回复不设置前缀 | "single_chat_reply_prefix": "", # 推荐设置,回复不设置前缀 | ||||
"plugin_trigger_prefix": "&", # 推荐设置,在手机微信客户端中,$%^等符号与中文连在一起时会自动显示一段较大的间隔,用户体验不好。请不要使用管理员指令前缀"#",这会造成未知问题。 | "plugin_trigger_prefix": "&", # 推荐设置,在手机微信客户端中,$%^等符号与中文连在一起时会自动显示一段较大的间隔,用户体验不好。请不要使用管理员指令前缀"#",这会造成未知问题。 | ||||
@@ -40,12 +41,13 @@ sudo iptables-save > /etc/iptables/rules.v4 | |||||
程序启动并监听端口后,在刚才的“服务器配置”中点击`提交`即可验证你的服务器。 | 程序启动并监听端口后,在刚才的“服务器配置”中点击`提交`即可验证你的服务器。 | ||||
随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。 | 随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。 | ||||
如果在启用后如果遇到如下报错: | |||||
之后需要在公众号开发信息下将本机IP加入到IP白名单。 | |||||
不然在启用后,发送语音、图片等消息可能会遇到如下报错: | |||||
``` | ``` | ||||
'errcode': 40164, 'errmsg': 'invalid ip xx.xx.xx.xx not in whitelist rid | 'errcode': 40164, 'errmsg': 'invalid ip xx.xx.xx.xx not in whitelist rid | ||||
``` | ``` | ||||
需要在公众号开发信息下将IP加入到IP白名单。 | |||||
## 个人微信公众号的限制 | ## 个人微信公众号的限制 | ||||
由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 | 由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 | ||||
@@ -91,7 +93,7 @@ python3 -m pip install pyttsx3 | |||||
## TODO | ## TODO | ||||
- [x] 语音输入 | - [x] 语音输入 | ||||
- [ ] 图片输入 | |||||
- [x] 图片输入 | |||||
- [x] 使用临时素材接口提供认证公众号的图片和语音回复 | - [x] 使用临时素材接口提供认证公众号的图片和语音回复 | ||||
- [x] 使用永久素材接口提供未认证公众号的图片和语音回复 | - [x] 使用永久素材接口提供未认证公众号的图片和语音回复 | ||||
- [ ] 高并发支持 | - [ ] 高并发支持 |
@@ -19,26 +19,23 @@ class Query: | |||||
def POST(self): | def POST(self): | ||||
# Make sure to return the instance that first created, @singleton will do that. | # Make sure to return the instance that first created, @singleton will do that. | ||||
channel = WechatMPChannel() | |||||
try: | try: | ||||
verify_server(web.input()) | |||||
message = web.data() # todo crypto | |||||
# logger.debug("[wechatmp] Receive request:\n" + webData.decode("utf-8")) | |||||
args = web.input() | |||||
verify_server(args) | |||||
channel = WechatMPChannel() | |||||
message = web.data() | |||||
encrypt_func = lambda x: x | |||||
if args.get("encrypt_type") == "aes": | |||||
logger.debug("[wechatmp] Receive encrypted post data:\n" + message.decode("utf-8")) | |||||
if not channel.crypto: | |||||
raise Exception("Crypto not initialized, Please set wechatmp_aes_key in config.json") | |||||
message = channel.crypto.decrypt_message(message, args.msg_signature, args.timestamp, args.nonce) | |||||
encrypt_func = lambda x: channel.crypto.encrypt_message(x, args.nonce, args.timestamp) | |||||
else: | |||||
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8")) | |||||
msg = parse_message(message) | msg = parse_message(message) | ||||
if msg.type == "event": | |||||
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 replyPost.render() | |||||
else: | |||||
return "success" | |||||
wechatmp_msg = WeChatMPMessage(msg, client=channel.client) | |||||
if wechatmp_msg.ctype in [ContextType.TEXT, ContextType.IMAGE, ContextType.VOICE]: | |||||
if msg.type in ["text", "voice", "image"]: | |||||
wechatmp_msg = WeChatMPMessage(msg, client=channel.client) | |||||
from_user = wechatmp_msg.from_user_id | from_user = wechatmp_msg.from_user_id | ||||
content = wechatmp_msg.content | content = wechatmp_msg.content | ||||
message_id = wechatmp_msg.msg_id | message_id = wechatmp_msg.msg_id | ||||
@@ -70,6 +67,18 @@ class Query: | |||||
channel.produce(context) | channel.produce(context) | ||||
# The reply will be sent by channel.send() in another thread | # The reply will be sent by channel.send() in another thread | ||||
return "success" | return "success" | ||||
elif msg.type == "event": | |||||
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()) | |||||
else: | |||||
return "success" | |||||
else: | else: | ||||
logger.info("暂且不处理") | logger.info("暂且不处理") | ||||
return "success" | return "success" | ||||
@@ -17,7 +17,7 @@ def verify_server(data): | |||||
signature = data.signature | signature = data.signature | ||||
timestamp = data.timestamp | timestamp = data.timestamp | ||||
nonce = data.nonce | nonce = data.nonce | ||||
echostr = data.echostr | |||||
echostr = data.get("echostr", None) | |||||
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写 | token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写 | ||||
check_signature(token, signature, timestamp, nonce) | check_signature(token, signature, timestamp, nonce) | ||||
return echostr | return echostr | ||||
@@ -20,13 +20,21 @@ class Query: | |||||
def POST(self): | def POST(self): | ||||
try: | try: | ||||
verify_server(web.input()) | |||||
args = web.input() | |||||
verify_server(args) | |||||
request_time = time.time() | request_time = time.time() | ||||
channel = WechatMPChannel() | channel = WechatMPChannel() | ||||
message = web.data() # todo crypto | |||||
message = web.data() | |||||
encrypt_func = lambda x: x | |||||
if args.get("encrypt_type") == "aes": | |||||
logger.debug("[wechatmp] Receive encrypted post data:\n" + message.decode("utf-8")) | |||||
if not channel.crypto: | |||||
raise Exception("Crypto not initialized, Please set wechatmp_aes_key in config.json") | |||||
message = channel.crypto.decrypt_message(message, args.msg_signature, args.timestamp, args.nonce) | |||||
encrypt_func = lambda x: channel.crypto.encrypt_message(x, args.nonce, args.timestamp) | |||||
else: | |||||
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8")) | |||||
msg = parse_message(message) | msg = parse_message(message) | ||||
logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8")) | |||||
if msg.type in ["text", "voice", "image"]: | if msg.type in ["text", "voice", "image"]: | ||||
wechatmp_msg = WeChatMPMessage(msg, client=channel.client) | wechatmp_msg = WeChatMPMessage(msg, client=channel.client) | ||||
from_user = wechatmp_msg.from_user_id | from_user = wechatmp_msg.from_user_id | ||||
@@ -88,7 +96,7 @@ class Query: | |||||
) | ) | ||||
replyPost = create_reply(reply_text, msg) | replyPost = create_reply(reply_text, msg) | ||||
return replyPost.render() | |||||
return encrypt_func(replyPost.render()) | |||||
# Wechat official server will request 3 times (5 seconds each), with the same message_id. | # Wechat official server will request 3 times (5 seconds each), with the same message_id. | ||||
@@ -126,7 +134,7 @@ class Query: | |||||
# return timeout message | # return timeout message | ||||
reply_text = "【正在思考中,回复任意文字尝试获取回复】" | reply_text = "【正在思考中,回复任意文字尝试获取回复】" | ||||
replyPost = create_reply(reply_text, msg) | replyPost = create_reply(reply_text, msg) | ||||
return replyPost.render() | |||||
return encrypt_func(replyPost.render()) | |||||
# reply is ready | # reply is ready | ||||
channel.request_cnt.pop(message_id) | channel.request_cnt.pop(message_id) | ||||
@@ -167,7 +175,7 @@ class Query: | |||||
) | ) | ||||
) | ) | ||||
replyPost = create_reply(reply_text, msg) | replyPost = create_reply(reply_text, msg) | ||||
return replyPost.render() | |||||
return encrypt_func(replyPost.render()) | |||||
elif (reply_type == "voice"): | elif (reply_type == "voice"): | ||||
media_id = reply_content | media_id = reply_content | ||||
@@ -183,7 +191,7 @@ class Query: | |||||
) | ) | ||||
replyPost = VoiceReply(message=msg) | replyPost = VoiceReply(message=msg) | ||||
replyPost.media_id = media_id | replyPost.media_id = media_id | ||||
return replyPost.render() | |||||
return encrypt_func(replyPost.render()) | |||||
elif (reply_type == "image"): | elif (reply_type == "image"): | ||||
media_id = reply_content | media_id = reply_content | ||||
@@ -199,7 +207,7 @@ class Query: | |||||
) | ) | ||||
replyPost = ImageReply(message=msg) | replyPost = ImageReply(message=msg) | ||||
replyPost.media_id = media_id | replyPost.media_id = media_id | ||||
return replyPost.render() | |||||
return encrypt_func(replyPost.render()) | |||||
elif msg.type == "event": | elif msg.type == "event": | ||||
logger.info( | logger.info( | ||||
@@ -210,7 +218,7 @@ class Query: | |||||
if msg.event in ["subscribe", "subscribe_scan"]: | if msg.event in ["subscribe", "subscribe_scan"]: | ||||
reply_text = subscribe_msg() | reply_text = subscribe_msg() | ||||
replyPost = create_reply(reply_text, msg) | replyPost = create_reply(reply_text, msg) | ||||
return replyPost.render() | |||||
return encrypt_func(replyPost.render()) | |||||
else: | else: | ||||
return "success" | return "success" | ||||
@@ -16,6 +16,7 @@ from channel.chat_channel import ChatChannel | |||||
from channel.wechatmp.common import * | from channel.wechatmp.common import * | ||||
from channel.wechatmp.wechatmp_client import WechatMPClient | from channel.wechatmp.wechatmp_client import WechatMPClient | ||||
from wechatpy.exceptions import WeChatClientException | from wechatpy.exceptions import WeChatClientException | ||||
from wechatpy.crypto import WeChatCrypto | |||||
import web | import web | ||||
# If using SSL, uncomment the following lines, and modify the certificate path. | # If using SSL, uncomment the following lines, and modify the certificate path. | ||||
@@ -34,7 +35,12 @@ class WechatMPChannel(ChatChannel): | |||||
self.NOT_SUPPORT_REPLYTYPE = [] | self.NOT_SUPPORT_REPLYTYPE = [] | ||||
appid = conf().get("wechatmp_app_id") | appid = conf().get("wechatmp_app_id") | ||||
secret = conf().get("wechatmp_app_secret") | secret = conf().get("wechatmp_app_secret") | ||||
token = conf().get("wechatmp_token") | |||||
aes_key = conf().get("wechatmp_aes_key") | |||||
self.client = WechatMPClient(appid, secret) | self.client = WechatMPClient(appid, secret) | ||||
self.crypto = None | |||||
if aes_key: | |||||
self.crypto = WeChatCrypto(token, aes_key, appid) | |||||
if self.passive_reply: | if self.passive_reply: | ||||
# Cache the reply to the user's first message | # Cache the reply to the user's first message | ||||
self.cache_dict = dict() | self.cache_dict = dict() | ||||
@@ -73,8 +73,9 @@ available_setting = { | |||||
# wechatmp的配置 | # wechatmp的配置 | ||||
"wechatmp_token": "", # 微信公众平台的Token | "wechatmp_token": "", # 微信公众平台的Token | ||||
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 | "wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 | ||||
"wechatmp_app_id": "", # 微信公众平台的appID,仅服务号需要 | |||||
"wechatmp_app_secret": "", # 微信公众平台的appsecret,仅服务号需要 | |||||
"wechatmp_app_id": "", # 微信公众平台的appID | |||||
"wechatmp_app_secret": "", # 微信公众平台的appsecret | |||||
"wechatmp_aes_key": "", # 微信公众平台的EncodingAESKey,加密模式需要 | |||||
# chatgpt指令自定义触发词 | # chatgpt指令自定义触发词 | ||||
"clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | "clear_memory_commands": ["#清除记忆"], # 重置会话指令,必须以#开头 | ||||
# channel配置 | # channel配置 | ||||