[TOC]
公司使用了Gitlab,Jira等工具来管理,沟通方面主要是钉钉,但郁闷的是各系统相互独立,而我已经习惯了前公司那种方式:
有bug的时候会自动发送消息到聊天框中,而不是目前这样,需要开发人员手动定时去刷新jira页面才能知道,效率低下;gitlab也是一样,有merge请求的时候,我希望不需要别人提醒我去审核代码,而是gitlab直接发送merge消息到我钉钉即可;
可能其他同事习惯邮件通知吧,公司并无打通各系统与钉钉联系的计划,所以我只能自己撸一套了,我不是专职后端,轻喷,功能够我用就好;
已迁移到 掘金, 欢迎关注;
更新记录:
1. 2017.04.17 发现钉钉直接支持几个平台的webhook推送
-
打开钉钉群聊天框右上角的聊天机器人
-
选择其中需要的平台
-
添加完后再对应平台的设置中添加webhook地址即可;
但感觉这个比较粗糙,以jira为例,消息过于精简,而且通知到群里的话,会让用户操心了本不需要操心的内容,个人觉得,这个比较适合gitlab的merge代码被通过时的通知,通知成员用户更新本地代码:
2. 2017.8.30 重构项目
使用gradle/kotlin/rxjava/retrofit等改造了之前的项目,支持快速新增gitlab项目部门,手动刷新accessToken及部门信息等功能,部分示例如下,具体请看 项目 :
P.S.重构后,war包大小由原来的30+M减小到5M左右 ==!
Github项目地址
相关文档
Gitlab webhook document
Jira webhook document
钉钉开放文档-服务器端
步骤
- 在
gitlab
上启用Webhooks
通知(可指定要Webhooks
的操作,这里仅hookmerge
操作,注意:需要项目管理权限才能设定,jira
也是类似)
- 在服务端,根据post请求的head信息来区分不同系统发来的hook消息:
- gitlab的merge请求包含:
X-Gitlab-Event:Merge Request Hook
- jira的hook请求包含:
user-agent:Atlassian HttpClient0.17.3 / JIRA-6.3.15 (6346) / Default
- 在服务器端打开获取钉钉的人员信息,并调用其 企业会话消息接口 发送指定信息;
由于该会话接口需要 员工id和企业应用id以及access_token,而 获取access_token 需要CorpId
和CorpSecret
(二者是企业的唯一标识), 因此可知: - 虽然公司的钉钉后台上有
CorpId
等信息,但不一定会开放,而等公司组织人员开发又可能遥遥无期,因此还是自己注册一下企业,创建部门并添加你想通知的人员作为部门员工即可,这样也能获取员工 通讯录详情 , 得到用userId,从而发送钉钉消息; -
需要创建一个微应用,以该应用为会话发起人来发送消息;
建立钉钉微应用
- 在 钉钉开放平台 中搜索
微应用
就可以找到Step 1 -- 注册钉钉企业
的 链接; - 根据上面的
step
引导操作注册企业并添加部门和员工,然后进入 钉钉管理后台; - 在
企业应用
标签页左侧导航条中选择 微应用设置 即可在右侧看到CorpID
和CorpSecret
; - 在
企业应用
标签下新建应用
即可; - 完成后点击新建的微应用图标,选择
设置
接口查看到微应用的AgentID
;
通讯录规则
在通讯录root部门中添加所有人,以便发送消息到特定用户时可以从root部门中通过查询用户姓名得到用户id;
根据gitlab项目路径配置各项目部门,比如:
- 假设gitlab项目地址为: https://gitlab.lynxz.org/demo-android/detail-android
则表示项目名称(name
) 为:detail-android
,项目所在空间(namespace
)为:demo-android
- 在钉钉后台通讯录中需要先创建部门:
demo_android
,然后创建其子部门detail_android
注意: 由于钉钉部门名称不允许使用-
,因此创建时改为_
替代 - 目前只支持两级部门结构,若有多个部门符合上述规则gitlab merge通过时会通知所有匹配的部门成员;
备注: 更新钉钉通讯录后,记得及时通知server刷新本地数据,本版支持通过url出发刷新命令,直接访问如下网址即可(其中yourServerHost
是war包运行后的访问地址):
{yourServerHost}/action/updateDepartmentInfo
钉钉发送消息流程
1. retrofit请求
interface ApiService {
/**
* [获取钉钉AccessToken](https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.dfrJ5p&treeId=172&articleId=104980&docType=1)
* @param id corpid 企业id
* @param secret corpsecret 企业应用的凭证密钥
* */
@GET("gettoken")
fun getAccessToken(@Query("corpid") id: String, @Query("corpsecret") secret: String): Observable<AccessTokenBean>
/**
* [获取部门列表信息](https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.xIVqtB&treeId=172&articleId=104979&docType=1#s0)
*/
@GET("department/list")
fun getDepartmentList(): Observable<DepartmentListBean>
/**
* [获取指定部门的成员信息,默认获取全部成员](https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.xIVqtB&treeId=172&articleId=104979&docType=1#s12)
* */
@GET("user/simplelist")
fun getDepartmentMemberList(@Query("department_id") id: Int = 1): Observable<DepartmentMemberListBean>
/**
* [向指定用户发送普通文本消息](https://open-doc.dingtalk.com/docs/doc.htm?spm=a219a.7629140.0.0.oavHEu&treeId=172&articleId=104973&docType=1#s2)
*/
@POST("message/send")
fun sendTextMessage(@Body bean: MessageTextBean): Observable<MessageResponseBean>
}
2. 添加必要的request信息
// 给请求添加统一的query参数:access_token
// 这里的ConstantsPara.accessToken是全局变量,存储获取到的accessToken
val queryInterceptor = Interceptor { chain ->
val original = chain.request()
val url = original.url().newBuilder()
.addQueryParameter("access_token", ConstantsPara.accessToken)
.build()
val requestBuilder = original.newBuilder().url(url)
chain.proceed(requestBuilder.build())
}
// 给请求添加统一的header参数:Content-Type
val headerInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Content-Type", "application/json")
.build()
chain.proceed(request)
}
val okHttpClient: OkHttpClient = OkHttpClient()
.newBuilder()
.addInterceptor(headerInterceptor)
.addInterceptor(queryInterceptor)
.build()
val ddRetrofit: Retrofit = Retrofit.Builder()
.client(okHttpClient)
.baseUrl("https://oapi.dingtalk.com/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
val apiService: ApiService = ddRetrofit.create(ApiService::class.java)
3. 刷新钉钉的AccessToken
apiService.getAccessToken(ConstantsPara.dd_corp_id, ConstantsPara.dd_corp_secret)
.retry(1)
.subscribe(object : Observer<AccessTokenBean> {
override fun onError(e: Throwable) {
e.printStackTrace()
}
override fun onSubscribe(d: Disposable) {
addDisposable(d)
}
override fun onComplete() {
}
override fun onNext(t: AccessTokenBean) {
println("refreshAccessToken $t")
ConstantsPara.accessToken = t.access_token ?: ""
}
})
4. 获取部门列表及各部门下的成员信息
部门信息存放在 ConstantsPara.departmentNameMap
中,是一个hashmap,记录部门id及名称,id用于唯一确定部门,以便后续查找指定部门成员信息;
部门名称需跟gitlab项目名称对应,其中部门id为1的是公司的根部门,主要要将所有人员都添加进去,因为通知指定人员时,是从根部门中查找用户姓名,若匹配就发出消息,而子部门的存在只为适配gitlab项目路径;
apiService.getDepartmentList()
.flatMap { list ->
ConstantsPara.departmentList = list
list.department.forEach { ConstantsPara.departmentNameMap.put(it.id, it.name) }
Observable.fromIterable(list.department)
}
.map { departmentBean -> departmentBean.id }
.flatMap { departmentId ->
Observable.zip(Observable.create({ it.onNext(departmentId) }),
apiService.getDepartmentMemberList(departmentId),
BiFunction<Int, DepartmentMemberListBean, DepartmentMemberListBean> { t1, t2 ->
t2.departmentId = t1
t2
})
}
.retry(1)
.subscribe(object : Observer<DepartmentMemberListBean> {
override fun onNext(t: DepartmentMemberListBean) {
ConstantsPara.departmentMemberMap.put(t.departmentId, t.userlist)
}
override fun onSubscribe(d: Disposable) {
addDisposable(d)
}
override fun onError(e: Throwable) {
e.printStackTrace()
}
override fun onComplete() {
println("getDepartmentInfo onComplete:\n${ConstantsPara.departmentMemberMap.keys.forEach { println("departId: $it") }}")
// sendTextMessage(ConstantsPara.defaultNoticeUserName, "test from server")
}
})
5. 发送钉钉消息
/**
* 向指定用户[targetUserName]发送文本内容[message]
* 若目标用户名[targetUserName]为空,则发送给指定部门[departmentId]所有人,比如gitlab merge请求通过时,通知所有人
* */
fun sendTextMessage(targetUserName: String? = null, message: String = "", departmentId: Int = 1) {
ConstantsPara.departmentMemberMap[departmentId]?.apply {
stream().filter { targetUserName.isNullOrBlank() or it.name.equals(targetUserName, true) }
.forEach {
val textBean = MessageTextBean().apply {
touser = it.userid
agentid = ConstantsPara.dd_agent_id
msgtype = MessageType.TEXT
text = MessageTextBean.TextBean().apply {
content = message
}
}
apiService.sendTextMessage(textBean)
.subscribeOn(Schedulers.io())
.subscribe(object : Observer<MessageResponseBean> {
override fun onComplete() {
}
override fun onSubscribe(d: Disposable) {
addDisposable(d)
}
override fun onNext(t: MessageResponseBean) {
println("${msec2date()} sendTextMessage $t")
}
override fun onError(e: Throwable) {
e.printStackTrace()
}
})
}
}
}
其他说明
- 钉钉消息有个 限制, 因此我在所有消息文本中添加服务器当前时间,尽量确保每条消息都不同:
forbiddenUserId: 因发送消息过于频繁或超量而被流控过滤后实际未发送的userid。未被限流的接收者仍会被成功发送。限流规则包括:1、给同一用户发相同内容消息一天仅允许一次;2、如果是ISV接入方式,给同一用户发消息一天不得超过50次;如果是企业接入方式,此上限为500。
- jira的hook信息若是存在
changelog
则表明有用户修改了issue的状态或者内容,另外,issuse.comment
一定存在, 数组comments
存储了用户提交的所有备注信息,按时间先后顺序排列; - accessToken的有效期为7200秒,因此项目中需要定时刷新token;