场景介绍

企业内部有较多系统支撑着公司的核心业务流程,譬如CRM系统、交易系统、监控报警系统等。通过钉钉的自定义机器人,可以将这些系统事件同步到钉钉的聊天群。

说明
当前机器人尚不支持应答机制,该机制指的是群里成员在聊天@机器人的时候,钉钉回调指定的服务地址,即Outgoing机器人。

调用频率限制

由于消息发送太频繁会严重影响群的使用体验,因此自定义机器人发送消息的频率限制如下:

  • 每个机器人每分钟最多发送20条消息到群里,如果超过20条,会限流10分钟。

注意
如果你有大量发消息的场景(譬如系统监控报警)可以将这些信息进行整合,通过markdown消息以摘要的形式发送到群里。

具体步骤

步骤一:获取自定义机器人Webhook

  1. 选择需要添加机器人的群聊,然后依次单击群设置 > 智能群助手
  2. java 钉钉机器人通过加签推送 对接钉钉机器人_发送消息

  3. 在机器人管理页面选择自定义机器人,输入机器人名字并选择要发送消息的群,同时可以为机器人设置机器人头像。
  4. java 钉钉机器人通过加签推送 对接钉钉机器人_发送消息_02

  5. 完成必要的安全设置勾选我已阅读并同意《自定义机器人服务及免责条款》,然后单击完成
  6. java 钉钉机器人通过加签推送 对接钉钉机器人_自定义_03

以自定义关键词为例,最多可以设置10个关键词,消息中至少包含其中1个关键词才可以发送成功。
例如添加了一个自定义关键词:监控报警,则这个机器人所发送的消息,必须包含监控报警这个词,才能发送成功。

  1. 完成安全设置后,复制出机器人的Webhook地址,可用于向这个群发送消息,格式如下:
https://oapi.dingtalk.com/robot/send?access_token=XXXXXX

注意
请保管好此Webhook 地址,不要公布在外部网站上,泄露后有安全风险。

步骤二:使用自定义机器人

获取到Webhook地址后,用户可以向该地址发起HTTP POST 请求,即可实现给该钉钉群发送消息。

注意

  • 已默认开通使用自定义机器人发消息的权限,无需申请。即向Webhook地址发请求时,无需申请权限。
  • 发起POST请求时,必须将字符集编码设置成UTF-8。
  • 每个机器人每分钟最多发送20条。消息发送太频繁会严重影响群成员的使用体验,大量发消息的场景 (譬如系统监控报警) 可以将这些信息进行整合,通过markdown消息以摘要的形式发送到群里。

当前自定义机器人支持以下消息类型,请根据自己的使用场景选择合适的类型,详情参见消息类型及数据格式

  • 文本 (text)
  • 链接 (link)
  • markdown(markdown)
  • ActionCard
  • FeedCard

自定义机器人发送消息时,可以通过手机号码指定“被@人列表”。在“被@人列表”里面的人员收到该消息时,会有@消息提醒。免打扰会话仍然通知提醒,首屏出现“有人@你”。

步骤三:测试自定义机器人

通过以下方法,可以快速验证自定义机器人是否可以正常工作:

  1. 使用命令行工具curl。

说明
为避免出错,将以下命令逐行复制到命令行,需要将xxxxxxxx替换为真实access_token;若测试出错,请检查复制的命令是否和测试命令一致,多特殊字符会报错。

curl 'https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxx' \
 -H 'Content-Type: application/json' \
 -d '{"msgtype": "text","text": {"content":"我就是我, 是不一样的烟火"}}'
  1. SDK请求示例(Java)
DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/robot/send?access_token=566cc69da782ec******");
OapiRobotSendRequest request = new OapiRobotSendRequest();
request.setMsgtype("text");
OapiRobotSendRequest.Text text = new OapiRobotSendRequest.Text();
text.setContent("测试文本消息");
request.setText(text);
OapiRobotSendRequest.At at = new OapiRobotSendRequest.At();
at.setAtMobiles(Arrays.asList("132xxxxxxxx"));
// isAtAll类型如果不为Boolean,请升级至最新SDK
at.setIsAtAll(true);
at.setAtUserIds(Arrays.asList("109929","32099"));
request.setAt(at);

