在工作的微信群发现可以添加群机器人,本着好奇和探索的心态,用Erlang开发了一个能推送消息的群机器人
环境
企业微信+Erlang+Jiffy框架
关于Jiffy框架的使用我在这篇有过介绍 Erlang解析JSON之Jiffy篇 ,下面是关于机器人代码的添加过程
前置任务
当然是在企业微信的某个群先创建一个群机器人,需要拉两个人才能创群哦,别手误把老大拉进来了~
拉完以后就可以直接添加机器人了,创建完以后会给我们一个机器人的webhook地址,复制下来,然后也有一个简略的API可以查看,
可以发现直接通过该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>
可以发现我们的第一步完成了,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和报错的源码
发现问题可能是我传进去的中文问题,将对应编码改为utf8即可,回头看看API发现也是有提示我们这么做的
在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};
测试发送
下面我们编写一个更复杂的功能,找一个能返回天气数据的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};
测试发送
拼接是有点丑,但是无伤大雅
返回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编码
先准备一张图片,我这里准备了一张喝水的表情包,放在我们项目下的目录中
注意大小不要超过2M(表情包会有多大呢?)
核心代码为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">>
测试发送
到这里教程就告一段落了,之所以写在gen_server中也是方便服务一直运行,你可以编写特定的时间函数,让机器人在指定的时间推送指定的消息,如早上八点报天气,饭点发个干饭表情包,每两个小时来一张提醒喝水表情包等,甚至可以把这个模块放进项目中(别git push了哦),在项目报错的时候,监控Err信息转发给机器人,将对应的错误发送到群聊中
最后祭出喝水表情包原图