制作钉钉企业内部机器人

2,263 阅读7分钟
企业机器人是钉钉为用户提供的组织内部使用的机器人,为组织数字化转型业务服务。开发者可通过本文所描述步骤进行机器人的自主开发和上架,组织内其它成员可通过方便快捷地在群内添加企业机器人,并使用机器人的能力。
基于企业机器人的outgoing(回调)机制,用户@机器人之后,钉钉会将消息内容POST到开发者的消息接收地址。开发者解析出消息内容、发送者身份,根据企业的业务逻辑,组装响应的消息内容返回,钉钉会将响应内容发送到群里。

一、创建机器人

1.进入机器人创建页

进入 open-dev.dingtalk.com/#/corprobot 如果没有开发者账号可以注册一个。

2.点击按钮"创建机器人"

创建机器人

3.添加相关信息

添加信息

4.创建完成后得到相关凭证

5.填写相关信息

消息接收地址可以通过内网穿透,写个代理地址进行方便本地调试

6.发布机器人

点击上线,则可以将机器人加入群聊

二、完成机器人回调代码

1.验证工具类

开发者需对header中的 timestamp和sign进行验证,以判断是否是来自钉钉的合法请求,避免其他仿冒钉钉调用开发者的HTTPS服务传送数据

import org.apache.tomcat.util.codec.binary.Base64;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

/**
 * 回调sign工具类
 * @author Carrot
 * @since 2020/9/14 0:29
 */
public class CallbackSignUtils {

    /**
     * 对header中的 timestamp和sign进行验证,以判断是否是来自钉钉的合法请求人的appSecret
     * @param sign 回调header的sign
     * @param timestamp 回调header的timestamp
     * @param appSecret 机器人的appSecret
     * @return 是否通过验证
     */
    public static boolean verifySign(String sign,Long timestamp,String appSecret){
        String stringToSign = timestamp + "\n" + appSecret;
        Mac mac = null;
        try {
            mac = Mac.getInstance("HmacSHA256");
        } catch (NoSuchAlgorithmException e) {
            return false;
        }
        String correctSign=null;
        try {
            mac.init(new SecretKeySpec(appSecret.getBytes("UTF-8"), "HmacSHA256"));
            byte[] signData = mac.doFinal(stringToSign.getBytes("UTF-8"));
            correctSign = new String(Base64.encodeBase64(signData));
        } catch (InvalidKeyException | UnsupportedEncodingException e) {
            return false;
        }
        return correctSign.equals(sign);
    }

}

2.构建钉钉回调中body模型

import java.util.List;

/**
 * 当用户@机器人时,钉钉会通过机器人开发者的HTTPS服务地址,把消息内容发送出去
 * 此为body
 * -------------------------------------------------------------
 * {
 *     "msgtype": "text",
 *     "text": {
 *         "content": "我就是我, 是不一样的烟火"
 *     },
 *     "msgId": "XXXX",
 *     "createAt": 1487561654123,
 *     "conversationType": "2",
 *     "conversationId": "XXXX",
 *     "conversationTitle": "钉钉群标题",
 *     "senderId": "XXXX",
 *     "senderNick": "星星",
 *     "senderCorpId": "XXXX",
 *     "senderStaffId": "XXXX",
 *     "chatbotUserId":"XXXX",
 *     "atUsers":[
 *        {
 *          "dingtalkId":"XXXX",
 *          "staffId":"XXXX"
 *        }
 *     ]
 * }
 * -------------------------------------------------------------
 * 参考 https://ding-doc.dingtalk.com/doc#/serverapi2/elzz1p
 * @author Carrot
 * @since 2020/9/13 23:40
 */
public class PostRobotRequest {

    /**
     * 消息文本
     */
    static class Text{

        /**
         * 消息文本
         */
        private String content;

        public String getContent() {
            return content;
        }

        public void setContent(String content) {
            this.content = content;
        }

        @Override
        public String toString() {
            return "Text{" +
                    "content='" + content + '\'' +
                    '}';
        }
    }

    /**
     * 被@人的信息
     */
    public static class AtUser{

        /**
         * 加密的发送者ID
         */
        private String dingtalkId;

