[TOC]
公司使用了Gitlab,Jira等工具來管理,溝通方面主要是釘釘,但郁悶的是各系統相互獨立,而我已經習慣了前公司那種方式:
有bug的時候會自動發送消息到聊天框中,而不是目前這樣,需要開發人員手動定時去刷新jira頁面才能知道,效率低下;gitlab也是一樣,有merge請求的時候,我希望不需要別人提醒我去審核代碼,而是gitlab直接發送merge消息到我釘釘即可;
可能其他同事習慣郵件通知吧,公司并無打通各系統與釘釘聯系的計劃,所以我只能自己擼一套了,我不是專職后端,輕噴,功能夠我用就好;
已遷移到 掘金, 歡迎關注;
更新記錄:
1. 2017.04.17 發現釘釘直接支持幾個平臺的webhook推送
-
打開釘釘群聊天框右上角的聊天機器人
聊天機器人 -
選擇其中需要的平臺
hook列表 -
添加完后再對應平臺的設置中添加webhook地址即可;
但感覺這個比較粗糙,以jira為例,消息過于精簡,而且通知到群里的話,會讓用戶操心了本不需要操心的內容,個人覺得,這個比較適合gitlab的merge代碼被通過時的通知,通知成員用戶更新本地代碼:
jira通知示例
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
也是類似)
gitlab添加webhook - 在服務端,根據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;