Merge Pull Request #686 into master

This commit is contained in:
lanvent
2023-04-05 04:18:06 +08:00
parent eca369532d
commit cc881adda6
20 changed files with 659 additions and 61 deletions

View File

@@ -17,4 +17,7 @@ def create_channel(channel_type):
elif channel_type == 'terminal':
from channel.terminal.terminal_channel import TerminalChannel
return TerminalChannel()
elif channel_type == 'wechatmp':
from channel.wechatmp.wechatmp_channel import WechatMPServer
return WechatMPServer()
raise RuntimeError

View File

@@ -0,0 +1,34 @@
# 个人微信公众号channel
鉴于个人微信号在服务器上通过itchat登录有封号风险这里新增了个人微信公众号channel提供无风险的服务。
但是由于个人微信公众号的众多接口限制目前支持的功能有限实现简陋提供了一个最基本的文本对话服务支持加载插件优化了命令格式支持私有api_key。暂未实现图片输入输出、语音输入输出等交互形式。
如有公众号是企业主体且可以通过微信认证,即可获得更多接口,解除大多数限制。欢迎大家提供更多的支持。
## 使用方法
在开始部署前你需要一个拥有公网IP的服务器以提供微信服务器和我们自己服务器的连接。或者你需要进行内网穿透否则微信服务器无法将消息发送给我们的服务器。
此外需要在我们的服务器上安装python的web框架web.py。
以ubuntu为例(在ubuntu 22.04上测试):
```
pip3 install web.py
```
然后在[微信公众平台](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`是你自己编的一个特定的令牌。消息加解密方式目前选择的是明文模式。
相关的服务器验证代码已经写好,你不需要再添加任何代码。你只需要在本项目根目录的`config.json`中添加`"channel_type": "wechatmp", "wechatmp_token": "your Token", ` 然后运行`python3 app.py`启动web服务器然后在刚才的“服务器配置”中点击`提交`即可验证你的服务器。
随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器关闭手动填写规则的自动回复即可实现ChatGPT的自动回复。
## 个人微信公众号的限制
由于目前测试的公众号不是企业主体所以没有客服接口因此公众号无法主动发出消息只能被动回复。而微信官方对被动回复有5秒的时间限制最多重试2次因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙ChatGPT的回答就没办法及时回复给用户。为了解决这个问题这里做了回答缓存它需要你在回复超时后再次主动发送任意文字例如1来尝试拿到回答缓存。为了优化使用体验目前设置了两分钟120秒的timeout用户在至多两分钟后即可得到查询到回复或者错误原因。
另外由于微信官方的限制自动回复有长度限制。因此这里将ChatGPT的回答拆分分成每段600字回复限制大约在700字
## 私有api_key
公共api有访问频率限制免费账号每分钟最多20次ChatGPT的API调用这在服务多人的时候会遇到问题。因此这里多加了一个设置私有api_key的功能。目前通过godcmd插件的命令来设置私有api_key。
## 测试范围
目前在`RoboStyle`这个公众号上进行了测试感兴趣的可以关注并体验。开启了godcmd, Banwords, role, dungeon, finish这五个插件其他的插件还没有测试。百度的接口暂未测试。语音对话没有测试。图片直接以链接形式回复没有临时素材上传接口的权限

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-#
# filename: receive.py
import xml.etree.ElementTree as ET
def parse_xml(web_data):
if len(web_data) == 0:
return None
xmlData = ET.fromstring(web_data)
msg_type = xmlData.find('MsgType').text
if msg_type == 'text':
return TextMsg(xmlData)
elif msg_type == 'image':
return ImageMsg(xmlData)
elif msg_type == 'event':
return Event(xmlData)
class Msg(object):
def __init__(self, xmlData):
self.ToUserName = xmlData.find('ToUserName').text
self.FromUserName = xmlData.find('FromUserName').text
self.CreateTime = xmlData.find('CreateTime').text
self.MsgType = xmlData.find('MsgType').text
self.MsgId = xmlData.find('MsgId').text
class TextMsg(Msg):
def __init__(self, xmlData):
Msg.__init__(self, xmlData)
self.Content = xmlData.find('Content').text.encode("utf-8")
class ImageMsg(Msg):
def __init__(self, xmlData):
Msg.__init__(self, xmlData)
self.PicUrl = xmlData.find('PicUrl').text
self.MediaId = xmlData.find('MediaId').text
class Event(object):
def __init__(self, xmlData):
self.ToUserName = xmlData.find('ToUserName').text
self.FromUserName = xmlData.find('FromUserName').text
self.CreateTime = xmlData.find('CreateTime').text
self.MsgType = xmlData.find('MsgType').text
self.Event = xmlData.find('Event').text

52
channel/wechatmp/reply.py Normal file
View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-#
# filename: reply.py
import time
class Msg(object):
def __init__(self):
pass
def send(self):
return "success"
class TextMsg(Msg):
def __init__(self, toUserName, fromUserName, content):
self.__dict = dict()
self.__dict['ToUserName'] = toUserName
self.__dict['FromUserName'] = fromUserName
self.__dict['CreateTime'] = int(time.time())
self.__dict['Content'] = content
def send(self):
XmlForm = """
<xml>
<ToUserName><![CDATA[{ToUserName}]]></ToUserName>
<FromUserName><![CDATA[{FromUserName}]]></FromUserName>
<CreateTime>{CreateTime}</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[{Content}]]></Content>
</xml>
"""
return XmlForm.format(**self.__dict)
class ImageMsg(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[image]]></MsgType>
<Image>
<MediaId><![CDATA[{MediaId}]]></MediaId>
</Image>
</xml>
"""
return XmlForm.format(**self.__dict)

View File

@@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
import web
import time
import math
import hashlib
import textwrap
from channel.channel import Channel
import channel.wechatmp.reply as reply
import channel.wechatmp.receive as receive
from common.log import logger
from config import conf
from bridge.reply import *
from bridge.context import *
from plugins import *
import traceback
# If using SSL, uncomment the following lines, and modify the certificate path.
# from cheroot.server import HTTPServer
# from cheroot.ssl.builtin import BuiltinSSLAdapter
# HTTPServer.ssl_adapter = BuiltinSSLAdapter(
# certificate='/ssl/cert.pem',
# private_key='/ssl/cert.key')
class WechatMPServer():
def __init__(self):
pass
def startup(self):
urls = (
'/wx', 'WechatMPChannel',
)
app = web.application(urls, globals())
web.httpserver.runsimple(app.wsgifunc(), ('0.0.0.0', 80))
cache_dict = dict()
query1 = dict()
query2 = dict()
query3 = dict()
from concurrent.futures import ThreadPoolExecutor
thread_pool = ThreadPoolExecutor(max_workers=8)
class WechatMPChannel(Channel):
def GET(self):
try:
data = web.input()
if len(data) == 0:
return "hello, this is handle view"
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 _do_build_reply(self, cache_key, fromUser, message):
context = dict()
context['session_id'] = fromUser
reply_text = super().build_reply_content(message, context)
# The query is done, record the cache
logger.info("[threaded] Get reply for {}: {} \nA: {}".format(fromUser, message, reply_text))
global cache_dict
reply_cnt = math.ceil(len(reply_text) / 600)
cache_dict[cache_key] = (reply_cnt, reply_text)
def send(self, reply : Reply, cache_key):
global cache_dict
reply_cnt = math.ceil(len(reply.content) / 600)
cache_dict[cache_key] = (reply_cnt, reply.content)
def handle(self, context):
global cache_dict
try:
reply = Reply()
logger.debug('[wechatmp] ready to handle context: {}'.format(context))
# reply的构建步骤
e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, {'channel' : self, 'context': context, 'reply': reply}))
reply = e_context['reply']
if not e_context.is_pass():
logger.debug('[wechatmp] ready to handle context: type={}, content={}'.format(context.type, context.content))
if context.type == ContextType.TEXT or context.type == ContextType.IMAGE_CREATE:
reply = super().build_reply_content(context.content, context)
# elif context.type == ContextType.VOICE:
# msg = context['msg']
# file_name = TmpDir().path() + context.content
# msg.download(file_name)
# reply = super().build_voice_to_text(file_name)
# if reply.type != ReplyType.ERROR and reply.type != ReplyType.INFO:
# context.content = reply.content # 语音转文字后将文字内容作为新的context
# context.type = ContextType.TEXT
# reply = super().build_reply_content(context.content, context)
# if reply.type == ReplyType.TEXT:
# if conf().get('voice_reply_voice'):
# reply = super().build_text_to_voice(reply.content)
else:
logger.error('[wechatmp] unknown context type: {}'.format(context.type))
return
logger.debug('[wechatmp] ready to decorate reply: {}'.format(reply))
# reply的包装步骤
if reply and reply.type:
e_context = PluginManager().emit_event(EventContext(Event.ON_DECORATE_REPLY, {'channel' : self, 'context': context, 'reply': reply}))
reply=e_context['reply']
if not e_context.is_pass() and reply and reply.type:
if reply.type == ReplyType.TEXT:
pass
elif reply.type == ReplyType.ERROR or reply.type == ReplyType.INFO:
reply.content = str(reply.type)+":\n" + reply.content
elif reply.type == ReplyType.IMAGE_URL or reply.type == ReplyType.VOICE or reply.type == ReplyType.IMAGE:
pass
else:
logger.error('[wechatmp] unknown reply type: {}'.format(reply.type))
return
# reply的发送步骤
if reply and reply.type:
e_context = PluginManager().emit_event(EventContext(Event.ON_SEND_REPLY, {'channel' : self, 'context': context, 'reply': reply}))
reply=e_context['reply']
if not e_context.is_pass() and reply and reply.type:
logger.debug('[wechatmp] ready to send reply: {} to {}'.format(reply, context['receiver']))
self.send(reply, context['receiver'])
else:
cache_dict[context['receiver']] = (1, "No reply")
logger.info("[threaded] Get reply for {}: {} \nA: {}".format(context['receiver'], context.content, reply.content))
except Exception as exc:
print(traceback.format_exc())
cache_dict[context['receiver']] = (1, "ERROR")
def POST(self):
try:
queryTime = time.time()
webData = web.data()
# logger.debug("[wechatmp] Receive request:\n" + webData.decode("utf-8"))
recMsg = receive.parse_xml(webData)
if isinstance(recMsg, receive.Msg) and recMsg.MsgType == 'text':
fromUser = recMsg.FromUserName
toUser = recMsg.ToUserName
createTime = recMsg.CreateTime
message = recMsg.Content.decode("utf-8")
message_id = recMsg.MsgId
logger.info("[wechatmp] {}:{} Receive post query {} {}: {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), fromUser, message_id, message))
global cache_dict
global query1
global query2
global query3
cache_key = fromUser
cache = cache_dict.get(cache_key)
reply_text = ""
# New request
if cache == None:
# The first query begin, reset the cache
cache_dict[cache_key] = (0, "")
# thread_pool.submit(self._do_build_reply, cache_key, fromUser, message)
context = Context()
context.kwargs = {'isgroup': False, 'receiver': fromUser, 'session_id': fromUser}
user_data = conf().get_user_data(fromUser)
context['openai_api_key'] = user_data.get('openai_api_key') # None or user openai_api_key
img_match_prefix = check_prefix(message, conf().get('image_create_prefix'))
if img_match_prefix:
message = message.replace(img_match_prefix, '', 1).strip()
context.type = ContextType.IMAGE_CREATE
else:
context.type = ContextType.TEXT
context.content = message
thread_pool.submit(self.handle, context)
query1[cache_key] = False
query2[cache_key] = False
query3[cache_key] = False
# Request again
elif cache[0] == 0 and query1.get(cache_key) == True and query2.get(cache_key) == True and query3.get(cache_key) == True:
query1[cache_key] = False #To improve waiting experience, this can be set to True.
query2[cache_key] = False #To improve waiting experience, this can be set to True.
query3[cache_key] = False
elif cache[0] >= 1:
# Skip the waiting phase
query1[cache_key] = True
query2[cache_key] = True
query3[cache_key] = True
cache = cache_dict.get(cache_key)
if query1.get(cache_key) == False:
# The first query from wechat official server
logger.debug("[wechatmp] query1 {}".format(cache_key))
query1[cache_key] = True
cnt = 0
while cache[0] == 0 and cnt < 45:
cnt = cnt + 1
time.sleep(0.1)
cache = cache_dict.get(cache_key)
if cnt == 45:
# waiting for timeout (the POST query will be closed by wechat official server)
time.sleep(5)
# and do nothing
return
else:
pass
elif query2.get(cache_key) == False:
# The second query from wechat official server
logger.debug("[wechatmp] query2 {}".format(cache_key))
query2[cache_key] = True
cnt = 0
while cache[0] == 0 and cnt < 45:
cnt = cnt + 1
time.sleep(0.1)
cache = cache_dict.get(cache_key)
if cnt == 45:
# waiting for timeout (the POST query will be closed by wechat official server)
time.sleep(5)
# and do nothing
return
else:
pass
elif query3.get(cache_key) == False:
# The third query from wechat official server
logger.debug("[wechatmp] query3 {}".format(cache_key))
query3[cache_key] = True
cnt = 0
while cache[0] == 0 and cnt < 45:
cnt = cnt + 1
time.sleep(0.1)
cache = cache_dict.get(cache_key)
if cnt == 45:
# Have waiting for 3x5 seconds
# return timeout message
reply_text = "【正在响应中,回复任意文字尝试获取回复】"
logger.info("[wechatmp] Three queries has finished For {}: {}".format(fromUser, message_id))
replyPost = reply.TextMsg(fromUser, toUser, reply_text).send()
return replyPost
else:
pass
if float(time.time()) - float(queryTime) > 4.8:
logger.info("[wechatmp] Timeout for {} {}".format(fromUser, message_id))
return
if cache[0] > 1:
reply_text = cache[1][:600] + "\n【未完待续,回复任意文字以继续】" #wechatmp auto_reply length limit
cache_dict[cache_key] = (cache[0] - 1, cache[1][600:])
elif cache[0] == 1:
reply_text = cache[1]
cache_dict.pop(cache_key)
logger.info("[wechatmp] {}:{} Do send {}".format(web.ctx.env.get('REMOTE_ADDR'), web.ctx.env.get('REMOTE_PORT'), reply_text))
replyPost = reply.TextMsg(fromUser, toUser, reply_text).send()
return replyPost
elif isinstance(recMsg, receive.Event) and recMsg.MsgType == 'event':
logger.info("[wechatmp] Event {} from {}".format(recMsg.Event, recMsg.FromUserName))
content = textwrap.dedent("""\
感谢您的关注!
这里是ChatGPT可以自由对话。
资源有限,回复较慢,请勿着急。
支持通用表情输入。
暂时不支持图片输入。
支持图片输出,画字开头的问题将回复图片链接。
支持角色扮演和文字冒险两种定制模式对话。
输入'#帮助' 查看详细指令。""")
replyMsg = reply.TextMsg(recMsg.FromUserName, recMsg.ToUserName, content)
return replyMsg.send()
else:
logger.info("暂且不处理")
return "success"
except Exception as exc:
logger.exception(exc)
return exc
def check_prefix(content, prefix_list):
for prefix in prefix_list:
if content.startswith(prefix):
return prefix
return None