@@ -1,9 +1,7 @@ | |||
# 微信公众号channel | |||
鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。 | |||
目前支持订阅号(个人)和服务号(企业)两种类型的公众号,它们的主要区别就是被动回复和主动回复。 | |||
个人微信订阅号有许多接口限制,目前仅支持最基本的文本对话和语音输入,支持加载插件,支持私有api_key。 | |||
暂未实现图片输入输出、语音输出等交互形式。 | |||
目前支持订阅号和服务号两种类型的公众号。个人主体的微信订阅号由于无法通过微信认证,接口存在限制,目前仅支持最基本的文本交互和语音输入。通过微信认证的订阅号或者服务号可以回复图片和语音。 | |||
## 使用方法(订阅号,服务号类似) | |||
@@ -21,40 +19,42 @@ pip3 install web.py | |||
相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加 | |||
``` | |||
"channel_type": "wechatmp", | |||
"channel_type": "wechatmp", # 如果通过了微信认证,将"wechatmp"替换为"wechatmp_service",可极大的优化使用体验 | |||
"wechatmp_token": "Token", # 微信公众平台的Token | |||
"wechatmp_port": 8080, # 微信公众平台的端口,需要端口转发到80或443 | |||
"wechatmp_app_id": "", # 微信公众平台的appID,仅服务号需要 | |||
"wechatmp_app_secret": "", # 微信公众平台的appsecret,仅服务号需要 | |||
"wechatmp_app_id": "", # 微信公众平台的appID,wechatmp_service需要填写 | |||
"wechatmp_app_secret": "", # 微信公众平台的appsecret,wechatmp_service需要填写 | |||
"single_chat_prefix": [""], # 推荐设置,任意对话都可以触发回复,不添加前缀 | |||
"single_chat_reply_prefix": "", # 推荐设置,回复不设置前缀 | |||
"plugin_trigger_prefix": "&", # 推荐设置,在手机微信客户端中,$%^等符号与中文连在一起时会自动显示一段较大的间隔,用户体验不好。请不要使用管理员指令前缀"#",这会造成未知问题。 | |||
``` | |||
然后运行`python3 app.py`启动web服务器。这里会默认监听8080端口,但是微信公众号的服务器配置只支持80/443端口,有两种方法来解决这个问题。第一个是推荐的方法,使用端口转发命令将80端口转发到8080端口(443同理,注意需要支持SSL,也就是https的访问,在`wechatmp_channel.py`需要修改相应的证书路径): | |||
然后运行`python3 app.py`启动web服务器。这里会默认监听8080端口,但是微信公众号的服务器配置只支持80/443端口,有两种方法来解决这个问题。第一个是推荐的方法,使用端口转发命令将80端口转发到8080端口: | |||
``` | |||
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 8080 | |||
sudo iptables-save > /etc/iptables/rules.v4 | |||
``` | |||
第二个方法是让python程序直接监听80端口。这样可能会导致权限问题,在linux上需要使用`sudo`。然而这会导致后续缓存文件的权限问题,因此不是推荐的方法。 | |||
最后在刚才的“服务器配置”中点击`提交`即可验证你的服务器。 | |||
第二个方法是让python程序直接监听80端口,在配置文件中设置`"wechatmp_port": 80`,在linux上需要使用`sudo python3 app`启动程序。然而这会导致一系列环境和权限问题,因此不是推荐的方法。 | |||
443端口同理,注意需要支持SSL,也就是https的访问,在`wechatmp_channel.py`中需要修改相应的证书路径。 | |||
程序启动并监听端口后,在刚才的“服务器配置”中点击`提交`即可验证你的服务器。 | |||
随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。 | |||
## 个人微信公众号的限制 | |||
由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。 | |||
另外,由于微信官方的限制,自动回复有长度限制。因此这里将ChatGPT的回答拆分,分成每段600字回复(限制大约在700字)。 | |||
另外,由于微信官方的限制,自动回复有长度限制。因此这里将ChatGPT的回答进行了拆分,以满足限制。 | |||
## 私有api_key | |||
公共api有访问频率限制(免费账号每分钟最多20次ChatGPT的API调用),这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。 | |||
公共api有访问频率限制(免费账号每分钟最多3次ChatGPT的API调用),这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。 | |||
## 语音输入 | |||
利用微信自带的语音识别功能,提供语音输入能力。需要在公众号管理页面的“设置与开发”->“接口权限”页面开启“接收语音识别结果”。 | |||
## 测试范围 | |||
目前在`RoboStyle`这个公众号上进行了测试(基于[wechatmp分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp)),感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件,其他的插件还没有测试。百度的接口暂未测试。语音对话没有测试。图片直接以链接形式回复(没有临时素材上传接口的权限)。 | |||
## 测试 | |||
目前在`RoboStyle`这个公众号上进行了测试(基于[wechatmp-stable分支](https://github.com/JS00000/chatgpt-on-wechat/tree/wechatmp-stable)),感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件,其他的插件还没有详尽测试。百度的接口暂未测试。pytts可用。 | |||
## TODO | |||
* 服务号交互完善 | |||
* 服务号使用临时素材接口,提供图片回复能力 | |||
* 插件测试 | |||
* 图片输入 | |||
* 使用永久素材接口提供未认证公众号的图片和语音回复 | |||
* 高并发支持 |
@@ -2,8 +2,8 @@ import time | |||
import web | |||
import channel.wechatmp.receive as receive | |||
import channel.wechatmp.reply as reply | |||
from channel.wechatmp.wechatmp_message import parse_xml | |||
from channel.wechatmp.passive_reply_message import TextMsg | |||
from bridge.context import * | |||
from channel.wechatmp.common import * | |||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | |||
@@ -22,7 +22,7 @@ class Query: | |||
try: | |||
webData = web.data() | |||
# logger.debug("[wechatmp] Receive request:\n" + webData.decode("utf-8")) | |||
wechatmp_msg = receive.parse_xml(webData) | |||
wechatmp_msg = parse_xml(webData) | |||
if ( | |||
wechatmp_msg.msg_type == "text" | |||
or wechatmp_msg.msg_type == "voice" | |||
@@ -62,7 +62,7 @@ class Query: | |||
) | |||
) | |||
content = subscribe_msg() | |||
replyMsg = reply.TextMsg( | |||
replyMsg = TextMsg( | |||
wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content | |||
) | |||
return replyMsg.send() |
@@ -2,8 +2,8 @@ import time | |||
import web | |||
import channel.wechatmp.receive as receive | |||
import channel.wechatmp.reply as reply | |||
from channel.wechatmp.wechatmp_message import parse_xml | |||
from channel.wechatmp.passive_reply_message import TextMsg | |||
from bridge.context import * | |||
from channel.wechatmp.common import * | |||
from channel.wechatmp.wechatmp_channel import WechatMPChannel | |||
@@ -22,7 +22,7 @@ class Query: | |||
channel = WechatMPChannel() | |||
webData = web.data() | |||
logger.debug("[wechatmp] Receive post data:\n" + webData.decode("utf-8")) | |||
wechatmp_msg = receive.parse_xml(webData) | |||
wechatmp_msg = parse_xml(webData) | |||
if wechatmp_msg.msg_type == "text" or wechatmp_msg.msg_type == "voice": | |||
from_user = wechatmp_msg.from_user_id | |||
to_user = wechatmp_msg.to_user_id | |||
@@ -77,7 +77,7 @@ class Query: | |||
"""\ | |||
未知错误,请稍后再试""" | |||
) | |||
replyPost = reply.TextMsg(wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content).send() | |||
replyPost = TextMsg(wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content).send() | |||
return replyPost | |||
@@ -157,7 +157,7 @@ class Query: | |||
reply_text, | |||
) | |||
) | |||
replyPost = reply.TextMsg(from_user, to_user, reply_text).send() | |||
replyPost = TextMsg(from_user, to_user, reply_text).send() | |||
return replyPost | |||
elif wechatmp_msg.msg_type == "event": | |||
@@ -167,7 +167,7 @@ class Query: | |||
) | |||
) | |||
content = subscribe_msg() | |||
replyMsg = reply.TextMsg( | |||
replyMsg = TextMsg( | |||
wechatmp_msg.from_user_id, wechatmp_msg.to_user_id, content | |||
) | |||
return replyMsg.send() |
@@ -32,6 +32,29 @@ class TextMsg(Msg): | |||
return XmlForm.format(**self.__dict) | |||
class VoiceMsg(Msg): | |||
def __init__(self, toUserName, fromUserName, mediaId): | |||
self.__dict = dict() | |||
self.__dict["ToUserName"] = toUserName | |||
self.__dict["FromUserName"] = fromUserName | |||
self.__dict["CreateTime"] = int(time.time()) | |||
self.__dict["MediaId"] = mediaId | |||
def send(self): | |||
XmlForm = """ | |||
<xml> | |||
<ToUserName><![CDATA[{ToUserName}]]></ToUserName> | |||
<FromUserName><![CDATA[{FromUserName}]]></FromUserName> | |||
<CreateTime>{CreateTime}</CreateTime> | |||
<MsgType><![CDATA[voice]]></MsgType> | |||
<Voice> | |||
<MediaId><![CDATA[{MediaId}]]></MediaId> | |||
</Voice> | |||
</xml> | |||
""" | |||
return XmlForm.format(**self.__dict) | |||
class ImageMsg(Msg): | |||
def __init__(self, toUserName, fromUserName, mediaId): | |||
self.__dict = dict() |
@@ -42,9 +42,9 @@ class WechatMPChannel(ChatChannel): | |||
def startup(self): | |||
if self.passive_reply: | |||
urls = ("/wx", "channel.wechatmp.subscribe_account.Query") | |||
urls = ("/wx", "channel.wechatmp.passive_reply.Query") | |||
else: | |||
urls = ("/wx", "channel.wechatmp.service_account.Query") | |||
urls = ("/wx", "channel.wechatmp.active_reply.Query") | |||
app = web.application(urls, globals(), autoreload=False) | |||
port = conf().get("wechatmp_port", 8080) | |||
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) | |||