Przeglądaj źródła

feat(wechatmp): add support for message encryption

- Add support for message encryption in WeChat MP channel.
- Add `wechatmp_aes_key` configuration item to `config.json`.
lanvent 3 lat temu
rodzic
commit
7c85c6f742

+ 7 - 5
channel/wechatmp/README.md

@@ -1,7 +1,7 @@
 # 微信公众号channel
 
 鉴于个人微信号在服务器上通过itchat登录有封号风险,这里新增了微信公众号channel,提供无风险的服务。
-目前支持订阅号和服务号两种类型的公众号。个人主体的微信订阅号由于无法通过微信认证,接口存在限制,目前仅支持最基本的文本交互和语音输入。通过微信认证的订阅号或者服务号可以回复图片和语音
+目前支持订阅号和服务号两种类型的公众号,它们都支持文本交互,语音和图片输入其中个人主体的微信订阅号由于无法通过微信认证,存在回复时间限制,每天的图片和声音回复次数也有限制
 
 ## 使用方法(订阅号,服务号类似)
 
@@ -15,7 +15,7 @@ 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`是你自己编的一个特定的令牌。消息加解密方式目前选择的是明文模式
+然后根据[接入指南](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`中添加
 ```
@@ -24,6 +24,7 @@ pip3 install web.py
 "wechatmp_port": 8080,          # 微信公众平台的端口,需要端口转发到80或443
 "wechatmp_app_id": "xxxx",      # 微信公众平台的appID
 "wechatmp_app_secret": "xxxx",  # 微信公众平台的appsecret
+"wechatmp_aes_key": "",         # 微信公众平台的EncodingAESKey,加密模式需要
 "single_chat_prefix": [""],     # 推荐设置,任意对话都可以触发回复,不添加前缀
 "single_chat_reply_prefix": "", # 推荐设置,回复不设置前缀
 "plugin_trigger_prefix": "&",   # 推荐设置,在手机微信客户端中,$%^等符号与中文连在一起时会自动显示一段较大的间隔,用户体验不好。请不要使用管理员指令前缀"#",这会造成未知问题。
@@ -40,12 +41,13 @@ sudo iptables-save > /etc/iptables/rules.v4
 程序启动并监听端口后,在刚才的“服务器配置”中点击`提交`即可验证你的服务器。
 随后在[微信公众平台](https://mp.weixin.qq.com)启用服务器,关闭手动填写规则的自动回复,即可实现ChatGPT的自动回复。
 
-如果在启用后如果遇到如下报错:
+之后需要在公众号开发信息下将本机IP加入到IP白名单。
+
+不然在启用后,发送语音、图片等消息可能会遇到如下报错:
 ```
 'errcode': 40164, 'errmsg': 'invalid ip xx.xx.xx.xx not in whitelist rid
 ```
 
-需要在公众号开发信息下将IP加入到IP白名单。
 
 ## 个人微信公众号的限制
 由于人微信公众号不能通过微信认证,所以没有客服接口,因此公众号无法主动发出消息,只能被动回复。而微信官方对被动回复有5秒的时间限制,最多重试2次,因此最多只有15秒的自动回复时间窗口。因此如果问题比较复杂或者我们的服务器比较忙,ChatGPT的回答就没办法及时回复给用户。为了解决这个问题,这里做了回答缓存,它需要你在回复超时后,再次主动发送任意文字(例如1)来尝试拿到回答缓存。为了优化使用体验,目前设置了两分钟(120秒)的timeout,用户在至多两分钟后即可得到查询到回复或者错误原因。
@@ -91,7 +93,7 @@ python3 -m pip install pyttsx3
 
 ## TODO
  - [x] 语音输入
- - [ ] 图片输入
+ - [x] 图片输入
  - [x] 使用临时素材接口提供认证公众号的图片和语音回复
  - [x] 使用永久素材接口提供未认证公众号的图片和语音回复
  - [ ] 高并发支持

+ 27 - 18
channel/wechatmp/active_reply.py

@@ -19,26 +19,23 @@ class Query:
 
     def POST(self):
         # Make sure to return the instance that first created, @singleton will do that.
-        channel = WechatMPChannel()
         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)
-            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
                 content = wechatmp_msg.content
                 message_id = wechatmp_msg.msg_id
@@ -70,6 +67,18 @@ class Query:
                     channel.produce(context)
                 # The reply will be sent by channel.send() in another thread
                 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:
                 logger.info("暂且不处理")
                 return "success"

+ 1 - 1
channel/wechatmp/common.py

@@ -17,7 +17,7 @@ def verify_server(data):
         signature = data.signature
         timestamp = data.timestamp
         nonce = data.nonce
-        echostr = data.echostr
+        echostr = data.get("echostr", None)
         token = conf().get("wechatmp_token")  # 请按照公众平台官网\基本配置中信息填写
         check_signature(token, signature, timestamp, nonce)
         return echostr

+ 18 - 10
channel/wechatmp/passive_reply.py

@@ -20,13 +20,21 @@ class Query:
 
     def POST(self):
         try:
-            verify_server(web.input())
+            args = web.input()
+            verify_server(args)
             request_time = time.time()
             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)
-            logger.debug("[wechatmp] Receive post data:\n" + message.decode("utf-8"))
-
             if msg.type in ["text", "voice", "image"]:
                 wechatmp_msg = WeChatMPMessage(msg, client=channel.client)
                 from_user = wechatmp_msg.from_user_id
@@ -88,7 +96,7 @@ class Query:
                             )
                         
                         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.
@@ -126,7 +134,7 @@ class Query:
                         # return timeout message
                         reply_text = "【正在思考中,回复任意文字尝试获取回复】"
                         replyPost = create_reply(reply_text, msg)
-                        return replyPost.render()
+                        return encrypt_func(replyPost.render())
 
                 # reply is ready
                 channel.request_cnt.pop(message_id)
@@ -167,7 +175,7 @@ class Query:
                         )
                     )
                     replyPost = create_reply(reply_text, msg)
-                    return replyPost.render()
+                    return encrypt_func(replyPost.render())
 
                 elif (reply_type == "voice"):
                     media_id = reply_content
@@ -183,7 +191,7 @@ class Query:
                     )
                     replyPost = VoiceReply(message=msg)
                     replyPost.media_id = media_id
-                    return replyPost.render()
+                    return encrypt_func(replyPost.render())
 
                 elif (reply_type == "image"):
                     media_id = reply_content
@@ -199,7 +207,7 @@ class Query:
                     )
                     replyPost = ImageReply(message=msg)
                     replyPost.media_id = media_id
-                    return replyPost.render()
+                    return encrypt_func(replyPost.render())
 
             elif msg.type == "event":
                 logger.info(
@@ -210,7 +218,7 @@ class Query:
                 if msg.event in ["subscribe", "subscribe_scan"]:
                     reply_text = subscribe_msg()
                     replyPost = create_reply(reply_text, msg)
-                    return replyPost.render()
+                    return encrypt_func(replyPost.render())
                 else:
                     return "success"
 

+ 6 - 0
channel/wechatmp/wechatmp_channel.py

@@ -16,6 +16,7 @@ from channel.chat_channel import ChatChannel
 from channel.wechatmp.common import *
 from channel.wechatmp.wechatmp_client import WechatMPClient
 from wechatpy.exceptions import WeChatClientException
+from wechatpy.crypto import WeChatCrypto
 
 import web
 # If using SSL, uncomment the following lines, and modify the certificate path.
@@ -34,7 +35,12 @@ class WechatMPChannel(ChatChannel):
         self.NOT_SUPPORT_REPLYTYPE = []
         appid = conf().get("wechatmp_app_id")
         secret = conf().get("wechatmp_app_secret")
+        token = conf().get("wechatmp_token")
+        aes_key = conf().get("wechatmp_aes_key")
         self.client = WechatMPClient(appid, secret)
+        self.crypto = None
+        if aes_key:
+            self.crypto = WeChatCrypto(token, aes_key, appid)
         if self.passive_reply:
             # Cache the reply to the user's first message
             self.cache_dict = dict()

+ 3 - 2
config.py

@@ -73,8 +73,9 @@ available_setting = {
     # wechatmp的配置
     "wechatmp_token": "",  # 微信公众平台的Token
     "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指令自定义触发词
     "clear_memory_commands": ["#清除记忆"],  # 重置会话指令,必须以#开头
     # channel配置