request.setMsgtype("link");
OapiRobotSendRequest.Link link = new OapiRobotSendRequest.Link();
link.setMessageUrl("https://www.dingtalk.com/");
link.setPicUrl("");
link.setTitle("时代的火车向前开");
link.setText("这个即将发布的新版本,创始人xx称它为红树林。而在此之前,每当面临重大升级,产品经理们都会取一个应景的代号,这一次,为什么是红树林");
request.setLink(link);

request.setMsgtype("markdown");
OapiRobotSendRequest.Markdown markdown = new OapiRobotSendRequest.Markdown();
markdown.setTitle("杭州天气");
markdown.setText("#### 杭州天气 @156xxxx8827\n" +
        "> 9度,西北风1级,空气良89,相对温度73%\n\n" +
        "> ![screenshot](https://gw.alicdn.com/tfs/TB1ut3xxbsrBKNjSZFpXXcXhFXa-846-786.png)\n"  +
        "> ###### 10点20分发布 [天气](http://www.thinkpage.cn/) \n");
request.setMarkdown(markdown);
OapiRobotSendResponse response = client.execute(request);

消息类型及数据格式

  • text类型
{
    "at": {
        "atMobiles":[
            "180xxxxxx"
        ],
        "atUserIds":[
            "user123"
        ],
        "isAtAll": false
    },
    "text": {
        "content":"我就是我, @XXX 是不一样的烟火"
    },
    "msgtype":"text"
}

java 钉钉机器人通过加签推送 对接钉钉机器人_自定义_04


java 钉钉机器人通过加签推送 对接钉钉机器人_spring_05

  • link类型
{
    "msgtype": "link", 
    "link": {
        "text": "这个即将发布的新版本,创始人xx称它为红树林。而在此之前,每当面临重大升级,产品经理们都会取一个应景的代号,这一次,为什么是红树林", 
        "title": "时代的火车向前开", 
        "picUrl": "", 
        "messageUrl": "https://www.dingtalk.com/s?__biz=MzA4NjMwMTA2Ng==&mid=2650316842&idx=1&sn=60da3ea2b29f1dcc43a7c8e4a7c97a16&scene=2&srcid=09189AnRJEdIiWVaKltFzNTw&from=timeline&isappinstalled=0&key=&ascene=2&uin=&devicetype=android-23&version=26031933&nettype=WIFI"
    }
}

java 钉钉机器人通过加签推送 对接钉钉机器人_spring_06


java 钉钉机器人通过加签推送 对接钉钉机器人_java 钉钉机器人通过加签推送_07

  • markdown类型
{
     "msgtype": "markdown",
     "markdown": {
         "title":"杭州天气",
         "text": "#### 杭州天气 @150XXXXXXXX \n > 9度,西北风1级,空气良89,相对温度73%\n > ![screenshot](https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png)\n > ###### 10点20分发布 [天气](https://www.dingtalk.com) \n"
     },
      "at": {
          "atMobiles": [
              "150XXXXXXXX"
          ],
          "atUserIds": [
              "user123"
          ],
          "isAtAll": false
      }
 }

java 钉钉机器人通过加签推送 对接钉钉机器人_自定义_08


java 钉钉机器人通过加签推送 对接钉钉机器人_java 钉钉机器人通过加签推送_09

目前只支持markdown语法的子集,具体支持的元素如下:

标题
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题

引用
> A man who stands for nothing will fall for anything.

文字加粗、斜体
**bold**
*italic*