        /**
         * 发送者在企业内的userid(企业内部群有)
         */
        private String staffId;

        public String getDingtalkId() {
            return dingtalkId;
        }

        public void setDingtalkId(String dingtalkId) {
            this.dingtalkId = dingtalkId;
        }

        public String getStaffId() {
            return staffId;
        }

        public void setStaffId(String staffId) {
            this.staffId = staffId;
        }

        @Override
        public String toString() {
            return "AtUser{" +
                    "dingtalkId='" + dingtalkId + '\'' +
                    ", staffId='" + staffId + '\'' +
                    '}';
        }
    }

    /**
     * 目前只支持text
     */
    private String msgtype;

    /**
     * 消息文本
     */
    private Text text;

    /**
     * 加密的消息ID
     */
    private String msgId;

    /**
     * 消息的时间戳,单位ms
     */
    private Long createAt;

    /**
     * 1-单聊、2-群聊
     */
    private String conversationType;

    /**
     * 加密的会话ID
     */
    private String conversationId;

    /**
     * 会话标题(群聊时才有)
     */
    private String conversationTitle;

    /**
     * 加密的发送者ID
     */
    private String senderId;

    /**
     * 发送者昵称
     */
    private String senderNick;

    /**
     * 发送者当前群的企业corpId(企业内部群有)
     */
    private String senderCorpId;

    /**
     * 发送者在企业内的userid(企业内部群有)
     */
    private String senderStaffId;

    /**
     * 加密的机器人ID
     */
    private String chatbotUserId;

    /**
     * 被@人的信息列表
     */
    private List<AtUser> atUsers;

    public String getMsgtype() {
        return msgtype;
    }

    public void setMsgtype(String msgtype) {
        this.msgtype = msgtype;
    }

    public Text getText() {
        return text;
    }

    public void setText(Text text) {
        this.text = text;
    }

    public String getMsgId() {
        return msgId;
    }

    public void setMsgId(String msgId) {
        this.msgId = msgId;
    }

    public Long getCreateAt() {
        return createAt;
    }

    public void setCreateAt(Long createAt) {
        this.createAt = createAt;
    }

    public String getConversationType() {
        return conversationType;
    }

    public void setConversationType(String conversationType) {
        this.conversationType = conversationType;
    }

    public String getConversationId() {
        return conversationId;
    }

    public void setConversationId(String conversationId) {
        this.conversationId = conversationId;
    }

    public String getConversationTitle() {
        return conversationTitle;
    }

    public void setConversationTitle(String conversationTitle) {
        this.conversationTitle = conversationTitle;
    }

    public String getSenderId() {
        return senderId;
    }

    public void setSenderId(String senderId) {
        this.senderId = senderId;
    }

    public String getSenderNick() {
        return senderNick;
    }

    public void setSenderNick(String senderNick) {
        this.senderNick = senderNick;
    }

    public String getSenderCorpId() {
        return senderCorpId;
    }

    public void setSenderCorpId(String senderCorpId) {
        this.senderCorpId = senderCorpId;
    }

    public String getSenderStaffId() {
        return senderStaffId;
    }

    public void setSenderStaffId(String senderStaffId) {
        this.senderStaffId = senderStaffId;
    }

    public String getChatbotUserId() {
        return chatbotUserId;
    }

    public void setChatbotUserId(String chatbotUserId) {
        this.chatbotUserId = chatbotUserId;
    }

    public List<AtUser> getAtUsers() {
        return atUsers;
    }

    public void setAtUsers(List<AtUser> atUsers) {
        this.atUsers = atUsers;
    }

    @Override
    public String toString() {
        return "PostRobotResponse{" +
                "msgtype='" + msgtype + '\'' +
                ", text=" + text.toString() +
                ", msgId='" + msgId + '\'' +
                ", createAt=" + createAt +
                ", conversationType='" + conversationType + '\'' +
                ", conversationId='" + conversationId + '\'' +
                ", conversationTitle='" + conversationTitle + '\'' +
                ", senderId='" + senderId + '\'' +
                ", senderNick='" + senderNick + '\'' +
                ", senderCorpId='" + senderCorpId + '\'' +
                ", senderStaffId='" + senderStaffId + '\'' +
                ", chatbotUserId='" + chatbotUserId + '\'' +
                ", atUsers=" + (atUsers==null?"null":atUsers.toString()) +
                '}';
    }
}

