- 需求背景
在企业当中,经常会被多次问到相同的问题,而我们都有自己的其他需求需要做,于是为了提高办公效率,缩短沟通成本,我们可以在企业微信里面拉一个群,创建一个机器人,通过@企业机器人自助问答的方式获取想要的结果;
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.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 {
@Autowired
private 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";
@Override
public 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>
*/
@Override
public 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);
//消息类型,此时固定为text
reqBody.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 {
// 构造和配置OkHttpClient
OkHttpClient 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;
/**
* 签名工具类
*
*/
@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’)