基于Erlang的企业微信机器人

在工作的微信群发现可以添加群机器人,本着好奇和探索的心态,用Erlang开发了一个能推送消息的群机器人

环境

企业微信+Erlang+Jiffy框架

关于Jiffy框架的使用我在这篇有过介绍 Erlang解析JSON之Jiffy篇 ,下面是关于机器人代码的添加过程

前置任务

当然是在企业微信的某个群先创建一个群机器人,需要拉两个人才能创群哦,别手误把老大拉进来了~

基于Erlang的企业微信机器人

拉完以后就可以直接添加机器人了,创建完以后会给我们一个机器人的webhook地址,复制下来,然后也有一个简略的API可以查看,

基于Erlang的企业微信机器人

可以发现直接通过该webhook发送post请求,且附带指定json参数,就可以对机器人进行操作,下面我们可以开始尝试用机器人发出第一条消息

搭建demo

发送文字

新建一个gen_server服务,这样能保证在后台持续运行,你也可以添加对应的监控树也好,重启策略也好

我们将发送代码写进其中一个handle_call/2,然后导出一个接口即可

可以把刚才的WebHook新建一个宏定义,等下直接调用定义名即可

-define(WebHook, "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=7fcbd8ef-644c-4473-aaaaaaaaaa").
%% 后面的具体内容替换成你的机器人链接哦

发送hello world

下面我们测试第一种,发送一个hello world的内容

API中测试的例子为

curl 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=693axxx6-7aoc-4bc4-97a0-aaaaaaaaaa' \
   -H 'Content-Type: application/json' \
   -d '
   {
        "msgtype": "text",
        "text": {
            "content": "hello world"
        }
   }'

可以发现请求头为Content-Type: application/json,Data数据部分为json字符

在Erlang中我们可以调用httpc:request来实现该需求

handle_call(test, _From, State) ->
    Msg = jiffy:encode({[{msgtype, <<"text">>},{text, {[{content, <<"hello world">>}]}}]}),
    {ok, _Result}=httpc:request(post, {?WebHook, [], "applcation/json", Msg},[],[]),
    {reply, ok, State};

这里jiffy:encode内的内容对应的就是上面的json内容,是不是觉得很阴间?其实也有第二种写法,可以传入Map的结构,也就是 #{}结构,稍微阳间一点

