新玩具-企业微信机器人

这个机器人其实蛮久前就做好了,现在才写了点分享出来。 最近企业微信不断地开放了机器人的接口,所以我想想拿来做一些开发工具集成也是挺不错的,顺便也是为了继续熟悉一下 Rust 的编程习惯。 那么这次就大量使用 futures 来实现这个机器人的接口服务,这也是即将到来的无栈协程语法糖 await 的基石。

企业微信机器人大体上分为两个部分,第一个部分是主动推送消息。就是机器人创建好以后,会给一个地址,用这个地址按文档发json的HTTP/HTTPS请求就可以用机器人发消息了。这类接口给的消息种类比较多,图片、功能及其首先的Markdown、带At功能的文字等都可以。这个主动发消息的接口我就用 python 实现了。

机器人主要是实现Web Server监听来自企业微信的消息,

graph LR;
    收包-->Dispatch;
    Dispatch-->鉴权;
    鉴权-->解密;
    解密-->执行处理;
    执行处理-->打包;
    打包-->加密;
    加密-->二次打包;
    二次打包-->Response;

这个流程。截至到我写这篇分享为止,回包还仅支持Markdown和Text两种。 先贴下成果吧: https://github.com/owent/wxwork_robotd

关于文献

Rust 官网改版了,之前的 《Rust 程序设计语言》 变得很难找到了,这里记录下这本书的地址:

