打通Gitlab与钉钉之间的通讯

[TOC]

公司使用了Gitlab,Jira等工具来管理,沟通方面主要是钉钉,但郁闷的是各系统相互独立,而我已经习惯了前公司那种方式:

有bug的时候会自动发送消息到聊天框中,而不是目前这样,需要开发人员手动定时去刷新jira页面才能知道,效率低下;

gitlab也是一样,有merge请求的时候,我希望不需要别人提醒我去审核代码,而是gitlab直接发送merge消息到我钉钉即可;

可能其他同事习惯邮件通知吧,公司并无打通各系统与钉钉联系的计划,所以我只能自己撸一套了,我不是专职后端,轻喷,功能够我用就好;

已迁移到 掘金, 欢迎关注;

更新记录:

1. 2017.04.17 发现钉钉直接支持几个平台的webhook推送

  1. 打开钉钉群聊天框右上角的聊天机器人


    聊天机器人
  2. 选择其中需要的平台


    hook列表
  3. 添加完后再对应平台的设置中添加webhook地址即可;
    但感觉这个比较粗糙,以jira为例,消息过于精简,而且通知到群里的话,会让用户操心了本不需要操心的内容,个人觉得,这个比较适合gitlab的merge代码被通过时的通知,通知成员用户更新本地代码:


    jira通知示例

2. 2017.8.30 重构项目

使用gradle/kotlin/rxjava/retrofit等改造了之前的项目,支持快速新增gitlab项目部门,手动刷新accessToken及部门信息等功能,部分示例如下,具体请看 项目 :
P.S.重构后,war包大小由原来的30+M减小到5M左右 ==!

gitlab有新merge代码审核请求时会通知审核人

gitlab merge 请求被通过时,会通知相关项目部门所有成员更新代码

Github项目地址

相关文档

Gitlab webhook document
Jira webhook document
钉钉开放文档-服务器端

步骤

  1. gitlab 上启用 Webhooks 通知(可指定要 Webhooks 的操作,这里仅hook merge 操作,注意:需要项目管理权限才能设定, jira 也是类似)
    gitlab添加webhook
  2. 在服务端,根据post请求的head信息来区分不同系统发来的hook消息:
  3. gitlab的merge请求包含: X-Gitlab-Event:Merge Request Hook
  4. jira的hook请求包含: user-agent:Atlassian HttpClient0.17.3 / JIRA-6.3.15 (6346) / Default
  5. 在服务器端打开获取钉钉的人员信息,并调用其 企业会话消息接口 发送指定信息;
    由于该会话接口需要 员工id和企业应用id以及access_token,而 获取access_token 需要 CorpIdCorpSecret (二者是企业的唯一标识), 因此可知:
  6. 虽然公司的钉钉后台上有 CorpId 等信息,但不一定会开放,而等公司组织人员开发又可能遥遥无期,因此还是自己注册一下企业,创建部门并添加你想通知的人员作为部门员工即可,这样也能获取员工 通讯录详情 , 得到用userId,从而发送钉钉消息;
  7. 需要创建一个微应用,以该应用为会话发起人来发送消息;


    钉钉管理后台

建立钉钉微应用

  1. 钉钉开放平台 中搜索 微应用 就可以找到 Step 1 -- 注册钉钉企业链接;
  2. 根据上面的 step 引导操作注册企业并添加部门和员工,然后进入 钉钉管理后台;
  3. 企业应用 标签页左侧导航条中选择 微应用设置 即可在右侧看到 CorpIDCorpSecret;
  4. 企业应用 标签下 新建应用 即可;
  5. 完成后点击新建的微应用图标,选择 设置 接口查看到微应用的 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()
                                }
                            })
                }
    }
}

其他说明

  1. 钉钉消息有个 限制, 因此我在所有消息文本中添加服务器当前时间,尽量确保每条消息都不同:

forbiddenUserId: 因发送消息过于频繁或超量而被流控过滤后实际未发送的userid。未被限流的接收者仍会被成功发送。限流规则包括:1、给同一用户发相同内容消息一天仅允许一次;2、如果是ISV接入方式,给同一用户发消息一天不得超过50次;如果是企业接入方式,此上限为500。

  1. jira的hook信息若是存在 changelog 则表明有用户修改了issue的状态或者内容,另外, issuse.comment 一定存在, 数组 comments 存储了用户提交的所有备注信息,按时间先后顺序排列;
  2. accessToken的有效期为7200秒,因此项目中需要定时刷新token;
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,597评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,455评论 25 707
  • 第一杯酒,阳光明媚,窗外的青藤爬进了我的眼。 第二杯酒,春风轻漾,叶梢轻拂着我的眉。 第三杯酒,鸟儿鸣叫,轻啄着我...
    瑶幺儿阅读 1,451评论 0 0
  • 最近一个周,每天早上步行四十分钟到上班地点。最初的目的是减肥。走着走着发现生活状态都开始改变,我会惦记着好好吃早餐...
    龟苓膏味的菜菜阅读 156评论 0 0
  • 有这么一位偶像,每一次和她的相遇都教会了我许许多多。 即使在后台累到睡着,但是为了大家,为了她的粉丝们 不管再怎么...
    曾艳芬微光站阅读 358评论 0 1