Skip to content

Commit ade05b7

Browse files
authored
🎨 #4100 【企业微信】支持智能机器人 API 模式
1 parent dceeaf3 commit ade05b7

9 files changed

Lines changed: 294 additions & 31 deletions

File tree

‎weixin-java-cp/INTELLIGENT_ROBOT.md‎

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# 企业微信智能机器人接口
22

3-
本模块提供企业微信智能机器人相关的API接口实现。
3+
本模块提供企业微信智能机器人相关的 API 接口实现。
4+
5+
> `createRobot`、`chat`、`sendMessage` 等既有方法走企业应用 `access_token` 接口,
6+
> 需要在 `WxCpConfigStorage` 中配置应用 `agentId` 和 `secret`。它们不适用于机器人后台创建的新版 API 模式。
47
58
## 官方文档
69

@@ -73,7 +76,7 @@ String sessionId = "session123";
7376
robotService.resetSession(robotId, userid, sessionId);
7477
```
7578

76-
### 主动发送消息
79+
### 旧版 access_token 主动发送消息
7780

7881
智能机器人可以主动向用户发送消息,用于推送通知或提醒。
7982

@@ -89,34 +92,29 @@ String msgId = response.getMsgId();
8992
String sessionId = response.getSessionId();
9093
```
9194

92-
### 接收用户消息
95+
### 新版 API 模式:接收回调与回复消息
9396

94-
当用户向智能机器人发送消息时,企业微信会通过回调接口推送消息。可以使用 `WxCpXmlMessage` 接收和解析这些消息:
97+
在机器人后台开启 API 模式后,配置 URL、Token、EncodingAESKey。企业微信会推送加密 JSON 回调;
98+
它不是 XML,也不需要企业应用 `secret`。从请求参数取得 `msg_signature`、`timestamp`、`nonce`,
99+
从请求体取得 `encrypt` 字段后,可以直接解密和解析:
95100

96101
```java
97-
// 在接收回调消息的接口中
98-
WxCpXmlMessage message = WxCpXmlMessage.fromEncryptedXml(
99-
requestBody, wxCpConfigStorage, timestamp, nonce, msgSignature
100-
);
101-
102-
// 获取智能机器人相关字段
103-
String robotId = message.getRobotId(); // 机器人ID
104-
String sessionId = message.getSessionId(); // 会话ID
105-
String content = message.getContent(); // 消息内容
106-
String fromUser = message.getFromUserName(); // 发送用户
107-
108-
// 处理消息并回复
109-
// ...
102+
WxCpIntelligentRobotMessage callbackMessage =
103+
robotService.parseEncryptedCallbackMessage(
104+
msgSignature, timestamp, nonce, encryptedJson,
105+
token, encodingAesKey, aiBotId);
106+
107+
String responseUrl = callbackMessage.getResponseUrl();
108+
String content = callbackMessage.getText().getContent();
110109
```
111110

112-
对于智能机器人 API 模式的 JSON 回调消息,可使用 `WxCpIntelligentRobotMessage` 解析:
111+
回复时使用回调中的短期 `response_url`,不调用基于 `access_token` 的 `sendMessage`:
113112

114113
```java
115-
WxCpIntelligentRobotMessage callbackMessage =
116-
robotService.parseCallbackMessage(jsonBody);
117-
String botId = callbackMessage.getAiBotId();
118-
String userId = callbackMessage.getFrom().getUserid();
119-
String msgType = callbackMessage.getMsgType();
114+
String replyJson = "{\"msgtype\":\"text\",\"text\":{\"content\":\"您好\"}}";
115+
robotService.replyMessage(
116+
responseUrl, replyJson, token, encodingAesKey, aiBotId,
117+
String.valueOf(System.currentTimeMillis() / 1000), java.util.UUID.randomUUID().toString());
120118
```
121119

122120
### 删除智能机器人
@@ -144,7 +142,8 @@ robotService.deleteRobot(robotId);
144142

145143
### 消息接收
146144

147-
- `WxCpXmlMessage`: 支持接收智能机器人回调消息,包含 `robotId` 和 `sessionId` 字段
145+
- `WxCpIntelligentRobotMessage`: 智能机器人 API 模式的已解密 JSON 回调消息
146+
- `WxCpIntelligentRobotCryptUtil`: 智能机器人 API 模式的消息加解密工具
148147

149148
### 服务接口
150149

@@ -153,7 +152,6 @@ robotService.deleteRobot(robotId);
153152

154153
## 注意事项
155154