现在翻译版本也很完整了,但是第一版英文原版似乎被删除了,其实我觉得第二版的关于宏的部分讲得很模糊,特别是过程宏。Rust 现有的很多库的语法糖和高级特新都是过程宏,它允许在编译期直接对抽象语法树(AST)做Patch,十分强大(我还是很怀疑这样工程规模大了以后编译是不是也是奇慢无比)。这方面也结合一下原来第一版的文档和其他的一些文档(我之前文章里贴的 https://danielkeep.github.io/tlborm/book/index.html )。

企业微信机器人的通用服务接入

前面也贴了大致的流程,实际执行的时候还有一些工作在 执行处理 这个阶段。首先我想要支持多个命令,于是对输入消息就采用了正则表达式的方式。用输入的消息依次匹配到一个可以匹配到的语句,然后执行内容。另外考虑到想要一个机器人服务可以提供多个机器人,并且可以共用一部分命令,所以机器人服务内部加了一个 项目 机制首先通过 URL找到对应的项目, 然后指令部分分为了 公共指令项目指令 。收到消息后先去匹配 项目指令 ,如果匹配不成功再去匹配 公共指令 , 还是不成功的话依次查找 项目指令公共指令 指令里的默认项目。

一开始我接入的 执行处理 的实际执行内容的时候,考虑的是接入内部的CI系统。所以行为是访问一个HTTP请求,当然要支持多个的话必然涉及变量。我找到一个看起来还不错的模板引擎 handlebars 就用它对部分变量做参数填充了。而后为了测试方便增加了echo命令来直接输出消息;为了统一自动输出帮助消息增加了help命令来自动生成所有可用的命令描述然后数据;为了更灵活增加了spawn命令用于起一个子线程执行任意脚本或程序。 为了方便子进程里读取到接出来的企业微信的消息数据把配置的变量和一些匹配结果都写到了环境变量里,这样子进程或者脚本直接读取对应的环境变量就行了。

大致流程就是这样:

graph TB;
    执行消息-->URL查找项目;
    URL查找项目-->项目1;
    URL查找项目-->项目2-命中;
    URL查找项目-->项目3;
    URL查找项目-->项目...;
    项目2-命中-->匹配项目指令;
    匹配项目指令-->项目指令1;
    匹配项目指令-->项目指令2;
    匹配项目指令-->项目指令...;
    匹配项目指令-->全部失败则匹配全局指令;
    全部失败则匹配全局指令-->全局指令1;
    全部失败则匹配全局指令-->全局指令2;
    全部失败则匹配全局指令-->全局指令...;
    项目指令2-->命中指令;
    全局指令1-->命中指令;
    命中指令-->准备执行环境;
    准备执行环境-->执行内容;
    执行内容-->打包结果;
    打包结果-->构造回包;

在第一阶段实现完成之后,企业微信又增加了分享机器人的功能。这样一个机器人的URL就可能对应多个群,我们原来有个脚本为了发送图片回去,是收到消息后启动一个后台脚本,执行完调用发消息的接口去发送结果的,而这么一来以后,原来的发布消息的接口变成了群发。 后来我看了下它也增加了个一个 ChatId 字段和 GetChatInfoUrl 字段。前面一个用于区分来源的群,收到消息以后。发消息接口附带这个参数就能实现仅回复来源的群,而后一个接口是用于拉取来源群的信息的。

另外我们内网的地址和外部的机器人地址不一样,所以为了方便我也是提取出了机器人的KEY,以便后台任务执行完后通知的时候直接用转换后的地址。还有些零零碎碎的字段都在最开始贴的项目地址里了。

跨平台构建cross和自动release

之前也提到rust的嵌入式小组提供了交叉编译的工具链 cross 。这其实是一套基于docker的工具链集合,帮我们把一些通用的依赖库和工具链准备好了,这样我们就不用每个环境自己去配交叉编译环境。 我就依赖这个部署了 appveyor和travis-ci的自动部署服务。

在使用过程中我发现这套工具还不是非常稳定,特别是MIPS架构下有些很基础的库构建不出来,当然这也算是这些库的构建脚本或者代码有点问题。所以最终我只提供了我测试能够打包出来的几个版本,至少目前Linux的ARM、ARM64、x86、x86_64和和Windows的x86、x86_64没什么太大问题,部分架构下的musl工具链也编不出来。最终自动发布的结果都放在 https://github.com/owent/wxwork_robotd/releases 了,以后发现有新的可用适配环境的话再加吧。

大家有兴趣也可以下载自己需要的架构的预编译好的机器人发布包自己Happy自己玩。配置大概这个样子:

{
    "listen": ["0.0.0.0:12019", ":::12019"], // 监听列表,这里配置了ipv4和ipv6地址
    "taskTimeout": 4000,                     // 超时时间4000ms,企业微信要求在5秒内回应,这里容忍1秒钟的网络延迟
    "workers": 8,                            // 工作线程数
    "backlog": 256,                          // 建立连接的排队长度
    "cmds": {                                // 这里所有的command所有的project共享
        "default": {                         // 如果找不到命令,会尝试找名称为default的命令执行,这时候
            "type": "echo",                  // 直接输出类型的命令
            "echo": "我还不认识这个指令呐!({{WXWORK_ROBOT_CMD}})", // 输出内容
            "hidden": true                   // 是否隐藏,所有的命令都有这个选项,用户help命令隐藏这条指令的帮助信息
        },
        "(help)|(帮助)|(指令列表)": {
            "type": "help",                    // 帮助类型的命令
            "description": "help|帮助|指令列表", // 描述,所有的命令都有这个选项,用于help类型命令的输出,如果没有这一项,则会直接输出命令的key(匹配式)
            "prefix": "### 可用指令列表\r\n"       // 帮助信息前缀
            "suffix": ""                       // 帮助信息后缀
        },
        "说\\s*(?P<MSG>[^\\r\\n]+)": {
            "type": "echo",
            "echo": "{{WXWORK_ROBOT_CMD_MSG}}", // 可以使用匹配式里的变量
            "description": "说**消息内容**"
        },
        "执行命令\\s*(?P<EXEC>[^\\s]+)\\s*(?P<PARAM>[^\\s]*)": {
            "type": "spawn",                    // 启动子进程执行命令,注意,任务超时并不会被kill掉
            "exec": "{{WXWORK_ROBOT_CMD_EXEC}}",
            "args": ["{{WXWORK_ROBOT_CMD_PARAM}}"],
            "cwd": "",
            "env": {                            // 命令级环境变量,所有的命令都有这个选项,这些环境变量仅此命令有效
                "TEST_ENV": "all env key will be WXWORK_ROBOT_CMD_{NAME IN ENV} or WXWORK_ROBOT_PROJECT_{NAME}"
            },
            "description": "执行命令**可执行文件路径** ***参数***",
            "output_type": "输出类型"            // markdown/text
        }
    },
    "projects": [{                                                          // 项目列表,可以每个项目对应一个机器人,也可以多个机器人共享一个项目
        "name": "test_proj",                                                // 名称,影响机器人回调路径,比如说这里的配置就是: http://外网IP:/12019/test_proj/
        "token": "hJqcu3uJ9Tn2gXPmxx2w9kkCkCE2EPYo",                        // 对应机器人里配置的Token
        "encodingAESKey": "6qkdMrq68nTKduznJYO1A37W2oEgpkMUvkttRToqhUt",    // 对应机器人里配置的EncodingAESKey
        "env": {                                                            // 项目级环境变量,这些环境变量仅此项目有效
            "testURL": "robots.txt"
        },
        "cmds": {                                                           // 项目级命令,这些命令仅此项目有效
            "http请求": {
                "type": "http",                                             // http请求类命令
                "method": "get",                                            // http方法,可选值为 get/post/put/delete/head,如果不填则会自动从设置,如果post里有数据则会自动设为post,否则自动设为get
                "url": "https://owent.net/{{WXWORK_ROBOT_PROJECT_TEST_URL}}", // http请求地址
                "post": "",                                                   // body里的数据
                "content_type": "",                                           // content-type,可不填
                "headers": {                                                  // 请求的额外header
                    "X-TEST": "value"
                },
                "echo": "已发起HTTP请求,回包内容\r\n{{WXWORK_ROBOT_HTTP_RESPONSE}}" // 机器人回应内容
            },
            "访问\\s*(?P<URL>[^\\r\\n]+)": {
                "type": "http",
                "url": "{{WXWORK_ROBOT_CMD_URL}}",
                "post": "",
                "echo": "HTTP请求: {{WXWORK_ROBOT_CMD_URL}}\r\n{{WXWORK_ROBOT_HTTP_RESPONSE}}",
                "description": "访问**URL地址**"
            }
        }
    }]
}

当然实际配置里用的是标准json,所以是不支持注释的。上面的例子里projects只配了一个(访问地址为: http://127.0.0.1:12019/test_proj ),但是也是支持配多个的。README.md 里的用法说明应该还是比较完整易懂的。

自动由CI发release包,之前美搞过。这次也是为了方便打包发布多架构平台试了下。这方面倒是没什么坑,appveyor在页面上由token加密工具。travis得用命令行,稍微麻烦点。

一些感想

倒腾完这个小玩具,我也基本上了解了 rustfutures 的设计模型和设计思路了吧。通过标准化的依赖反转来减轻开发人员的心智负担,同时为了静态类型语言而做的各种类型转换着实是有点绕,要各种 map 结果和 map_err ,如果涉及join操作可能还要提取结果,也是有点反人类。等新版出来移除了强制的Error类型依赖应该会简单一些。另外还有不同futures之间的参数传递要保证最优的生命周期管理就只能借助它的零开销移动语义,通过Result或者Error传递给下一跳了,这个在 await 功能标准化了以后也能解决这个问题。

C++下一代里的协程设计也差不多是这个思路,可惜C++不支持过程宏,所以接入起来目前看来非常的恶心,也很不直观。印象中挺久以前有位大神提了个编译期反射的草案,看起来有点过程宏的意思,但是还没强大到到能够修改语法树的程度。

Rust 的路还很长远,期待ing…。