打通Gitlab與釘釘之間的通訊

我啥時候說啦jj發表於2017-12-29

[TOC]

公司使用了Gitlab,Jira等工具來管理,溝通方面主要是釘釘,但鬱悶的是各系統相互獨立,而我已經習慣了前公司那種方式:
有bug的時候會自動傳送訊息到聊天框中,而不是目前這樣,需要開發人員手動定時去重新整理jira頁面才能知道,效率低下;

gitlab也是一樣,有merge請求的時候,我希望不需要別人提醒我去稽核程式碼,而是gitlab直接傳送merge訊息到我釘釘即可;

可能其他同事習慣郵件通知吧,公司並無打通各系統與釘釘聯絡的計劃,所以我只能自己擼一套了,我不是專職後端,輕噴,功能夠我用就好;

Github專案地址

原理: 利用各系統自帶的 Webhook 功能, 在觸發指定操作時,傳送一條 hook 資訊到我們的伺服器上, 伺服器做出處理後轉發訊息到對應人員的釘釘上;

效果展示

gitlab有新merge程式碼稽核請求時會通知稽核人
gitlab merge 請求被通過時,會通知相關專案部門所有成員更新程式碼

相關文件

步驟

  1. gitlab 上啟用 Webhooks 通知(可指定要 Webhooks 的操作,這裡hook了 merge 操作);
    注意:需要專案管理許可權才能設定, jira 也是類似;
    gitlab新增webhook
  2. 在server端,根據 post 請求的 head 資訊來區分不同系統發來的 hook 訊息:
    • gitlabmerge 請求包含: X-Gitlab-Event:Merge Request Hook
    • jirahook 請求包含: user-agent:Atlassian HttpClient0.17.3 / JIRA-6.3.15 (6346) / Default
  3. server 端獲取釘釘的人員資訊,並呼叫其 企業會話訊息介面 傳送指定訊息;
    由於該會話介面需要 員工id企業應用id 以及 access_token ,而 獲取access_token 需要 CorpIdCorpSecret (二者是企業的唯一標識);

    公司當然不可能對個人開放其 CorpId 等資訊 ,因此還是自己註冊一個企業,建立部門並新增你想通知的人員作為部門員工即可,這樣也能獲取員工的 通訊錄詳情 , 得到其 userId ,從而傳送訊息到其釘釘上;

  4. 建立一個微應用,以該應用為會話發起人來傳送訊息;
    釘釘管理後臺

建立釘釘微應用

  1. 釘釘開放平臺 中搜尋 微應用 就可以找到 Step 1 -- 註冊釘釘企業連結;
  2. 根據上面的 step 引導操作註冊企業並新增部門和員工,然後進入 釘釘管理後臺;
  3. 切換到 工作臺 標籤頁(即上圖中的 企業應用,現已改名,偷懶就不重新截圖了╮( ̄▽ ̄)╭) , 點選下方的 自建應用 ,按需填寫資訊;
  4. 完成後點選新建的微應用圖示,選擇 設定 即可檢視到微應用的 AgentID;

獲取企業的 CorpIDCorpSecret

  1. 登入 釘釘管理後臺;
  2. 點選 工作臺 - 應用開發 即可檢視到企業的 CorpIDCorpSecret資訊;

    文件連結若有變化, 請自行到 釘釘開放平臺 搜尋 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()
                                }
                            })
                }
    }
}
複製程式碼

其他說明

  1. 釘釘訊息有個 限制, 因此我在所有訊息文字中新增伺服器當前時間,儘量確保每條訊息都不同:

forbiddenUserId: 因傳送訊息過於頻繁或超量而被流控過濾後實際未傳送的userid。未被限流的接收者仍會被成功傳送。
限流規則包括:
1、給同一使用者發相同內容訊息一天僅允許一次;
2、如果是ISV接入方式,給同一使用者發訊息一天不得超過50次;如果是企業接入方式,此上限為500。

  1. jira的hook資訊若是存在 changelog 則表明有使用者修改了issue的狀態或者內容,另外, issuse.comment 一定存在, 陣列 comments 儲存了使用者提交的所有備註資訊,按時間先後順序排列;
  2. accessToken的有效期為7200秒,因此專案中需要定時重新整理token;
  3. 釘釘自帶有 聊天機器人 , 直接支援幾個平臺的 webhook 訊息, 不過只能轉發到 中, 對不需要關注的成員來說, 就是垃圾訊息, 而且訊息格式固定死板,靈活性不強,具體操作可到 釘釘開放平臺 搜尋 機器人 檢視;

相關文章