handle_call(test, _From, State) ->
    Msg = jiffy:encode(#{msgtype => <<"text">>,text=>{[{content,<<"干饭人"/utf8>>}]}}),
    {ok, _Result}=httpc:request(post, {?WebHook, [], "applcation/json", Msg},[],[]),
    {reply, ok, State};

注意每一项的层级对应关系,不放心的话可以先用jiffy:encode导出看看得到的是不是对应的json结构

测试导出

21> jiffy:encode({[{msgtype, <<"text">>},{text, {[{content, <<"hello world">>}]}}]}).
<<"{\"msgtype\":\"text\",\"text\":{\"content\":\"hello world\"}}">>
22> io:format("~ts",[jiffy:encode(#{msgtype => <<"text">>,text=>{[{content,<<"hello world">>}]}})]).
{"text":{"content":"hello world"},"msgtype":"text"}ok

可以发现两种结构导出都没什么问题,都能得到Erlang对应的Json数据流,下面我们测试一下发送

哦对了,启动服务之前,记得启动Erlang中inets和ssl的模块

init([]) ->
    inets:start(),
    ssl:start(),
    {ok, #state{}}.

测试发送

Eshell V10.3  (abort with ^G)
1> robot_demo:start().
{ok,<0.78.0>}
2> robot_demo:test().
ok
3> 

基于Erlang的企业微信机器人

可以发现我们的第一步完成了,hello world成功了那自然是成功了一半,但也不要高兴的太早,下面我们测试一下Erlang发送中文的情况,且以下的json转码前结构我们均采用Map的形式来存储

发送你好我是机器人

我们简单的把content改成中文试试

handle_call(test1, _From, State) ->
    Msg = jiffy:encode(#{msgtype => <<"text">>,text=>{[{content,<<"你好我是机器人">>}]}}),
    {ok, _Result} = httpc:request(post, {?WebHook, [], "applcation/json", Msg}, [], []),
    {reply, ok, State};

编译以后发现直接崩了

gen_server:call(wechat_robot, {send, jiffy:encode(#{msgtype => <<"text">>,text=>{[{content,<<"你好我是机器人">>}]}})}).
** exception error: {invalid_string,<<"�ɷ���">>}
     in function  jiffy:encode/2 (src/ip_set_address/jiffy.erl, line 99)

咋回事呢?既然问题是出现在Jiffy,那就回忆一下Jiffy框架的特性,查看ReadMe和报错的源码

基于Erlang的企业微信机器人

发现问题可能是我传进去的中文问题,将对应编码改为utf8即可,回头看看API发现也是有提示我们这么做的

基于Erlang的企业微信机器人

在Erlang将中文转化为utf8也有两种方案

141> unicode:characters_to_binary("干饭人").
<<229,185,178,233,165,173,228,186,186>>
142> unicode:characters_to_binary("干饭人",utf8).
<<229,185,178,233,165,173,228,186,186>>
143> <<"干饭人"/utf8>>.
<<229,185,178,233,165,173,228,186,186>>

这里关于unicode和utf8的关系大家可以自己去查阅一下,还是挺有意思的,之前我在两种编码上翻过车,记录在Unicode和UTF8的区别

可以发现都没问题,我们修改一下handle_call代码

handle_call(test1, _From, State) ->
%%    Msg = jiffy:encode(#{msgtype => <<"text">>,text=>{[{content,<<"你好我是机器人"/utf8>>}]}}),
    Msg = jiffy:encode(#{msgtype => <<"text">>,text=>{[{content,unicode:characters_to_binary("你好我是机器人")}]}}),
    {ok, _Result} = httpc:request(post, {?WebHook, [], "applcation/json", Msg}, [], []),
    {reply, ok, State};

测试发送

基于Erlang的企业微信机器人

下面我们编写一个更复杂的功能,找一个能返回天气数据的API网站,我们将查找到的天气数据传给这个机器人,让机器人每天早上定时发送天气数据

发送天气数据

首先我们找一个接口网站,这里使用了搞得开放平台的API接口,具体页面如下链接

https://lbs.amap.com/api/webservice/guide/api/weatherinfo#weatherinfo

Tips:高德开放平台注册个人开发账后以后比较稳定,且有很多其他的功能可以玩

可以看到这里返回了一个json数据,我们又要用到Jiffy的解码了

{"status":"1","count":"1","info":"OK","infocode":"10000","lives":[{"province":"广东","city":"广州市","adcode":"440100","weather":"多云","temperature":"23","winddirection":"北","windpower":"≤3","humidity":"39","reporttime":"2021-11-15 15:30:43"}]}

可以发现返回的东西还是有点多了,我们肯定是挑需要的拿,且这里的请求我们还是使用到httpc:request的方法,只不过参数改为get,

请求逻辑

WeatherUrl = "https://restapi.amap.com/v3/weather/weatherInfo?key=这里写你注册的高德的key&city=440100(这里是城市的adcode,我这里写了广州)",
    case catch httpc:request(get, {WeatherUrl, []}, [], [{full_result, false}]) of
        {ok, {200, Json}} ->
            case jiffy:decode(Json) of
                {_Data} ->
                    %% 具体操作
                    ok;
                _ ->
                    ok
                    %%返回错误信息
            end;
        _Err ->
            io:format("请求获取天气信息失败:~w", [_Err])
    end,

这里我们简单写一个解码过程,大概是这个样子,然后我们来解析返回给我们的Json数据

解码过程

我们用httpc请求返回的数据长什么样可以先尝试一下

2>{ok, {200, Json}} = httpc:request(get, {WeatherUrl, []}, [], [{full_result, false}]).
{ok,{200,
     [123,34,115,116,97,116,117,115,34,58,34,49,34,44,34,99,111,
      117,110,116,34,58,34,49,34|...]}}
4> {Data} = jiffy:decode(Json).
{[{<<"status">>,<<"1">>},
  {<<"count">>,<<"1">>},
  {<<"info">>,<<"OK">>},
  {<<"infocode">>,<<"10000">>},
  {<<"lives">>,
   [{[{<<"province">>,<<229,185,191,228,184,156>>},
      {<<"city">>,<<229,185,191,229,183,158,229,184,130>>},
      {<<"adcode">>,<<"440100">>},
      {<<"weather">>,<<229,164,154,228,186,145>>},
      {<<"temperature">>,<<"23">>},
      {<<"winddirection">>,<<229,140,151>>},
      {<<"windpower">>,<<226,137,164,51>>},
      {<<"humidity">>,<<"39">>},
      {<<"reporttime">>,<<"2021-11-15 15:30:43">>}]}]}]}

可以发现返回的参数还可以,我们如果取weather,可以先把Data的外衣{}匹配掉,发现剩下的是一个list,怎么取呢,直接lists:keyfind强行拿出来吧hhh,就有了以下代码

handle_call(get_weather, _From, State) ->
    WeatherUrl = "https://restapi.amap.com/v3/weather/weatherInfo?key=这里写你注册的高德的key&city=440100(这里是城市的adcode,我这里写了广州)",
    WeatherInfo =
        case catch httpc:request(get, {WeatherUrl, []}, [], [{full_result, false}]) of
            {ok, {200, Json}} ->
                case jiffy:decode(Json) of
                    {Data} ->
                        {_, [{LivesData}]} = lists:keyfind(<<"lives">>, 1, Data),
                        {_, Weather} = lists:keyfind(<<"weather">>, 1, LivesData),
                        Weather;
                    _ ->
                        404
                    %%返回错误信息
                end;
            _Err ->
                io:format("请求获取天气信息失败:~w", [_Err]),
                404
        end,
    WeatherMsg = list_to_binary([unicode:characters_to_binary("广州当前天气为:"),
        WeatherInfo]),
    Msg = jiffy:encode(#{msgtype => <<"text">>,
        text=>{[{content, WeatherMsg}]}}),
    {ok, _Result} = httpc:request(post, {?WebHook, [], "applcation/json", Msg}, [], []),
    {reply, ok, State};

关于上面的Json解析,其实也可以用map返回,也更好处理,仁者见仁智者见智吧

handle_call(get_weather, _From, State) ->
    WeatherUrl = "https://restapi.amap.com/v3/weather/weatherInfo?key=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&city=440100",
    WeatherInfo =
        case catch httpc:request(get, {WeatherUrl, []}, [], [{full_result, false}]) of
            {ok, {200, Json}} ->
                case jiffy:decode(Json, [return_maps]) of
                    MapData ->
                        [LivesData] = maps:get(<<"lives">>, MapData),
                        #{<<"weather">> := Weather,<<"city">> := City, <<"humidity">> := Humidity,
                            <<"province">> := Province, <<"reporttime">> := ReportTime,
                            <<"temperature">> := Temperature, <<"winddirection">> := WindDirection,
                            <<"windpower">> := WindPower} = LivesData,
                        io:format("现在是~ts,~ts~ts当前气温为~ts℃,空气湿度为~ts,风力为~ts,风向为~ts",
                            [ReportTime,Province, City, Temperature, Humidity, WindPower, WindDirection]),
                        Weather;
                    _ ->
                        404
                    %%返回错误信息
                end;
            _Err ->
                io:format("请求获取天气信息失败:~w", [_Err]),
                404
        end,
    WeatherMsg = list_to_binary([unicode:characters_to_binary("广州当前天气为:"), WeatherInfo]),
    Msg = jiffy:encode(#{msgtype => <<"text">>,
        text=>{[{content, WeatherMsg}]}}),
    {ok, _Result} = httpc:request(post, {?WebHook, [], "applcation/json", Msg}, [], []),
    {reply, ok, State};

测试发送

拼接是有点丑,但是无伤大雅

基于Erlang的企业微信机器人

返回Map的情况:

1> robot_demo:start().
{ok,<0.78.0>}
2> robot_demo:get_weather().
现在是2021-11-15 16:00:55,广东广州市当前气温为23℃,空气湿度为39,风力为<=3,风向为北ok
3>

当然了,天气API有很多,如果你的API接口多种多样则可以不同信息的解析,下面我们测试一下发送图片

发送图片

这个相对复杂一些,但也都可以解决

首先我们查看一下API,需要些什么,

{
    "msgtype": "image",
    "image": {
        "base64": "DATA",
        "md5": "MD5"
    }
}

乍一看挺简单的,就三个参数,问题来了,Erlang怎么获取这些参数呢?

参数 是否必填 说明
msgtype 消息类型,此时固定为image
base64 图片内容的base64编码
md5 图片内容(base64编码前)的md5值

一步一步来,我们先用Erlang获取图片的base64编码

Erlang获取图片的base64编码

先准备一张图片,我这里准备了一张喝水的表情包,放在我们项目下的目录中

基于Erlang的企业微信机器人

注意大小不要超过2M(表情包会有多大呢?)

基于Erlang的企业微信机器人

核心代码为base64:encode/1

处理图片代码

handle_call(send_pic, _From, State) ->
    Md5 =
        case os:type() of
            {win32, _} ->
%%                DD = os:cmd("certutil -hashfile " ++ Path ++" MD5"),
                [_, MatchMd5Win, _] = string:tokens(os:cmd("certutil -hashfile ./pic/drink.jpg MD5"), "\r\n"),
                MatchMd5Win;
            _ ->
%%                [H|_] = string:tokens(os:cmd("md5sum " ++ Path)," "),
                [H | _] = string:tokens(os:cmd("md5sum \"drink.jpg\""), " "),
                H
        end,
    {ok, BinStream} = file:read_file("./pic/drink.jpg"),
    Base64 = base64:encode(BinStream),
    Msg = jiffy:encode(#{msgtype => <<"image">>, image =>
    {[{base64, unicode:characters_to_binary(Base64)}, {md5, unicode:characters_to_binary(Md5)}]}}),
    {ok, _Result} = httpc:request(post, {?WebHook, [], "applcation/json", list_to_binary(Msg)}, [], []),
    {reply, ok, State};

注意这里的jiffy:encode返回的是一个list(在这里卡了很久,不知道为什么这里返回一个list,是因为太长了自动拼接了?),所以我们要把list转回binary,再推送给我们的机器人接口,同时这里写了两种win和linux下 获得md5加密值的方式,感觉还是挺有意思的,两种环境下的md5好像是不一样的,这里都调用了系统的cmd md5接口,没有使用erlang:md5,那个返回的东西要自己转码,也不是不能用,但是转出来的不太一致,建议不用

win下调用erlang:md5的值(建议不用)

5> list_to_binary([io_lib:format("~2.16.0b", [N]) || N <- binary_to_list(erlang:md5("drink.jpg"))]).
<<"921824ad7b62bfd94f5f7525cced39be">>

测试发送

基于Erlang的企业微信机器人

到这里教程就告一段落了,之所以写在gen_server中也是方便服务一直运行,你可以编写特定的时间函数,让机器人在指定的时间推送指定的消息,如早上八点报天气,饭点发个干饭表情包,每两个小时来一张提醒喝水表情包等,甚至可以把这个模块放进项目中(别git push了哦),在项目报错的时候,监控Err信息转发给机器人,将对应的错误发送到群聊中

最后祭出喝水表情包原图

基于Erlang的企业微信机器人

上一篇:rabbitmq和erlang版本


下一篇:RabbitMq安装(Erlang前置安装)