文章目录
- 1. 需求背景
- 2. 相关文档整理
- 3. 接入流程演示
- 3.1 拉群,添加机器人
- 3.2 给机器人取个名字
- 3.3 点击配置说明
- 3.4 配置 接收消息配置 信息
- 4. 代码演示
1. 需求背景
在企业当中,经常会被多次问到相同的问题,而我们都有自己的其他需求需要做,于是为了提高办公效率,缩短沟通成本,我们可以在企业微信里面拉一个群,创建一个机器人,通过@企业机器人自助问答的方式获取想要的结果;
2. 相关文档整理
- 添加机器人
https://developer.work.weixin.qq.com/document/path/91770 - 如何接受群里面艾特机器人后输入的聊天内容https://developer.work.weixin.qq.com/document/path/90930
- 如何发送信息到群群
https://developer.work.weixin.qq.com/document/path/91770 - 加解密相关
https://developer.work.weixin.qq.com/document/path/90968
https://developer.work.weixin.qq.com/document/path/90468
获取用户相关(企微机器人只能通过聊天信息拿到userid,如果需要通过userid获取用户信息,需要申请企微应用然后调用企微通讯录相关api来获取,企微应用可以在流程中心进行企微应用申请,然后联系it的同学获取更多信息)
-
申请企业微信应用以及应用的一些基本概念
https://developer.work.weixin.qq.com/document/path/90665 -
获取用户信息(注意因为保密原因企微通讯录拿不到用户email,你还需要和sso团队去拿到相关api来进行用户信息获取)
https://developer.work.weixin.qq.com/document/path/90196 -
提示
- 接受企业微信的聊天内容需要一个公网可达的接收点
- 加解密中有一个corpid参数是企业微信中识别企业的id,这个可以联系it的同学获取
- 企业微信文档那是非常差,需要多阅读和动手尝试,有问题可以在企业微信的社区提交问题咨询
- 多阅读企业微信的api文档
3. 接入流程演示
3.1 拉群,添加机器人
3.2 给机器人取个名字
3.3 点击配置说明
3.4 配置 接收消息配置 信息
- 第一栏URL 必须是外网可以访问的,且该接口已经在线上可以直接访问,所以配置该信息之前,你的线上环境已经有相关接口了
- 第二栏的 Token 随机生成即可,后续需要配置在代码里面
- 第三栏 和 第二栏一样,随机生成即可,后续需要配置在代码里面
讲一下流程:如上图所示,当点击保存的时候,机器人后台会去check一下输入的url,这个url会承担两部分功能,第一个是check一下url是否可用,第二个是当有人在群里@机器人时,会回调该接口,因此下面会出现两个相同url接口但是不同的接收参数形式:
4. 代码演示
Controller层:
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;import static org.springframework.util.MimeTypeUtils.APPLICATION_XML_VALUE;@RestController
@RequestMapping(value = "/chat")
@Slf4j
public class ChatRobotController {@Autowiredprivate ChatRobotService chatRobotService;/*** 支持Http Get请求验证URL有效性** @param msgSignature 企业微信加密签名,msg_signature计算结合了企业填写的token、请求中的timestamp、nonce、加密的消息体* @param timestamp 时间戳。与nonce结合使用,用于防止请求重放攻击* @param nonce 随机数。与timestamp结合使用,用于防止请求重放攻击* @param echoStr 加密的字符串。需要解密得到消息内容明文,解密后有random、msg_len、msg、receiveid四个字段,其中msg即为消息内容明文* @return 回调服务需要作出正确的响应*/@GetMapping(value = "/notify")public String verifyUrl(@RequestParam("msg_signature") String msgSignature,@RequestParam("timestamp") Integer timestamp,@RequestParam("nonce") String nonce,@RequestParam("echostr") String echoStr) {log.info("verifyUrl--> msgSignature:{},timestamp:{},nonce:{},echoStr:{}", msgSignature, timestamp, nonce, echoStr);return chatRobotService.verifyUrl(msgSignature, timestamp, nonce, echoStr);}/*** 支持Http Post请求接收业务数据* 当用户触发回调行为时,企业微信会发送回调消息到填写的URL,请求内容** @param msgSignature 企业微信加密签名,msg_signature结合了企业填写的token、请求中的timestamp、nonce参数、加密的消息体* @param timestamp 时间戳。与nonce结合使用,用于防止请求重放攻击* @param nonce 随机数。与timestamp结合使用,用于防止请求重放攻击。* @param dto 报文数据*/@PostMapping(value = "/notify", consumes = APPLICATION_XML_VALUE)public void callBack(@RequestParam("msg_signature") String msgSignature,@RequestParam("timestamp") Integer timestamp,@RequestParam("nonce") String nonce,@RequestBody ChatRobotCallBackDTO dto) {log.info("callBack-->msgSignature:{},timestamp:{},nonce:{},dto:{}", msgSignature, timestamp, nonce, JsonUtil.toJSON(dto));chatRobotService.callBack(msgSignature, timestamp, nonce, dto);}
}
Service层:
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import static cn.hutool.core.util.XmlUtil.readXML;/*** 企微机器人** @author wql* @date 2023/4/23 10:19*/
@Service
@Slf4j
public class ChatRobotServiceImpl implements ChatRobotService {private final static String TOKEN = "iLOUyBRIN6CWScZtPqHF15";@Overridepublic String verifyUrl(String msgSignature, Integer timestamp, String nonce, String echoStr) {String signature = getSignature(TOKEN, String.valueOf(timestamp), nonce, echoStr);if (!Objects.equals(signature, msgSignature)) {throw new VerifyException(VerifyException.GET_SIGNATURE_ERROR);}return getVerifyUrlContent(getOriginByte(echoStr));}/*** <xml>* <From>* <UserId>* <![CDATA[wo-ApVEAAANKrX-iuxI9zy34Enju8YeQ]]>* </UserId>* </From>* <WebhookUrl>* <![CDATA[https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=6132293c-dfcd-4b0e-a79c-b0009362e51e]]>* </WebhookUrl>* <ChatId>* <![CDATA[wr-ApVEAAAXa_Vwj9LjYp18C1jB_SPbA]]>* </ChatId>* <MsgId>* <![CDATA[CIGABBDxnJOiBhjl1aHskICAAyCQAQ==]]>* </MsgId>* <ChatType>* <![CDATA[group]]>* </ChatType>* <MsgType>* <![CDATA[text]]>* </MsgType>* <Text>* <Content>* <![CDATA[@TestRobot 1234567]]>* </Content>* </Text>* </xml>*/@Overridepublic void callBack(String msgSignature, Integer timestamp, String nonce, ChatRobotCallBackDTO dto) {//content 报文如上所示String content = this.verifyUrl(msgSignature, timestamp, nonce, dto.getEncrypt());if (StrUtil.isNotBlank(content)) {String text = readXML(content).getElementsByTagName("Text").item(0).getFirstChild().getTextContent();if (StrUtil.isNotBlank(text)) {List<String> textItems = Arrays.asList(text.split(" "));if (CollUtil.isNotEmpty(textItems)) {//0 是机器人, 1是输入的文本String queryText = textItems.get(1);//TODO 查询数据源并回答List<String> resultList = Lists.newArrayList();resultList.add("我叫李四");resultList.add("[这是一个链接](http://work.weixin.qq.com/api/doc)");WeChatBotUtils.sendMarkDown(queryText, resultList);}}}}public static void main(String[] args) {List<String> resultList = Lists.newArrayList();resultList.add("我叫李四");resultList.add("[这是一个链接](http://work.weixin.qq.com/api/doc)");WeChatBotUtils.sendMarkDown("我是张三", resultList);}
}
工具类:
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.jayway.jsonpath.JsonPath;
import com.jayway.jsonpath.PathNotFoundException;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.apache.commons.codec.digest.DigestUtils;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.TimeUnit;@Slf4j
public class WeChatBotUtils {private final static String ROBOT_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=6132293c-dfcd-4b0e" +"-a79c-b00e";/*** 发送文字消息* <p>* {* "msgtype": "text",* "text": {* "content": "广州今日天气:29度,大部分多云,降雨概率:60%",* "mentioned_list":["wangng","@all"],* "mentioned_mobile_list":["13800001111","@all"]* }* }** @param msg 需要发送的消息*/public static String sendTextMsg(String msg) {JSONObject text = new JSONObject();text.put("content", msg);
// ArrayList<String> users = Lists.newArrayList();
// users.add("@all");
// text.put("mentioned_list", users);JSONObject reqBody = new JSONObject();//本内容,最长不超过2048个字节,必须是utf8编码reqBody.put("text", text);//消息类型,此时固定为textreqBody.put("msgtype", "text");reqBody.put("safe", 0);return callWeChatBot(reqBody.toString());}/*** 发送图片消息,需要对图片进行base64编码并计算图片的md5值** @param path 需要发送的图片路径*/public static String sendImgMsg(String path) throws Exception {String base64 = "";String md5 = "";// 获取Base64编码try {FileInputStream inputStream = new FileInputStream(path);byte[] bs = new byte[inputStream.available()];inputStream.read(bs);base64 = Base64.getEncoder().encodeToString(bs);} catch (IOException e) {e.printStackTrace();}// 获取md5值try {FileInputStream inputStream = new FileInputStream(path);byte[] buf = new byte[inputStream.available()];inputStream.read(buf);md5 = DigestUtils.md5Hex(buf);} catch (IOException e) {e.printStackTrace();}JSONObject image = new JSONObject();image.put("base64", base64);image.put("md5", md5);JSONObject reqBody = new JSONObject();reqBody.put("msgtype", "image");reqBody.put("image", image);reqBody.put("safe", 0);return callWeChatBot(reqBody.toString());}/*** 发送MarKDown消息** @param question 需要发送的消息*/public static String sendMarkDown(String question, List<String> resultList) {JSONObject markdown = new JSONObject();String res = "您输入的关键字:\t<font color =\"warning\">" + question + "</font>\t机器人从文档库匹配出如下文档";if (CollUtil.isEmpty(resultList)) {res = res + "\n\t无法通过输入的关键词匹配到相关文档";}else {for (int i = 1; i <= resultList.size(); i++) {res = res + "\n\t" + i + ".\t" + resultList.get(i - 1);}}markdown.put("content", res);JSONObject reqBody = new JSONObject();reqBody.put("msgtype", "markdown");reqBody.put("markdown", markdown);reqBody.put("safe", 0);return callWeChatBot(reqBody.toString());}/*** 调用群机器人*/public static String callWeChatBot(String reqBody) {try {// 构造RequestBody对象,用来携带要提交的数据;需要指定MediaType,用于描述请求/响应 body 的内容类型MediaType contentType = MediaType.parse("application/json; charset=utf-8");RequestBody body = RequestBody.create(contentType, reqBody);// 调用群机器人String respMsg = okHttp(body, ROBOT_URL);if (StrUtil.isNotBlank(respMsg)) {int errCode = (Integer) getParamsFromJson(respMsg, "errcode");if (errCode == 0) {log.info("向群发送消息成功!");} else {log.info("向群发送消息失败!,错误信息为:{}", JSON.toJSONString(respMsg));}}return respMsg;} catch (Exception e) {log.error("群机器人推送消息失败:{}", ErrorUtil.getErrorStackTraceMsg(e));}return null;}private static Object getParamsFromJson(String json, String sourceKeyName) {Object object = null;try {object = JsonPath.read(json, "$." + sourceKeyName);} catch (PathNotFoundException e) {log.error(ErrorUtil.getErrorStackTraceMsg(e));}return object;}public static String okHttp(RequestBody body, String url) throws Exception {// 构造和配置OkHttpClientOkHttpClient client;client = new OkHttpClient.Builder()// 设置连接超时时间.connectTimeout(10, TimeUnit.SECONDS)// 设置读取超时时间.readTimeout(20, TimeUnit.SECONDS).build();// 构造Request对象Request request = new Request.Builder().url(url).post(body)// 响应消息不缓存.addHeader("cache-control", "no-cache").build();// 构建Call对象,通过Call对象的execute()方法提交异步请求Response response = null;try {response = client.newCall(request).execute();} catch (IOException e) {e.printStackTrace();}// 请求结果处理assert response != null;assert response.body() != null;byte[] datas = response.body().bytes();return new String(datas);}}
ChatRobotUtil
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;/*** 签名工具类** @author wql* @date 2023/4/23 10:30*/
@Slf4j
public class ChatRobotUtil {private final static Charset CHARSET = StandardCharsets.UTF_8;/*** 用SHA1算法生成安全签名** @param token 票据* @param timestamp 时间戳* @param nonce 随机字符串* @param encrypt 密文* @return 安全签名*/public static String getSignature(String token, String timestamp, String nonce, String encrypt) {try {String[] array = new String[]{token, timestamp, nonce, encrypt};StringBuilder sb = new StringBuilder();// 字符串排序Arrays.sort(array);for (int i = 0; i < 4; i++) {sb.append(array[i]);}String str = sb.toString();// SHA1签名生成MessageDigest md = MessageDigest.getInstance("SHA-1");md.update(str.getBytes());byte[] digest = md.digest();StringBuilder hexstr = new StringBuilder();String shaHex = "";for (byte b : digest) {shaHex = Integer.toHexString(b & 0xFF);if (shaHex.length() < 2) {hexstr.append(0);}hexstr.append(shaHex);}return hexstr.toString();} catch (Exception e) {log.error(ErrorUtil.getErrorStackTraceMsg(e));throw new VerifyException(VerifyException.GET_SIGNATURE_ERROR);}}public static byte[] getOriginByte(String echoStr) {String encodingAesKey = ApolloUtil.getKey("robot.encodingAesKey", "4pGyYEdb0qfvLN1yyNhNvLzDcLaSLMlBCGo9Q5ixW8w");byte[] aesKey = Base64.decodeBase64(encodingAesKey + "=");byte[] original;try {// 设置解密模式为AES的CBC模式Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);// 使用BASE64对密文进行解码byte[] encrypted = Base64.decodeBase64(echoStr);// 解密original = cipher.doFinal(encrypted);} catch (Exception e) {log.error(ErrorUtil.getErrorStackTraceMsg(e));throw new VerifyException(VerifyException.PARSER_ECHO_STR_ERROR);}return original;}public static String getVerifyUrlContent(byte[] original) {String xmlContent;try {byte[] bytes = decode(original);byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);int xmlLength = recoverNetworkBytesOrder(networkOrder);xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);} catch (Exception e) {log.error(ErrorUtil.getErrorStackTraceMsg(e));throw new VerifyException(VerifyException.XML_CONTENT_ERROR);}return xmlContent;}private static int recoverNetworkBytesOrder(byte[] orderBytes) {int sourceNumber = 0;for (int i = 0; i < 4; i++) {sourceNumber <<= 8;sourceNumber |= orderBytes[i] & 0xff;}return sourceNumber;}private static byte[] decode(byte[] decrypted) {int pad = (int) decrypted[decrypted.length - 1];if (pad < 1 || pad > 32) {pad = 0;}return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);}
}
errorUtil:
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;public class ErrorUtil {public ErrorUtil() {}public static String getErrorStackTraceMsg(Throwable error) {ByteArrayOutputStream baos = new ByteArrayOutputStream();error.printStackTrace(new PrintStream(baos));return baos.toString();}
}
DTO:
import lombok.Data;import java.io.Serializable;import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;/*** 当用户触发回调行为时,企业微信会发送回调消息到填写的URL,请求内容** @author wangqinglong01*/
@Data
public class ChatRobotCallBackDTO implements Serializable {private static final long serialVersionUID = -2513785018658444032L;/*** 企业微信的CorpID,当为第三方应用回调事件时,CorpID的内容为suiteid*/@JacksonXmlProperty(localName = "ToUserName")private String toUserName;/*** 接收的应用id,可在应用的设置页面获取。仅应用相关的回调会带该字段。*/@JacksonXmlProperty(localName = "AgentID")private String agentId;/*** 消息结构体加密后的字符串*/@JacksonXmlProperty(localName = "Encrypt")private String encrypt;}
需要引入的依赖
compile('com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.9.8')