首发于 xyzlab
微信机器人 —— 后 itchat 时代 Serverless 与飞书捷径的妙用

微信机器人 —— 后 itchat 时代 Serverless 与飞书捷径的妙用

前言

itchat 是调用了微信 Web API 用于控制微信个人号的一个库,但近两年新注册的号无法登录 Web 版的微信,且微信限制了多端登录的设备数,只能在一部手机上和一部 iPad/Mac/PC 上登录,而 itchat 用于微信机器人的时候就会占用一个设备,导致 iPad/Mac/PC 端的微信下线,这就严重影响到了正常工作生活。本文是对不能使用 itchat 等使用微信 Web API 的个人号进行机器人化的一些尝试。

需求

工作学习上的需要,每周都会对一些不错的文章进行收集。之前都是通过 iPhone 复制微信公众号文章的链接,并直接粘贴至 Mac 上的文件中,接着对这些文章链接跑分析代码得到所需的数据。不过关注的公众号数目实在是太多了,微信本身又不支持公众号分组,只能采取加入微信读书书架的方式将公众号进行分组,可微信读书内又不支持直接复制文章链接,让整个流程非常繁杂。除此之外,微信公众号在分享链接的时候,只能选取腾讯自家体系的 APP 进行分享,要分享到外部则需要用 Safari 打开再进行分享,增加了操作上的耗时。



微信读书还好,可以分享至外部程序,配合 Telegram 也可以很方便地满足我的需求,但微信读书还是直接分享至微信机器人更加方便,少了一个步骤,且平时在微信程序内的时候读到不错的文章也可以直接转发给机器人。



所以总结起来的需求就是:可以从「微信」、「微信读书」直接分享公众号文章至微信机器人,并由微信机器人将选中的文章记录到某个地方,减少流程上的耗时。

解决方案调研

使用的小号是去年参加创业比赛时候用的,想通过用户给机器人账号发送关键词拉人进群,但在当时就发现使用不了 itchat ,随后微信更是发布了一系列针对自动营销工具的封禁。这些涉及到营销、灰黑产的内容不在也不会在本文讨论范围之内。

  1. 企业微信

微信公众号的文章可以直接分享至企业微信,依稀记得年初的时候做了个疫情数据推送的企业微信机器人,觉得这条路子可行。在经过一些列尝试之后,发现企业微信机器人只有发送消息但没有接收消息的能力,也就是机器人读取不了用户发送的信息;我又换了一种思路,创建了一个企业微信的应用,通过读取企业微信群组内的聊天记录总可以获取到内容了吧,但是经过尝试之后发现,API 一直跳没有权限。在我查透了 API 文档之后,发现读取归档的聊天记录需要认证企业且需要收费,折腾了半天结果是这样的结果,企业微信怒删之。

2. Hook

微信 Web 版的协议就是普通的 Web API 接口,既然 Web 协议用不了,那么可以使用其他平台上的协议。这里就需要使用到 Hook 了,简单说就是调用微信本身的协议来进行收发消息。

在 GitHub 上探索了许久,找到了两个合适的方案,一个是 python-wechaty ,另一个是 WechatPCAPI ,但两者都有一些令人难受的地方。前者需要参加开源的开发才可以获得有时间限制的 Token,或是另外付费购买,这个 Token 本质上是调用其服务器(服务器上应该使用的是 iPad/Mac 等微信协议)的凭证,我觉得一是要付费,二是没有很大的控制权,服务不可持续,万一遇到不可抗力的事情,服务不再提供,迁移就比较麻烦;看 Star 量后者应该是对 WechatPCAPI 原作者仓库的一个备份,经过我实际安装调试之后发现系统缺少 dll 文件(跟电脑系统本身有关系,不是该库的 dll 文件缺失),我觉得调试起来非常麻烦,且平时使用的系统也不是 Windows,服务器上跑个 Windows Server 又不知道会出现什么问题,遂放弃本方案。

3. 魔改 macOS 微信插件

