我们常常会遇到向钉钉群中发送消息的需求,所以我把这个简单的功能抽象成了工具:一个钉钉群消息推送助手,并将它开源了。
项目地址:
http://github.com/all4you/walle
瓦力
瓦力是一个轻量级的钉钉群消息发送助手,通过瓦力你只需要配置一个发送消息的模板(支持多个地址,且可以在运行时动态修改),即可快速实现钉钉群消息的发送功能。
特性
- 模板管理: 群消息模板管理,目前支持 TEXT、MARKDOWN、LINK 三种类型群消息
- 多群匹配: 同一个模板支持同时发送至多个钉钉群,且支持条件表达式进行群路由
- JWT凭证管理: 通过 JWT 进行用户凭证的管理
- 开放接口: 对外暴露 REST 接口供用户触发群消息
- 扩展点: 面向接口设计,用户可自行实现各个扩展点
快速开始
- 首先我们需要先部署项目。
- 项目部署成功之后,接着创建账号和模板。
- 账号和模板都创建好之后,就可以进行接口调用了。
部署项目
下载项目
git clone http://github.com/all4you/walle
打包项目
cd walle
mvn clean package
打包时跳过测试:
mvn clean --DskipTests package
打好的包在这个目录:
./walle-core/target/walle-core-0.0.1.jar
启动项目
打包好之后可以直接通过命令行启动:
java -jar ./walle-core/target/walle-core-0.0.1.jar
或者也可以直接通过 mvn 指令启动项目:
cd walle-core
mvn spring-boot:run
数据库资源
创建相应的数据库,并将表结构创建好。
具体的脚本在: walle/walle-core/src/resources/sql/tables.sql
需要注意的是,创建的数据库账号密码要和 application.properties 文件中保持一致。
前端资源
该项目对应的前端页面是通过 vue 实现的,对应的前端项目是 walle-web
修改页面之后需要将最新的资源打包后拷贝到 walle 的资源目录
1.打包
# 构建生产环境
npm run build:prod
2.资源拷贝
打包好的资源在 dist/ 目录下,将该目录下的所有文件都拷贝到 walle 的资源目录下:
rm -rf ~/walle/walle-core/src/main/resources/static/*
cp -R dist/* ~/walle/walle-core/src/main/resources/static/
创建账号和模板
创建账号
项目运行起来之后,打开 http://localhost:7001
系统会进入登录页面,如下图所示:
login.jpg项目没有内置账号,我们点击 “马上注册”,进入账号注册页面:
register.jpg注册成功后,自动跳转回登录页面,输入注册好的账号密码之后,然后点击登录进入首页。
创建模板
登录成功后,进入首页,然后点击左侧的 “模板” —“群消息” 进入群消息模板列表。
然后点击右上角的“新增”按钮,进入创建模板页面,如下图所示:
group_board_create1.jpg目前共支持三种类型的群消息:Text、Markdown、Link。
我们以发送 Markdown 类型的群消息为例,创建一个消息模板,具体的内容如下所示:
group_board_create2.jpg因为是 Markdown 类型,所以正文是一个 Markdown 编辑器来编辑的,必须是 Markdown 的语法。
可以在正文中加入 @1381338xxxx 的手机号来让机器人艾特群里具体的人员。正文和标题都支持 velocity 语法,可以加入变量。
一个模板至少要有一个接收目标,也可以设置多个接收目标,一个接收目标有四个属性:
属性 | 含义 | 必填 |
---|---|---|
机器人名称 | 用来标识这个群机器人的名称 | 是 |
accessToken | 这个群机器人的 accessToken,创建群机器人之后可以拿到 | 是 |
规则条件 | 一个条件表达式,计算结果为 true 时才会发送消息 | 否 |
Secret | 群机器人设置的加签的秘钥 | 否 |
其中必须的就是名称和 accessToken 这个属性,另外基于安全性的考虑,有三种类型的安全设置(查看详情):
- 自定义关键词
- 加签
- IP地址(段)
所以现在我们创建群机器人的话,必须要设置一种安全设置,这里我选择了第二种加签的方式,只需要你把创建机器人的时候设置的密钥填在 Secret 字段里面就好了。
发送消息
一.单个接收目标
选择一个模板然后点击发送消息的按钮,进入消息发送页面,如下图所示:
send_msg_1.jpg发送消息时是可以携带数据的,模板的正文、标题会通过 Velocity 渲染成最终的结果。
条件表达式会通过 aviator 计算出结果,结果为 true 时,才会真正执行发送的操作。
数据编辑好之后,点击发送,不出意外应该会发送成功,如下图所示:
send_msg_success_1.jpg然后你的钉钉群将收到消息,如下图所示:
send_msg_success_2.jpg如果我们携带的数据只有一个 level 字段时,那将会收到这样的消息:
send_msg_success_3.jpg消息中来源和时间的值都是空的,而位置的值是 ${location}
这个字符串,这是根据模板中设置的变量有没有加感叹号确定的。
如果加了感叹号:$!{deviceType}
当该变量的值为空时,渲染的结果会是空字符串,否则会将该变量直接展示出来。
二.多个接收目标
我们可以将一个消息同时发给多个不同的钉钉群,只要在模板中设置多个接收目标就好了,这里就不再赘述了。
三.善用条件匹配
每个接收目标中可以设置一个条件表达式,发送之前会计算条件表达式的值,结果为 true 时才会发送消息。
比如设置如下两个接收目标,条件表达式设置为 [level=='high'],那么只有当携带的数据中有 level 变量,并且值等于 high 时,才会执行消息发送。
PS:条件表达式的计算基于aviator
如下图所示,当携带的数据是空时,消息发送失败:
send_msg_error_1.jpg利用条件表达式,我们可以做很多事情,比如:
- 将不同等级的设备离线告警,分发到不同的告警群,让不同的人员去处理
- 将表达式作为开关使用,比如设置表达式为:[false] 或 [1==2] 这样的值,就相当于不向该接收目标发送消息
接口调用
页面是进行模板管理的,最终发送消息还是要通过接口来操作的。
只需要两步即可通过接口发送消息:
1.引入 walle-api 模块:
<dependency>
<groupId>com.alibaba.walle</groupId>
<artifactId>walle-api</artifactId>
<version>0.0.1</version>
</dependency>
2.创建 WalleClient 并发送消息:
WalleConfig config = WalleConfig.builder()
.endPoint("http://127.0.0.1:7001")
.accessKey("m74hscNSZPVWo3tK")
.secretKey("QsfigP8ibVhgFv5QmcPHkwsV")
.build();
// 创建 WalleClient
WalleClient walleClient = new WalleHttpClient(config);
// 设置请求参数
GroupMessageDTO messageDTO = new GroupMessageDTO();
// 模板编号
messageDTO.setBoardCode("device_offline");
// 携带的数据
JSONObject data = new JSONObject();
data.put("level", "high");
messageDTO.setData(data);
// 发送请求
BaseResult result = walleClient.sendGroupMessage(messageDTO);
System.out.println(result);
其中 accessKey 和 secretKey 在【个人中心】-【安全设置】中查看。
用户凭证: JWT
用户凭证的生成与校验是通过 JWT 实现的。
JSON WEB Token(JWT),是一种基于 JSON 的、用于在网络上声明某种主张的令牌(token)。
JWT 通常由三部分组成:
- 头信息(header)
- 消息体(payload)
- 签名(signature)
头信息指定了该 JWT 使用的签名算法:
header = '{"alg":"HS256","typ":"JWT"}'
HS256
表示使用了 HMAC-SHA256 来生成签名。
消息体包含了 JWT 的意图:
payload = '{"loggedInAs":"admin","iat":1422779638}' // iat表示令牌生成的时间
而签名则是根据 header 和 payload 加密得到,具体的原理如下:
JWT中的数据包括三部分内容:a.b.c
a是header(头部部分),数据保存在一个叫 headerClaims 的Map中
b是payload(负载部分),数据保存在一个叫 payloadClaims 的Map中
payloadClaims可以定义:
+--标准的Claim,JWT保留的Claim,可以通过类似:withIssuedAt的方法来声明
+--公共的Claim
+--私有的Claim,可以声明自定义的Claim,通过方法:addClaim(key, value)来声明
c是signature(签证部分),由 header,payload,通过 secret加密得到,伪代码如下:
String base64Header = base64(header);
String base64Payload = base64(payload);
Algorithm algorithm = Algorithm(secret);
String signature = algorithm.sign(base64Header, base64Payload);
String base64Signature = base64(signature);
最终的 token 由三部分组成:
String jwtToken = String.format("%s.%s.%s", base64Header, base64Payload, base64Signature);
JWT令牌管理使用的是 java-jwt 的库:
https://github.com/auth0/java-jwt
生成 JWT
用户登录成功之后,为该用户创建一个 JWT,并将用户id、登录时间等非敏感信息保存在 JWT 中:
public String newToken(UserDO user, Date loginDate) {
String tokenId = IdUtil.objectId();
// token中保存了部分非敏感信息
return JWT.create()
// 设置创建时间
.withIssuedAt(DateUtil.date())
// 设置过期时间,1小时
.withExpiresAt(expireDate())
// 将部分信息保存到 PayloadClaim 的私有Claim中
.withClaim("userId", user.getUserId())
.withClaim("tokenId", tokenId)
.withClaim("account", user.getAccount())
.withClaim("gmtCreate", DateUtil.formatDateTime(user.getGmtCreate()))
.withClaim("accessKey", user.getAccessKey())
.withClaim("lastLogin", DateUtil.formatDateTime(loginDate))
// 以 JWT_SECRET 作为 token 的密钥对jwt中的数据进行加密
.sign(Algorithm.HMAC256(this.secret()));
}
生成 jwt 之后,将 jwt 写入 HttpServletResponse 的 header 中:
// 3.生成token
String token = tokenFactory.newToken(userDO, DateUtil.date());
/*
* 为了防止跨域请求(CORS),浏览器只能获得几个默认的响应头:
* <ul>
* <li> Cache-Control </li>
* <li> Content-Language </li>
* <li> Content-Type </li>
* <li> Expires </li>
* <li> Last-Modified </li>
* <li> Pragma </li>
* </ul>
* 如果让浏览器能获取其他响应头,需要在响应头中指定需要暴露的响应头
*/
response.addHeader("Access-Control-Expose-Headers", "walle-token");
response.addHeader("walle-token", token);
然后前端从响应头中将 jwt 取出,保存在 Cookie 中,并在之后的所有请求中,将 jwt 添加到 Request Header 中。
校验 JWT
服务端为了判断每个接口的合法性,需要对请求头中的 token 进行校验,校验通过之后才能进行具体的数据操作。
这部分校验工作可以通过 Spring 的拦截器实现,具体的校验部分,如下所示:
public boolean validToken(String token) {
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(this.secret())).build();
try {
jwtVerifier.verify(token);
} catch (JWTVerificationException e) {
GenericLogUtil.invokeError(log, "validToken", StrFormatter.format("token={}", token), e);
return false;
}
return true;
}
但是我们只能通过秘钥来判断 JWT 是否合法,无法做到主动让一个 JWT 失效,比如我们创建了一个 JWT,并设置了1个小时的有效期,在这1个小时之内,都是有效的,哪怕服务端重启了。
记录 JWT 的版本号
由于我们无法作废一个服务端颁布的 JWT,因为 JWT 本身只有一个有效期的校验规则。
为了保证服务端生成的 JWT 可以被主动作废,例如在用户注销登录、修改密码之后,我们需要主动将之前颁布给该用户的 JWT 作废。
为了达到这个目的,我们可以为颁布的 JWT 记录一个版本号,当需要作废一个 JWT 时,将关联的版本号删除即可。
在服务端用一个 Map 来记录每个 JWT 的版本号:
key ===> userId#tokenId
val ===> version
当需要作废一个 JWT 时,将 Map 中的版本号记录删除。
详细信息可参考 com.ngnis.walle.core.auth.TokenFactory
PS:该方式只适用于单机部署,如果服务端需要分布式部署,则需要将 JWT 的版本号保存在 Redis 或其他公共的存储中心。
扩展点: **Center
目前瓦力的设计是面向接口的,核心的账号、模板、消息管理都定义了标准接口,并提供了默认的实现类。
用户可以根据实际需求确定使用默认的实现类还是自定义自己的实现类,并注入到Spring容器中。
-
AccountCenter: 账号中心,可通过
@EnableAccountCenter
引入默认实现类 -
GroupBoardCenter: 模板中心,可通过
@EnableGroupBoardCenter
引入默认实现类 -
MessageCenter: 消息中心,可通过
@EnableMessageCenter
引入默认实现类
或者也可以通过 @EnableWalle
引入所有的默认实现类。
网友评论