3.构建4种文本类型

目前支持4中文本类型可以参考ding-doc.dingtalk.com/doc#/server… 目前先展示text类型和Markdown这两种经常使用的类型。

1)通用接口

/**
 * 通用接口,需要实现提供自己文本类别的方法
 * @author Carrot
 * @since 2020/9/14 2:06
 */
public interface Content {

    /**
     * 获得内容类别
     *
     * @return
     */
    String getContentName();
}

2)text类型

import com.woshale.dingtalkrobottest.model.robot.content.interfaces.Content;

/**
 * text类型
 * @author Carrot
 * @since 2020/9/14 2:28
 */
public class Text implements Content {

    public static final String CONTENT_NAME="text";

    /**
     * 消息文本
     */
    private String content;

    @Override
    public String getContentName() {
        return CONTENT_NAME;
    }

    private Text() {
    }

    public Text(String content) {
        this.content = content;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

3)Markdown类型

import com.woshale.dingtalkrobottest.model.robot.content.interfaces.Content;

/**
 * Markdown格式
 * @author Carrot
 * @since 2020/9/14 0:52
 */
public class Markdown implements Content {

    public static final String CONTENT_NAME="markdown";

    private String title;

    private String text;

    private Markdown() {
    }

    public Markdown(String title, String text) {
        this.title = title;
        this.text = text;
    }

    @Override
    public String getContentName() {
        return CONTENT_NAME;
    }

    public String getTitle() {
        return title;
    }

    public String getText() {
        return text;
    }
}

4.构建对回调响应的model

import com.fasterxml.jackson.annotation.JsonInclude;
import com.woshale.dingtalkrobottest.model.robot.content.Markdown;
import com.woshale.dingtalkrobottest.model.robot.content.Text;
import com.woshale.dingtalkrobottest.model.robot.content.interfaces.Content;

import java.util.List;

/**
 * 机器人进行回应model
 * 数据格式
 * -------------------------------------------------------------
 * {
 *      "msgtype": "text",
 *      "text": {
 *          "content": "我就是我, @150XXXXXXXX 是不一样的烟火"
 *      },
 *      "at": {
 *          "atMobiles": [
 *              "150XXXXXXXX"
 *          ],
 *          "isAtAll": false
 *      }
 *  }
 * -------------------------------------------------------------
 * @author Carrot
 * @since 2020/9/14 0:44
 */
public class PostRobotResponse {

    /**
     * at用户目标
     */
    public static class AtTarget{

        /**
         * at用户的电话列表
         * 必须在这里声明被@用户的号码,才能在文本中通过“@156xxxxxxxx(手机号)”这样的方式去@他人
         * 一般来讲,最终的文本会自动将“@156xxxxxxxx”替换成“@这个人在这个群内的昵称”
         */
        private List<String> atMobiles;

        /**
         * 是否at所有人
         */
        private boolean isAtAll;

        public AtTarget() {
        }

        public AtTarget(List<String> atMobiles) {
            this.atMobiles = atMobiles;
            this.isAtAll = false;
        }

        public AtTarget(List<String> atMobiles, boolean isAtAll) {
            this.atMobiles = atMobiles;
            this.isAtAll = isAtAll;
        }


        public List<String> getAtMobiles() {
            return atMobiles;
        }


        public boolean isAtAll() {
            return isAtAll;
        }

        public void setAtMobiles(List<String> atMobiles) {
            this.atMobiles = atMobiles;
        }

        public void setAtAll(boolean atAll) {
            isAtAll = atAll;
        }
    }

    /**
     * 文本类型名称
     */
    private String msgtype;

    /**
     * 文本类型-text
     */
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private Content text;

    /**
     * 文本类型-Markdown
     */
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private Content markdown;

    /**
     * 文本类型-actionCard
     * todo 构造方法
     */
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private Content actionCard;

    /**
     * 文本类型-feedCardext
     * todo 构造方法
     */
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private Content feedCard;

    /**
     * at目标
     */
    private AtTarget at;


    private PostRobotResponse() {
    }


