[TOC]
公司使用了Gitlab,Jira等工具來管理,溝通方面主要是釘釘,但鬱悶的是各系統相互獨立,而我已經習慣了前公司那種方式:
有bug的時候會自動傳送訊息到聊天框中,而不是目前這樣,需要開發人員手動定時去重新整理jira頁面才能知道,效率低下;gitlab也是一樣,有merge請求的時候,我希望不需要別人提醒我去稽核程式碼,而是gitlab直接傳送merge訊息到我釘釘即可;
可能其他同事習慣郵件通知吧,公司並無打通各系統與釘釘聯絡的計劃,所以我只能自己擼一套了,我不是專職後端,輕噴,功能夠我用就好;
Github專案地址
原理: 利用各系統自帶的
Webhook
功能, 在觸發指定操作時,傳送一條hook
資訊到我們的伺服器上, 伺服器做出處理後轉發訊息到對應人員的釘釘上;
效果展示
相關文件
步驟
- 在
gitlab
上啟用Webhooks
通知(可指定要Webhooks
的操作,這裡hook了merge
操作);
注意:需要專案管理許可權才能設定,jira
也是類似; - 在server端,根據
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
- 在
server
端獲取釘釘的人員資訊,並呼叫其 企業會話訊息介面 傳送指定訊息;
由於該會話介面需要員工id
和企業應用id
以及access_token
,而 獲取access_token 需要CorpId
和CorpSecret
(二者是企業的唯一標識);公司當然不可能對個人開放其
CorpId
等資訊 ,因此還是自己註冊一個企業,建立部門並新增你想通知的人員作為部門員工即可,這樣也能獲取員工的 通訊錄詳情 , 得到其userId
,從而傳送訊息到其釘釘上; - 建立一個微應用,以該應用為會話發起人來傳送訊息;
建立釘釘微應用
- 在 釘釘開放平臺 中搜尋
微應用
就可以找到Step 1 -- 註冊釘釘企業
的 連結; - 根據上面的
step
引導操作註冊企業並新增部門和員工,然後進入 釘釘管理後臺; - 切換到
工作臺
標籤頁(即上圖中的企業應用
,現已改名,偷懶就不重新截圖了╮( ̄▽ ̄)╭) , 點選下方的 自建應用 ,按需填寫資訊; - 完成後點選新建的微應用圖示,選擇
設定
即可檢視到微應用的AgentID
;
獲取企業的 CorpID
和 CorpSecret
通訊錄規則
在通訊錄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請求
// kotlin
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資訊
// kotlin
// 給請求新增統一的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
// kotlin
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及名稱; - 部門成員通訊錄存放在
ConstantsPara.departmentMemberMap
中, 也是一個HashMap
, 記錄部門id及部門中的所有成員資訊;
備註:
- 部門名稱需跟
gitlab
專案名稱對應,需要群發時通過專案名稱查詢對應的部門id; (目的: 建立 gitlab 專案 與 釘釘部門之間的對映關係); - 部門id用於唯一確定部門,用於查詢指定部門成員資訊;
- 其中部門id為
1
的是公司的根部門(root部門),要將所有人員都新增進去,以便在需要通知指定人員時,能從root部門成員中通過查詢使用者姓名獲取其使用者id,然後發出釘釘訊息;
// kotlin
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. 傳送釘釘訊息
/**
* kotlin
* 向指定使用者[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;
- 釘釘自帶有
聊天機器人
, 直接支援幾個平臺的 webhook 訊息, 不過只能轉發到群
中, 對不需要關注的成員來說, 就是垃圾訊息, 而且訊息格式固定死板,靈活性不強,具體操作可到 釘釘開放平臺 搜尋機器人
檢視;