在我将要放弃自动化老老实实使用传统方法时,我想到了既然有 PC 的 Hook,那一定有 Mac 的 Hook,我找了一圈 GitHub,发现除了乱七八糟的广告之外,并没有类似于 itchat 或是 WechatPCAPI 等功能齐全的库。突然想到了之前用的微信插件: WeChatExtension-ForMac ,支持消息自动转发、免认证登录、远程控制 Mac、AI 自动回复等功能。其中 AI 自动回复就是我需要的功能,通过将消息转发至一个 API 接口,再由这个 API 接口提供其他后端的能力即可。

魔改 macOS 微信插件

在魔改微信插件之前,我在 GitHub Issues 中搜索了一下,发现还是有很多类似的需求的,不过应该是考虑到可能会被应用于灰黑产、营销等微信严禁的方面,迟迟没有实现。





之后我还是有点抗拒修改自己完全不会的语言的代码,在微信插件中找到了之前一直没有开启的 Alfred 功能:



发现可以通过 Alfred 发送信息、查询聊天记录等,让我感觉这个插件应该是可以直接调用的 Hook:




在翻阅了几个简单的调用文件之后,发现该插件会在 http://127.0.0.1:52700/wechat-plugin/ 提供 Web 服务,即发送消息以及查询消息可以直接使用调用这些接口进行实现。

 BASE_URL = 'http://127.0.0.1:52700/wechat-plugin/'
 USER_URL = BASE_URL + 'user' # [GET]最近聊天列表,可选参数: [keyword]
 CHAT_LOG_URL = BASE_URL + 'chatlog' # [GET]根据用户 id 查询指定数量聊天记录,可选参数:[userId, count]
 SEND_MESSAGE_URL = BASE_URL + 'send-message' # [POST]根据用户 id 发送消息,可选参数:[userId, content, srvId](Content-Type: application/x-www-form-urlencoded)
 OPEN_SESSION_URL = BASE_URL + 'open-session' # [POST]不知道干嘛用的,参数:[userId](Content-Type: application/x-www-form-urlencoded)

尽管该微信插件支持微信客户端的多开,但 Alfred 插件只支持一个微信调用 API,因为新的微信启动的时候 52700 端口已经被占用。

发送消息解决了,但是并没有找到监听新消息的方法(也有可能是我没找到),简单猜测这个插件是查询数据库来实现获取消息记录的,这样需要监听 API 内容变动的方式我感觉太不优雅了,尤其是时间周期上的设置是个值得思考的问题,因此把这个作为备选方案。

在挣扎了一会儿之后,觉得还是不能接受通过监听 API 变动来实现消息的获取,于是我还是转向了魔改微信插件的方式。对 Clone 下来的代码进行了关键词搜索「AIReply」,不过并没有找到相关的 API 地址,然后去翻了整个仓库的提交记录找提交情况(写文章的时候发现草率了,直接搜索「.com」关键词不就可以了)。在翻了十几页提交记录之后,终于找到了一个名为「 Ai自动撩妹 」的提交记录,翻阅了代码,确定了调用的 AI 是腾讯的。