156-
1. 需要确保企业微信应用具有智能机器人相关权限
157-
2. 智能机器人功能可能需要特定的企业微信版本支持
158-
3. 会话ID可以用于保持对话的连续性,提升用户体验
159-
4. 机器人状态: 0表示停用,1表示启用
155+
1. 新版 API 模式的 Token、EncodingAESKey 和机器人 ID 由机器人后台配置,不要填写企业应用 secret。
156+
2. `response_url` 是回调附带的临时地址,应及时使用,且不应持久化。
157+
3. `parseCallbackMessage` 仅用于已解密的 JSON;HTTP 回调入口应使用 `parseEncryptedCallbackMessage`。

‎weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/WxCpIntelligentRobotService.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,40 @@ public interface WxCpIntelligentRobotService {
8282
*/
8383
WxCpIntelligentRobotMessage parseCallbackMessage(String callbackMessageJson);
8484

85+
/**
86+
* 解密并解析智能机器人 API 模式回调消息.
87+
*
88+
* @param msgSignature 回调 URL 参数中的签名
89+
* @param timestamp 回调 URL 参数中的时间戳
90+
* @param nonce 回调 URL 参数中的随机串
91+
* @param encryptedJson 回调 JSON 信封中的 encrypt 字段
92+
* @param token 机器人后台配置的 Token
93+
* @param encodingAesKey 机器人后台配置的 EncodingAESKey
94+
* @param aiBotId 机器人 ID
95+
* @return 解密并解析后的回调消息
96+
*/
97+
default WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce,
98+
String encryptedJson, String token, String encodingAesKey,
99+
String aiBotId) {
100+
throw new UnsupportedOperationException("当前智能机器人服务不支持 API 模式回调解析");
101+
}
102+
103+
/**
104+
* 加密并向智能机器人 API 模式的临时 response_url 回复消息.
105+
*
106+
* @param responseUrl 回调消息中的 response_url
107+
* @param plainJson 回复的明文 JSON
108+
* @param token 机器人后台配置的 Token
109+
* @param encodingAesKey 机器人后台配置的 EncodingAESKey
110+
* @param aiBotId 机器人 ID
111+
* @param timestamp 回复时间戳
112+
* @param nonce 回复随机串
113+
* @return 企业微信响应内容
114+
* @throws WxErrorException 微信接口异常
115+
*/
116+
default String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey, String aiBotId,
117+
String timestamp, String nonce) throws WxErrorException {
118+
throw new UnsupportedOperationException("当前智能机器人服务不支持 API 模式消息回复");
119+
}
120+
85121
}

‎weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/BaseWxCpServiceImpl.java‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,23 +430,29 @@ protected <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E
430430
* 普通请求,不自动带accessToken
431431
*/
432432
private <T, E> T executeNormal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
433+
String uriForLog = redactQueryString(uri);
433434
try {
434435
T result = executor.execute(uri, data, WxType.CP);
435-
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uri, data, result);
436+
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriForLog, data, result);
436437
return result;
437438
} catch (WxErrorException e) {
438439
WxError error = e.getError();
439440
if (error.getErrorCode() != 0) {
440-
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uri, data, error);
441+
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uriForLog, data, error);
441442
throw new WxErrorException(error, e);
442443
}
443444
return null;
444445
} catch (IOException e) {
445-
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uri, data, e.getMessage());
446+
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriForLog, data, e.getMessage());
446447
throw new WxErrorException(e);
447448
}
448449
}
449450