链接
[this is a link](http://name.com)

图片(建议不要超过20张)
![](http://name.com/pic.jpg)

无序列表
- item1
- item2

有序列表
1. item1
2. item2
  • 整体跳转ActionCard类型
{
    "actionCard": {
        "title": "乔布斯 20 年前想打造一间苹果咖啡厅,而它正是 Apple Store 的前身", 
        "text": "![screenshot](https://gw.alicdn.com/tfs/TB1ut3xxbsrBKNjSZFpXXcXhFXa-846-786.png) 
 ### 乔布斯 20 年前想打造的苹果咖啡厅 
 Apple Store 的设计正从原来满满的科技感走向生活化,而其生活化的走向其实可以追溯到 20 年前苹果一个建立咖啡馆的计划", 
        "btnOrientation": "0", 
        "singleTitle" : "阅读全文",
        "singleURL" : "https://www.dingtalk.com/"
    }, 
    "msgtype": "actionCard"
}

java 钉钉机器人通过加签推送 对接钉钉机器人_发送消息_10

通过整体跳转ActionCard类型消息发出的消息样式如下:

java 钉钉机器人通过加签推送 对接钉钉机器人_spring_11

  • 独立跳转ActionCard类型
{
    "msgtype": "actionCard",
    "actionCard": {
        "title": "我 20 年前想打造一间苹果咖啡厅,而它正是 Apple Store 的前身", 
        "text": "![screenshot](https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png) \n\n #### 乔布斯 20 年前想打造的苹果咖啡厅 \n\n Apple Store 的设计正从原来满满的科技感走向生活化,而其生活化的走向其实可以追溯到 20 年前苹果一个建立咖啡馆的计划", 
        "btnOrientation": "0", 
        "btns": [
            {
                "title": "内容不错", 
                "actionURL": "https://www.dingtalk.com/"
            }, 
            {
                "title": "不感兴趣", 
                "actionURL": "https://www.dingtalk.com/"
            }
        ]
    }
}

java 钉钉机器人通过加签推送 对接钉钉机器人_spring_12


通过独立跳转ActionCard类型消息发出的消息样式如下:

java 钉钉机器人通过加签推送 对接钉钉机器人_java 钉钉机器人通过加签推送_13

  • FeedCard类型
{
    "msgtype":"feedCard",
    "feedCard": {
        "links": [
            {
                "title": "时代的火车向前开1", 
                "messageURL": "https://www.dingtalk.com/", 
                "picURL": "https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png"
            },
            {
                "title": "时代的火车向前开2", 
                "messageURL": "https://www.dingtalk.com/", 
                "picURL": "https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png"
            }
        ]
    }
}

java 钉钉机器人通过加签推送 对接钉钉机器人_发送消息_14

通过FeedCard类型消息发出的消息样式如下:

java 钉钉机器人通过加签推送 对接钉钉机器人_自定义_15

错误码

SDK请求示例错误码

java 钉钉机器人通过加签推送 对接钉钉机器人_自定义_16

安全设置错误码

当出现以下错误时,表示消息校验未通过,请查看机器人的安全设置。

java 钉钉机器人通过加签推送 对接钉钉机器人_java 钉钉机器人通过加签推送_17

自定义请求代码

点击查看DingTalkAlertService

package com.demo.alert.service;

import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Maps;
import com.demo.alert.entity.AlertMsgLog;
import com.demo.alert.repository.AlertMsgLogRepository;
import com.demo.alert.valobj.AlertWay;
import com.demo.configuration.domain.configration.entity.Configuration;
import com.demo.configuration.domain.configration.enums.AlertConfigEnum;
import com.demo.configuration.domain.configration.service.ConfigurationService;
import com.demo.notice.application.exception.NoticeBusinessException;
import com.demo.notice.domain.noticetemplate.entity.NoticeTemplate;
import com.demo.notice.domain.noticetemplate.entity.NoticeTemplateType;
import com.demo.notice.domain.noticetemplate.entity.SendStatus;
import com.demo.notice.domain.noticetemplate.repository.NoticeTemplateRepository;
import org.apache.commons.lang3.text.StrSubstitutor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;

/**
 * @Description: 钉钉群机器人推送告警服务类
 * @Author: 
 * @Date: 2022/3/21 11:16
 */
@Service
public class DingTalkAlertService {

    @Autowired
    private RestTemplate restTemplate;
    @Autowired
    private NoticeTemplateRepository noticeTemplateRepository;
    @Autowired
    private ConfigurationService configurationService;
    @Autowired
    private AlertMsgLogRepository alertMsgLogRepository;

    /**
     * 向目标群钉钉机器人推送告警信息(不含安全码)<br/>
     * <i>注:通过总服务AlertService扩展、调用,否则无法设置安全key及告警序列号</i>
     * @param alertMsg 告警消息
     * @param isAtAll 是否艾特全员
     * @param serialNo 告警序列号
     */
    protected void sendAlertToAll(String alertMsg, Boolean isAtAll, String serialNo) {
        // 组装告警消息与模板
        alertMsg = this.assembleAlertMsg(alertMsg);
        // 构造报文头
        HttpHeaders headers = this.constructHttpHeaders();
        // 组装钉钉请求报文体
        String requestBody = this.assembleDingTalkRequestBody(alertMsg, isAtAll);
        // 拼接报文头报文体
        HttpEntity<String> formEntity = new HttpEntity<String>(requestBody, headers);
        // 推送消息
        this.executeSendAlert(formEntity, serialNo);
    }

    /**
     * 向目标群钉钉机器人推送告警信息(含安全码)<br/>
     * <i>注:通过总服务AlertService扩展、调用,否则无法设置安全key及告警序列号</i>
     * @param alertMsg 告警消息
     * @param safeCode 安全码
     * @param isAtAll 是否艾特全员
     * @param serialNo 告警序列号
     */
    protected void sendAlertToAllWithSafeCode(String alertMsg, String safeCode, Boolean isAtAll, String serialNo) {
        // 组装告警消息与模板
        alertMsg = this.assembleAlertMsgWithSafeCode(alertMsg, safeCode);
        // 构造报文头
        HttpHeaders headers = this.constructHttpHeaders();
        // 组装钉钉请求报文体
        String requestBody = this.assembleDingTalkRequestBody(alertMsg, isAtAll);
        // 拼接报文头与报文体内容
        HttpEntity<String> formEntity =  new HttpEntity<String>(requestBody, headers);
        // 推送消息
        this.executeSendAlert(formEntity, serialNo);
    }

    /**
     * 组装告警消息与模板(不含安全码)
     * @param alertMsg 告警消息
     * @return
     */
    private String assembleAlertMsg(String alertMsg) {
        NoticeTemplate noticeTemplate = noticeTemplateRepository.findByNoticeTemplateType(NoticeTemplateType.PLATFORM_ALERT_MSG);
        if (noticeTemplate == null) {
            throw new NoticeBusinessException(NoticeBusinessException.CodeOption.NOTICE_TEMPLATE_NOT_FOUND);
        }
        // 告警信息
        final HashMap<String, String> argMaps = Maps.newHashMap();
        argMaps.put("msg", alertMsg);
        StrSubstitutor strSubstitutor = new StrSubstitutor(argMaps);
        return strSubstitutor.replace(noticeTemplate.getContent());
    }

    /**
     * 组装告警消息与模板(含安全码)<br/>
     * <i>注:通过总服务AlertService扩展、调用,否则无法设置安全key</i>
     * @param alertMsg 告警消息
     * @param safeCode 安全码
     * @return
     */
    private String assembleAlertMsgWithSafeCode(String alertMsg, String safeCode) {
        NoticeTemplate noticeTemplate = noticeTemplateRepository.findByNoticeTemplateType(NoticeTemplateType.PLATFORM_ALERT_MSG_WITH_SAFE_CODE);
        if (noticeTemplate == null) {
            throw new NoticeBusinessException(NoticeBusinessException.CodeOption.NOTICE_TEMPLATE_NOT_FOUND);
        }
        // 告警信息
        final HashMap<String, String> argMaps = Maps.newHashMap();
        argMaps.put("msg", alertMsg);
        argMaps.put("code", safeCode);
        StrSubstitutor strSubstitutor = new StrSubstitutor(argMaps);
        return strSubstitutor.replace(noticeTemplate.getContent());
    }

    /**
     * 统一构造Http报文头
     * @return
     */
    private HttpHeaders constructHttpHeaders() {
        // 报文头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
        return headers;
    }

    /**
     * 组装钉钉请求报文体<br/>
     * <i>{"msgtype": "text", "text":{"content": "消息内容"}, "at": {"atMobiles": ["18100000000", "18100000001"], "isAtAll": true}}</i>
     * @param alertMsg 告警消息
     * @param isAtAll 是否艾特全员
     * @return
     */
    private String assembleDingTalkRequestBody(String alertMsg, Boolean isAtAll) {
        // 组装钉钉请求报文体,{"msgtype": "text", "text":{"content": "消息内容"}, "at": {"atMobiles": ["18100000000", "18100000001"], "isAtAll": true}}
        Map<String, Object> requestBody = new HashMap<>();
        Map<String, Object> content = new HashMap<>();
        content.put("content", alertMsg);
        Map<String, Object> at = new HashMap<>();
        at.put("isAtAll", isAtAll);
        requestBody.put("msgtype", "text");
        requestBody.put("text", content);
        requestBody.put("at", at);
        return JSONObject.toJSONString(requestBody);
    }

    /**
     * 执行发送告警消息
     * @param formEntity Post请求实体
     * @param serialNo 告警序列号
     */
    private void executeSendAlert(HttpEntity<String> formEntity, String serialNo) {
        // 告警日志
        AlertMsgLog alertMsgLog = null;
        // 消息推送的目标机器人url
        String url = null;
        // 初始化发送状态为失败
        SendStatus sendStatus = SendStatus.FAIL;
        // 错误信息
        String errorMsg = null;
        try {
            // 获取对应环境的钉钉消息推送的目标机器人url
            Configuration config = configurationService.getConfig(AlertConfigEnum.DINGTALK_ALERT_ROBOT_URL.getKey());
            url = config.getConfValue();
            // 发起POST请求
            ResponseEntity<Map> response = restTemplate.postForEntity(url, formEntity, Map.class);
            // 钉钉请求返回业务代码&错误信息,成功推送code为0,msg为ok
            Integer dingTalkSuccessCode = 0;
            Integer errCode = (Integer) response.getBody().get("errcode");
            String errMsg = (String) response.getBody().get("errmsg");
            // 完整响应体信息
            String resBodyStr = JSONObject.toJSONString(response.getBody());
            // HTTP请求返回码成功,且钉钉业务代码为0,即为推送成功
            HttpStatus httpStatus = response.getStatusCode();
            if (HttpStatus.OK.equals(httpStatus) && dingTalkSuccessCode.equals(errCode)) {
                sendStatus = SendStatus.SUCCESS;
            } else {
                errorMsg = "HTTP响应码:".concat(String.valueOf(httpStatus.value())).concat(",响应信息:").concat(resBodyStr);
            }
            alertMsgLog = new AlertMsgLog(url, formEntity.toString(), AlertWay.DING_TALK, sendStatus, errorMsg, serialNo);
        } catch (Exception e) {
            // 生成告警记录,批量发送时不抛出异常,避免中断其他人员的发送
            errorMsg = "HTTP请求异常:".concat(e.getMessage());
            alertMsgLog = new AlertMsgLog(url, formEntity.toString(), AlertWay.DING_TALK, sendStatus, errorMsg, serialNo);
        } finally {
            alertMsgLogRepository.save(alertMsgLog);
        }
    }
}

点击查看AlertMsgLog

package com.demo.alert.entity;

import com.demo.alert.valobj.AlertWay;
import com.demo.common.model.BaseEntity;
import com.demo.notice.domain.noticetemplate.entity.SendStatus;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;

import javax.persistence.*;

/**
 * @Description:
 * @Author: 
 * @Date: 2022/3/18 16:07
 */
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "alert_msg_log")
public class AlertMsgLog extends BaseEntity {

    /**
     * 主键ID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 接收者手机/邮箱,钉钉群为机器人Url
     */
    private String receiver;

    /**
     * 告警内容
     */
    @Lob
    @Type(type = "org.hibernate.type.TextType")
    private String alertMsg;

    /**
     * 告警方式
     */
    @Enumerated(EnumType.STRING)
    private AlertWay alertWay;

    /**
     * 告警信息发送状态
     */
    @Enumerated(EnumType.STRING)
    private SendStatus sendStatus;

    /**
     * 发送失败时的异常信息
     */
    @Lob
    @Type(type = "org.hibernate.type.TextType")
    private String errorMsg;

    /**
     * 告警序列号(同一批次告警序列号相同)
     */
    private String serialNo;

    /**
     * 告警日志
     * @param receiver 接收者
     * @param alertMsg 告警信息
     * @param sendStatus 发送状态
     * @param alertWay 告警方式
     * @param errorMsg 错误信息
     */
    public AlertMsgLog(String receiver, String alertMsg, AlertWay alertWay, SendStatus sendStatus, String errorMsg, String serialNo) {
        this.receiver = receiver;
        this.alertMsg = alertMsg;
        this.alertWay = alertWay;
        this.sendStatus = sendStatus;
        this.errorMsg = errorMsg;
        this.serialNo = serialNo;
    }
}