    /**
     * 构造Markdown类型
     * @param content
     * @param at
     */
    public PostRobotResponse(Markdown content, AtTarget at) {
        this.markdown=content;
        this.initTypeAndAtTarget(content,at);
    }

    /**
     * 构造text类型
     * @param content
     * @param at
     */
    public PostRobotResponse(Text content, AtTarget at) {
        this.markdown=content;
        this.initTypeAndAtTarget(content,at);
    }

    /**
     * 初始化一些构造信息
     * @param content
     * @param at
     */
    private void initTypeAndAtTarget(Content content,AtTarget at){
        this.msgtype=content.getContentName();
        this.at = at;
    }

    public String getMsgtype() {
        return msgtype;
    }

    public Content getText() {
        return text;
    }

    public Content getMarkdown() {
        return markdown;
    }

    public Content getActionCard() {
        return actionCard;
    }

    public Content getFeedCard() {
        return feedCard;
    }

    public AtTarget getAt() {
        return at;
    }
}

5.写一个回调demo接口

import com.woshale.dingtalkrobottest.model.robot.PostRobotRequest;
import com.woshale.dingtalkrobottest.model.robot.PostRobotResponse;
import com.woshale.dingtalkrobottest.model.robot.content.Markdown;
import com.woshale.dingtalkrobottest.utils.CallbackSignUtils;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;

/**
 * 钉钉机器人回调
 * 注意:
 * 开发者需对header中的 timestamp和sign进行验证,以判断是否是来自钉钉的合法请求,避免其他仿冒钉钉调用开发者的HTTPS服务传送数据,具体验证逻辑如下:
 * 1. timestamp 与系统当前时间戳如果相差1小时以上,则认为是非法的请求。
 *
 * 2. sign 与开发者自己计算的结果不一致,则认为是非法的请求。
 *
 * 必须当timestamp和sign同时验证通过,才能认为是来自钉钉的合法请求。

 * sign的计算方法:
 *
 * header中的timestamp + "\n" + 机器人的appSecret 当做签名字符串,使用HmacSHA256算法计算签名,然后进行Base64 encode,得到最终的签名值。
 * @author Carrot
 * @since 2020/9/13 23:15
 */
@RestController
@RequestMapping("/api/v1/callback")
public class CallbackController {

    @PostMapping("/robot")
    public PostRobotResponse robotCallback(HttpServletRequest req, @RequestBody PostRobotRequest body){
        System.out.println(body.toString());
        String reqSign=req.getHeader("sign");
        Long timestamp=Long.valueOf(req.getHeader("timestamp"));
        //todo 这里填写你的机器人的AppSecret
        if (!CallbackSignUtils.verifySign(reqSign,timestamp,"需要填写的appSecret")){
            System.out.println("sign验证错误");
            //todo 验证错误情况下业务处理
        }

        //验证成功后,进行响应 这里用markdown举例
        //获得了用户给机器人说的话
        String contentHasSent=body.getText().getContent();
        //todo 根据用户发的内容 构建title和text
        String title="根据业务得到的标题";
        String text="根据业务得到的文本";
        Markdown markdown=new Markdown(title,text);
        PostRobotResponse.AtTarget atTarget=new PostRobotResponse.AtTarget();
        List<String> atMobiles=new ArrayList<>();
        //todo 设置要@的人,可不填写
        atMobiles.add("15xxxxxxxx要@人的电话号码");
        atTarget.setAtMobiles(atMobiles);
        return new PostRobotResponse(markdown,atTarget);
    }

}

6.通过内网穿透或者部署到公网进行测试

1)填写服务地址以及服务所在白名单ip

2)测试机器人

可以正常使用了

三、后话

1.demo地址

github.com/rabbit-hulu…

2.机器人@人的改变

先前企业内部机器人是可以直接去@和机器人互动的人的,但是客服说实现变了,at参数不再能够填入发送者的加密userId了。导致我先前弄得机器人不能直接@人了,而且回调给的userId是加密的,我也不好通过服务器api去拿手机号,很麻烦,目前不知道怎么实现这种@互动人的方式了。

3.注意的地方

@人的话不仅要在at中声明电话号码,还要在文本中@电话号码,和以前直接在文本结尾自动@人不一样。