451+
static String redactQueryString(String uri) {
452+
int queryStart = uri.indexOf('?');
453+
return queryStart < 0 ? uri : uri.substring(0, queryStart) + "?******";
454+
}
455+
450456
@Override
451457
public void setWxCpConfigStorage(WxCpConfigStorage wxConfigProvider) {
452458
this.configStorage = wxConfigProvider;

‎weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpIntelligentRobotServiceImpl.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import me.chanjar.weixin.cp.api.WxCpIntelligentRobotService;
77
import me.chanjar.weixin.cp.api.WxCpService;
88
import me.chanjar.weixin.cp.bean.intelligentrobot.*;
9+
import me.chanjar.weixin.cp.util.crypto.WxCpIntelligentRobotCryptUtil;
910
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
1011

1112
import static me.chanjar.weixin.cp.constant.WxCpApiPathConsts.IntelligentRobot.*;
@@ -72,4 +73,19 @@ public WxCpIntelligentRobotMessage parseCallbackMessage(String callbackMessageJs
7273
return WxCpIntelligentRobotMessage.fromJson(callbackMessageJson);
7374
}
7475

76+
@Override
77+
public WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce,
78+
String encryptedJson, String token,
79+
String encodingAesKey, String aiBotId) {
80+
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId);
81+
return parseCallbackMessage(cryptUtil.decrypt(msgSignature, timestamp, nonce, encryptedJson));
82+
}
83+
84+
@Override
85+
public String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey,
86+
String aiBotId, String timestamp, String nonce) throws WxErrorException {
87+
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId);
88+
return this.cpService.postWithoutToken(responseUrl, cryptUtil.encrypt(plainJson, timestamp, nonce));
89+
}
90+
7591
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package me.chanjar.weixin.cp.util.crypto;
2+
3+
import com.google.gson.JsonObject;
4+
import me.chanjar.weixin.common.util.crypto.SHA1;
5+
import me.chanjar.weixin.common.util.crypto.WxCryptUtil;
6+
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
7+
import me.chanjar.weixin.common.error.WxRuntimeException;
8+
import org.apache.commons.codec.binary.Base64;
9+
10+
import javax.crypto.Cipher;
11+
import javax.crypto.spec.IvParameterSpec;
12+
import javax.crypto.spec.SecretKeySpec;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.Arrays;
15+
import java.util.UUID;
16+
17+
/**
18+
* 企业微信智能机器人 API 模式消息加解密工具.
19+
*
20+
* <p>机器人 API 模式使用机器人后台配置的 Token、EncodingAESKey 和机器人 ID,
21+
* 与企业应用 access_token 无关。</p>
22+
*/
23+
public class WxCpIntelligentRobotCryptUtil extends WxCryptUtil {
24+
25+
public WxCpIntelligentRobotCryptUtil(String token, String encodingAesKey, String aiBotId) {
26+
super(token, encodingAesKey, aiBotId);
27+
}
28+
29+
/**
30+
* 解密机器人 API 模式的 JSON 回调消息.
31+
*/
32+
public String decrypt(String msgSignature, String timestamp, String nonce, String encryptedContent) {
33+
String signature = SHA1.gen(this.token, timestamp, nonce, encryptedContent);
34+
if (!signature.equals(msgSignature)) {
35+
throw new WxRuntimeException("加密消息签名校验失败");
36+
}
37+
38+
try {
39+
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
40+
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(this.aesKey, "AES"),
41+
new IvParameterSpec(Arrays.copyOfRange(this.aesKey, 0, 16)));
42+
byte[] bytes = me.chanjar.weixin.common.util.crypto.PKCS7Encoder.decode(
43+
cipher.doFinal(Base64.decodeBase64(encryptedContent)));
44+
if (bytes.length < 20) {
45+
throw new WxRuntimeException("解密后数据长度异常,可能为错误的密文或EncodingAESKey");
46+
}
47+
48+
int plainTextLength = 0;
49+
for (int index = 16; index < 20; index++) {
50+
plainTextLength = (plainTextLength << 8) | (bytes[index] & 0xff);
51+
}
52+
int plainTextEnd = 20 + plainTextLength;
53+
if (plainTextLength < 0 || plainTextEnd > bytes.length) {
54+
throw new WxRuntimeException("解密后数据格式非法:消息长度不正确,可能为错误的密文或EncodingAESKey");
55+
}
56+
57+
String receiverId = new String(Arrays.copyOfRange(bytes, plainTextEnd, bytes.length), StandardCharsets.UTF_8);
58+
if (!this.appidOrCorpid.equals(receiverId)) {
59+
throw new WxRuntimeException("智能机器人ID不正确,请核实!");
60+
}
61+
return new String(Arrays.copyOfRange(bytes, 20, plainTextEnd), StandardCharsets.UTF_8);
62+
} catch (WxRuntimeException e) {
63+
throw e;
64+
} catch (Exception e) {
65+
throw new WxRuntimeException(e);
66+
}
67+
}
68+
69+
/**
70+
* 加密机器人 API 模式的 JSON 回复消息.
71+
*/
72+
public String encrypt(String plainJson, String timestamp, String nonce) {
73+
String encryptedContent = encrypt(UUID.randomUUID().toString().replace("-", "").substring(0, 16), plainJson);
74+
JsonObject result = new JsonObject();
75+
result.addProperty("encrypt", encryptedContent);
76+
result.addProperty("msg_signature", SHA1.gen(this.token, timestamp, nonce, encryptedContent));
77+
result.addProperty("timestamp", timestamp);
78+
result.addProperty("nonce", nonce);
79+
return WxCpGsonBuilder.create().toJson(result);
80+
}
81+
82+
/**
83+
* 解密 URL 校验请求中的 echostr.
84+
*/
85+
public String verifyUrl(String msgSignature, String timestamp, String nonce, String echoStr) {
86+
return decrypt(msgSignature, timestamp, nonce, echoStr);
87+
}
88+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package me.chanjar.weixin.cp.api.impl;
2+
3+
import org.testng.annotations.Test;
4+
5+
import static org.testng.Assert.assertEquals;
6+
7+
public class BaseWxCpServiceImplLogTest {
8+
9+
@Test
10+
public void redactQueryStringShouldHideTemporaryResponseUrlCredentials() {
11+
assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply?token=temporary-secret&nonce=123"),
12+
"https://example.com/reply?******");
13+
}
14+
15+
@Test
16+
public void redactQueryStringShouldKeepUrlWithoutQueryString() {
17+
assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply"), "https://example.com/reply");
18+
}
19+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package me.chanjar.weixin.cp.api.impl;
2+
3+
import com.google.gson.JsonObject;
4+
import me.chanjar.weixin.common.util.json.GsonParser;
5+
import me.chanjar.weixin.cp.api.WxCpService;
6+
import me.chanjar.weixin.cp.bean.intelligentrobot.WxCpIntelligentRobotMessage;
7+
import me.chanjar.weixin.cp.util.crypto.WxCpIntelligentRobotCryptUtil;
8+
import org.mockito.ArgumentCaptor;
9+
import org.testng.annotations.Test;
10+
11+
import static org.mockito.ArgumentMatchers.anyString;
12+
import static org.mockito.Mockito.mock;
13+
import static org.mockito.Mockito.verify;
14+
import static org.mockito.Mockito.when;
15+
import static org.testng.Assert.assertEquals;
16+
17+
public class WxCpIntelligentRobotApiModeServiceTest {
18+
private static final String TOKEN = "test-token";
19+
private static final String AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFA";
20+
private static final String AI_BOT_ID = "bot_1";
21+
private static final String TIMESTAMP = "1710000000";
22+
private static final String NONCE = "test-nonce";
23+
24+
@Test
25+
public void shouldParseEncryptedCallbackMessage() {
26+
String callbackJson = "{\"msgid\":\"msg_1\",\"aibotid\":\"bot_1\",\"msgtype\":\"text\","
27+
+ "\"from\":{\"userid\":\"user_1\"},\"text\":{\"content\":\"hello\"}}";
28+
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID);
29+
JsonObject encrypted = GsonParser.parse(cryptUtil.encrypt(callbackJson, TIMESTAMP, NONCE));
30+
WxCpIntelligentRobotServiceImpl service = new WxCpIntelligentRobotServiceImpl(mock(WxCpService.class));
31+
32+
WxCpIntelligentRobotMessage message = service.parseEncryptedCallbackMessage(
33+
encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, encrypted.get("encrypt").getAsString(),
34+
TOKEN, AES_KEY, AI_BOT_ID);
35+
36+
assertEquals(message.getMsgId(), "msg_1");
37+
assertEquals(message.getText().getContent(), "hello");
38+
}
39+
40+
@Test
41+
public void shouldReplyThroughResponseUrlWithoutAccessToken() throws Exception {
42+
WxCpService cpService = mock(WxCpService.class);
43+
when(cpService.postWithoutToken(anyString(), anyString())).thenReturn("ok");
44+
WxCpIntelligentRobotServiceImpl service = new WxCpIntelligentRobotServiceImpl(cpService);
45+
String responseUrl = "https://example.com/response";
46+
String plainJson = "{\"msgtype\":\"text\"}";
47+
48+
assertEquals(service.replyMessage(responseUrl, plainJson, TOKEN, AES_KEY, AI_BOT_ID, TIMESTAMP, NONCE), "ok");
49+
50+
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
51+
verify(cpService).postWithoutToken(org.mockito.ArgumentMatchers.eq(responseUrl), bodyCaptor.capture());
52+
JsonObject encrypted = GsonParser.parse(bodyCaptor.getValue());
53+
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID);
54+
assertEquals(cryptUtil.decrypt(encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE,
55+
encrypted.get("encrypt").getAsString()), plainJson);
56+
}
57+
}

0 commit comments

Comments
 (0)