这样子就非常好办了,只要替换腾讯智能闲聊的 API 地址为自己的服务地址即可,腾讯智能闲聊 API 的返回方式如下:

 {
   "ret": 0,
   "msg": "ok",
   "data": {
       "session": "10000",
       "answer": "xyzlab666"
 }

非常快速地搭建了一个 Django 的服务,之后只需要将 WeChatExtension/WeChatExtension/Sources/Helper/YMNetWorkHelper.m 中的 URL 修改即可为自己的服务地址即可:

 static NSString const *AI_API = @"https://api.ai.qq.com/fcgi-bin/nlp/nlp_textchat"; # 替换为自己的 API 地址

通过这样的修改,确实可以自动回复消息了,但是离我的需求还有一定的距离,接着进行魔改。

既然找到了关于 AI 回复的入口位置,只需要找到哪里调用了这个文件就可以了,通过搜索,发现在 WeChat+hook.m 文件中,之后找到了一个关于使用 AI 回复的方法 autoReplyByAI

 #pragma mark - Other
 - (void)autoReplyByAI:(AddMsg *)addMsg
     if (addMsg.msgType != 1) {
          return;
     NSString *userName = addMsg.fromUserName.string;
     MMSessionMgr *sessionMgr = [[objc_getClass("MMServiceCenter") defaultCenter] getService:objc_getClass("MMSessionMgr")];
     WCContactData *msgContact = nil;
     if (LargerOrEqualVersion(@"2.3.26")) {
         msgContact = [sessionMgr getSessionContact:userName];
    } else {
         msgContact = [sessionMgr getContact:userName];
     if ([msgContact isBrandContact] || [msgContact isSelf]) {
         //该消息为公众号或者本人发送的消息
         return;
     YMAIAutoModel *AIModel = [[YMWeChatPluginConfig sharedConfig] AIReplyModel];
     if (AIModel.specificContacts.count < 1) {
         return;
    [AIModel.specificContacts enumerateObjectsUsingBlock:^(NSString *wxid, NSUInteger idx, BOOL * _Nonnull stop) {
         if ([wxid isEqualToString:addMsg.fromUserName.string]) {
             NSString *content = @"";
             NSString *session = @"";
             if ([wxid containsString:@"@chatroom"]) {
                 NSArray *contents = [addMsg.content.stringcomponentsSeparatedByString:@":\n"];
                 NSArray *sessions = [wxid componentsSeparatedByString:@"@"];
                 if (contents.count > 1) {
                     content = contents[1];
                 if (sessions.count > 1) {
                     session = sessions[0];
            } else {
                 content = addMsg.content.string;
                 session = wxid;
            [[YMNetWorkHelper share] GET:content session:session success:^(NSString*content, NSString *session) {
                [[YMMessageManager shareManager] sendTextMessage:contenttoUsrName:addMsg.fromUserName.string delay:kArc4random_Double_inSpace(3, 8)];
 }

通过使用「公众号」、「小程序」等关键词在文件中搜索,找到了一个 YMMessageHelper.m ,应该是用于处理消息用的,其中有一段解析小程序的代码:

 + (void)parseMiniProgramMsg:(AddMsg *)addMsg
     // 显示 49 信息
     if (addMsg.msgType == 49) {
         // xml 转 dict
         // 省去代码
         if (type.intValue == 33) {// 显示小程序信息
             // 省去代码
        } else if (type.intValue == 2001) {// 显示红包信息
             // 省去代码
        }else if (type.intValue == 2000) {// 显示转账信息
             // 省去代码
 }

根据推测,类型 49 的消息种类属于链接分享、小程序分享、红包分享等信息,修改 WeChat+hook.m 中为支持该类型的消息:

 #pragma mark - Other
 - (void)autoReplyByAI:(AddMsg *)addMsg
     if (addMsg.msgType != 1 && addMsg.msgType != 49) { // 支持类型 49 的消息
          return;
 }

通过一番修改,Django 后端已经可以接收到关于文章分享链接的信息了,不过收到的是 XML 格式的数据,其中无用消息太多了,会降低不必要的消耗,于是结合 YMMessageHelper.m 中的内容,我对 autoReplyByAI 方法进行了进一步的修改:

 #include "XMLReader.h" // 引入头文件
 [AIModel.specificContacts enumerateObjectsUsingBlock:^(NSString *wxid, NSUInteger idx, BOOL * _Nonnull stop) {
         if (addMsg.msgType == 1) {
           // 原代码
        } else if (addMsg.msgType == 49) {
             NSString *msgContentStr = nil;
             NSString *session = @"";
             if ([addMsg.fromUserName.string containsString:@"@chatroom"]) {
                 NSArray *msgAry = [addMsg.content.stringcomponentsSeparatedByString:@":\n<?xml"];
                 NSArray *sessions = [wxid componentsSeparatedByString:@"@"];
                 if (msgAry.count > 1) {
                     msgContentStr = [NSString stringWithFormat:@"<?xml %@",msgAry[1]];
                } else {
                     msgAry = [addMsg.content.stringcomponentsSeparatedByString:@":\n<msg"];
                     if (msgAry.count > 1) {
                         msgContentStr = [NSString stringWithFormat:@"<msg%@",msgAry[1]];
                     if (sessions.count > 1) {
                         session = sessions[0];
            } else {
                 msgContentStr = addMsg.content.string;
                 session = wxid;
             NSString *url = @"";
             NSString *title = @"";
             NSError *error;
             NSDictionary *xmlDict = [XMLReader dictionaryForXMLString:msgContentStrerror:&error];
             NSDictionary *msgDict = [xmlDict valueForKey:@"msg"];
             NSDictionary *appMsgDict = [msgDict valueForKey:@"appmsg"];
             NSDictionary *titleDict = [appMsgDict valueForKey:@"title"];
             title = [titleDict valueForKey:@"text"];
             NSDictionary *urlDict = [appMsgDict valueForKey:@"url"];
             url = [urlDict valueForKey:@"text"];
             NSDictionary *content = @{
                 @"title":title,
                 @"url":url
             NSError *parseError = nil;
             NSData *jsonData = [NSJSONSerialization dataWithJSONObject:contentoptions:NSJSONWritingPrettyPrinted error:&parseError];
             NSString *str = [[NSString alloc] initWithData:jsonDataencoding:NSUTF8StringEncoding];
            [[YMNetWorkHelper share] GET:str session:session success:^(NSString *str, NSString *session) {
                [[YMMessageManager shareManager] sendTextMessage:strtoUsrName:addMsg.fromUserName.string delay:kArc4random_Double_inSpace(3, 8)];
    }];

以上的修改可以使得非 49 类型的消息正常以文字返回,49 类的消息则以如下的形式返回:

 {
     "title": "使用 SSH Key 访问服务器",
     "url": "https://mp.weixin.qq.com/s/94vbeZCFWzEabHEzqIf8Zg"
 }

在 Xcode 中 Build 时需要在 Target 选项中的「Signing & Capabilities」中选择自己的 Team,另外在 Build 之后微信插件会直接安装,不再需要导出。

Serverless

我对于微信机器人的需求并不是很密集,因为转发文章给机器人的时间可能会集中在某一个时间段,而且这个时候我的电脑一般都是启动着的,所以每次开机之后除了登录自己的个人号之外,还会要再登录机器人的号。这对我来说已经感觉很麻烦了,因此我并不想把运行 Django 的服务放在本地,因为我对回复消息的即时性并不高,因此 Serverless 可以很好地满足我这个需求,客户端(对我来说)和服务端都是按需调用,真正提升了资源利用率。

我用的是腾讯云的云函数服务,每个月有 100 万次的免费调用量足够用了,函数服务只需要选择一个 HTTP 触发器,即 API 网关(出口流量不超过 1元/GB)即可对外服务了。由于不想看到那种近似乱码一样的调用地址,我想绑定 API 网关到自己的域名下,但是 .ai 域名在中国备不了案,而腾讯云大陆地区要给 API 网关绑定自定义域名都需要经过备案,不过腾讯云还有中国香港的区域,该区域域名不需要备案,相较美国等地区又不会降低太多的访问速度。配合 来此加密 下载的 Let’s Encrypt 免费证书可以很快让 API 用上 HTTPS。简单贴一下函数服务内用到的代码:

 # -*- coding: utf8 -*-
 import json
 import requests
 def main_handler(event, context):
     print("Received event: " + json.dumps(event, indent = 2)) 
     print("Received context: " + str(context))
     URL = 'https://www.feishu.cn/flow/api/trigger-webhook/dc8085f840fcf9eab14b3dffe2320281'
     question = event['queryString']['question']
     try:
         question = json.loads(question)
         if 'mp.weixin.qq.com/s?' in question['url']:
             post_data = {
             'title': question['title'],
             'url': question['url']
             r = requests.post(URL, json=post_data)
             data = {
                 "ret": 0,
                 "msg": "ok",
                 "data": {
                     "session": "10000",
                     "answer": "已转存至飞书文档"
     except:
         data = {
             "ret": 0,
             "msg": "ok",
             "data": {