Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 26 additions & 28 deletions weixin-java-cp/INTELLIGENT_ROBOT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# 企业微信智能机器人接口

本模块提供企业微信智能机器人相关的API接口实现。
本模块提供企业微信智能机器人相关的 API 接口实现。

> `createRobot`、`chat`、`sendMessage` 等既有方法走企业应用 `access_token` 接口,
> 需要在 `WxCpConfigStorage` 中配置应用 `agentId` 和 `secret`。它们不适用于机器人后台创建的新版 API 模式。

## 官方文档

Expand Down Expand Up @@ -73,7 +76,7 @@ String sessionId = "session123";
robotService.resetSession(robotId, userid, sessionId);
```

### 主动发送消息
### 旧版 access_token 主动发送消息

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

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

### 接收用户消息
### 新版 API 模式:接收回调与回复消息

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

```java
// 在接收回调消息的接口中
WxCpXmlMessage message = WxCpXmlMessage.fromEncryptedXml(
requestBody, wxCpConfigStorage, timestamp, nonce, msgSignature
);

// 获取智能机器人相关字段
String robotId = message.getRobotId(); // 机器人ID
String sessionId = message.getSessionId(); // 会话ID
String content = message.getContent(); // 消息内容
String fromUser = message.getFromUserName(); // 发送用户

// 处理消息并回复
// ...
WxCpIntelligentRobotMessage callbackMessage =
robotService.parseEncryptedCallbackMessage(
msgSignature, timestamp, nonce, encryptedJson,
token, encodingAesKey, aiBotId);

String responseUrl = callbackMessage.getResponseUrl();
String content = callbackMessage.getText().getContent();
```

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

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

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

### 消息接收

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

### 服务接口

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

## 注意事项

1. 需要确保企业微信应用具有智能机器人相关权限
2. 智能机器人功能可能需要特定的企业微信版本支持
3. 会话ID可以用于保持对话的连续性,提升用户体验
4. 机器人状态: 0表示停用,1表示启用
1. 新版 API 模式的 Token、EncodingAESKey 和机器人 ID 由机器人后台配置,不要填写企业应用 secret。
2. `response_url` 是回调附带的临时地址,应及时使用,且不应持久化。
3. `parseCallbackMessage` 仅用于已解密的 JSON;HTTP 回调入口应使用 `parseEncryptedCallbackMessage`。
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,40 @@ public interface WxCpIntelligentRobotService {
*/
WxCpIntelligentRobotMessage parseCallbackMessage(String callbackMessageJson);

/**
* 解密并解析智能机器人 API 模式回调消息.
*
* @param msgSignature 回调 URL 参数中的签名
* @param timestamp 回调 URL 参数中的时间戳
* @param nonce 回调 URL 参数中的随机串
* @param encryptedJson 回调 JSON 信封中的 encrypt 字段
* @param token 机器人后台配置的 Token
* @param encodingAesKey 机器人后台配置的 EncodingAESKey
* @param aiBotId 机器人 ID
* @return 解密并解析后的回调消息
*/
default WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce,
String encryptedJson, String token, String encodingAesKey,
String aiBotId) {
throw new UnsupportedOperationException("当前智能机器人服务不支持 API 模式回调解析");
}

/**
* 加密并向智能机器人 API 模式的临时 response_url 回复消息.
*
* @param responseUrl 回调消息中的 response_url
* @param plainJson 回复的明文 JSON
* @param token 机器人后台配置的 Token
* @param encodingAesKey 机器人后台配置的 EncodingAESKey
* @param aiBotId 机器人 ID
* @param timestamp 回复时间戳
* @param nonce 回复随机串
* @return 企业微信响应内容
* @throws WxErrorException 微信接口异常
*/
default String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey, String aiBotId,
String timestamp, String nonce) throws WxErrorException {
throw new UnsupportedOperationException("当前智能机器人服务不支持 API 模式消息回复");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -430,23 +430,29 @@ protected <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E
* 普通请求,不自动带accessToken
*/
private <T, E> T executeNormal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
String uriForLog = redactQueryString(uri);
try {
T result = executor.execute(uri, data, WxType.CP);
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uri, data, result);
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriForLog, data, result);
return result;
} catch (WxErrorException e) {
WxError error = e.getError();
if (error.getErrorCode() != 0) {
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uri, data, error);
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【错误信息】:{}", uriForLog, data, error);
throw new WxErrorException(error, e);
}
return null;
} catch (IOException e) {
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uri, data, e.getMessage());
log.error("\n【请求地址】: {}\n【请求参数】:{}\n【异常信息】:{}", uriForLog, data, e.getMessage());
throw new WxErrorException(e);
}
}

static String redactQueryString(String uri) {
int queryStart = uri.indexOf('?');
return queryStart < 0 ? uri : uri.substring(0, queryStart) + "?******";
}

@Override
public void setWxCpConfigStorage(WxCpConfigStorage wxConfigProvider) {
this.configStorage = wxConfigProvider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import me.chanjar.weixin.cp.api.WxCpIntelligentRobotService;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.intelligentrobot.*;
import me.chanjar.weixin.cp.util.crypto.WxCpIntelligentRobotCryptUtil;
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;

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

@Override
public WxCpIntelligentRobotMessage parseEncryptedCallbackMessage(String msgSignature, String timestamp, String nonce,
String encryptedJson, String token,
String encodingAesKey, String aiBotId) {
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId);
return parseCallbackMessage(cryptUtil.decrypt(msgSignature, timestamp, nonce, encryptedJson));
}

@Override
public String replyMessage(String responseUrl, String plainJson, String token, String encodingAesKey,
String aiBotId, String timestamp, String nonce) throws WxErrorException {
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(token, encodingAesKey, aiBotId);
return this.cpService.postWithoutToken(responseUrl, cryptUtil.encrypt(plainJson, timestamp, nonce));
Comment thread
binarywang marked this conversation as resolved.
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package me.chanjar.weixin.cp.util.crypto;

import com.google.gson.JsonObject;
import me.chanjar.weixin.common.util.crypto.SHA1;
import me.chanjar.weixin.common.util.crypto.WxCryptUtil;
import me.chanjar.weixin.cp.util.json.WxCpGsonBuilder;
import me.chanjar.weixin.common.error.WxRuntimeException;
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.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;

/**
* 企业微信智能机器人 API 模式消息加解密工具.
*
* <p>机器人 API 模式使用机器人后台配置的 Token、EncodingAESKey 和机器人 ID,
* 与企业应用 access_token 无关。</p>
*/
public class WxCpIntelligentRobotCryptUtil extends WxCryptUtil {

public WxCpIntelligentRobotCryptUtil(String token, String encodingAesKey, String aiBotId) {
super(token, encodingAesKey, aiBotId);
}

/**
* 解密机器人 API 模式的 JSON 回调消息.
*/
public String decrypt(String msgSignature, String timestamp, String nonce, String encryptedContent) {
String signature = SHA1.gen(this.token, timestamp, nonce, encryptedContent);
if (!signature.equals(msgSignature)) {
throw new WxRuntimeException("加密消息签名校验失败");
}

try {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(this.aesKey, "AES"),
new IvParameterSpec(Arrays.copyOfRange(this.aesKey, 0, 16)));
byte[] bytes = me.chanjar.weixin.common.util.crypto.PKCS7Encoder.decode(
cipher.doFinal(Base64.decodeBase64(encryptedContent)));
if (bytes.length < 20) {
throw new WxRuntimeException("解密后数据长度异常,可能为错误的密文或EncodingAESKey");
}

int plainTextLength = 0;
for (int index = 16; index < 20; index++) {
plainTextLength = (plainTextLength << 8) | (bytes[index] & 0xff);
}
int plainTextEnd = 20 + plainTextLength;
if (plainTextLength < 0 || plainTextEnd > bytes.length) {
throw new WxRuntimeException("解密后数据格式非法:消息长度不正确,可能为错误的密文或EncodingAESKey");
}

String receiverId = new String(Arrays.copyOfRange(bytes, plainTextEnd, bytes.length), StandardCharsets.UTF_8);
if (!this.appidOrCorpid.equals(receiverId)) {
throw new WxRuntimeException("智能机器人ID不正确,请核实!");
}
return new String(Arrays.copyOfRange(bytes, 20, plainTextEnd), StandardCharsets.UTF_8);
} catch (WxRuntimeException e) {
throw e;
} catch (Exception e) {
throw new WxRuntimeException(e);
}
}

/**
* 加密机器人 API 模式的 JSON 回复消息.
*/
public String encrypt(String plainJson, String timestamp, String nonce) {
String encryptedContent = encrypt(UUID.randomUUID().toString().replace("-", "").substring(0, 16), plainJson);
JsonObject result = new JsonObject();
result.addProperty("encrypt", encryptedContent);
result.addProperty("msg_signature", SHA1.gen(this.token, timestamp, nonce, encryptedContent));
result.addProperty("timestamp", timestamp);
result.addProperty("nonce", nonce);
return WxCpGsonBuilder.create().toJson(result);
}

/**
* 解密 URL 校验请求中的 echostr.
*/
public String verifyUrl(String msgSignature, String timestamp, String nonce, String echoStr) {
return decrypt(msgSignature, timestamp, nonce, echoStr);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package me.chanjar.weixin.cp.api.impl;

import org.testng.annotations.Test;

import static org.testng.Assert.assertEquals;

public class BaseWxCpServiceImplLogTest {

@Test
public void redactQueryStringShouldHideTemporaryResponseUrlCredentials() {
assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply?token=temporary-secret&nonce=123"),
"https://example.com/reply?******");
}

@Test
public void redactQueryStringShouldKeepUrlWithoutQueryString() {
assertEquals(BaseWxCpServiceImpl.redactQueryString("https://example.com/reply"), "https://example.com/reply");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package me.chanjar.weixin.cp.api.impl;

import com.google.gson.JsonObject;
import me.chanjar.weixin.common.util.json.GsonParser;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.intelligentrobot.WxCpIntelligentRobotMessage;
import me.chanjar.weixin.cp.util.crypto.WxCpIntelligentRobotCryptUtil;
import org.mockito.ArgumentCaptor;
import org.testng.annotations.Test;

import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;

public class WxCpIntelligentRobotApiModeServiceTest {
Comment thread
binarywang marked this conversation as resolved.
private static final String TOKEN = "test-token";
private static final String AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFA";
private static final String AI_BOT_ID = "bot_1";
private static final String TIMESTAMP = "1710000000";
private static final String NONCE = "test-nonce";

Comment thread
binarywang marked this conversation as resolved.
@Test
public void shouldParseEncryptedCallbackMessage() {
String callbackJson = "{\"msgid\":\"msg_1\",\"aibotid\":\"bot_1\",\"msgtype\":\"text\","
+ "\"from\":{\"userid\":\"user_1\"},\"text\":{\"content\":\"hello\"}}";
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID);
JsonObject encrypted = GsonParser.parse(cryptUtil.encrypt(callbackJson, TIMESTAMP, NONCE));
WxCpIntelligentRobotServiceImpl service = new WxCpIntelligentRobotServiceImpl(mock(WxCpService.class));

WxCpIntelligentRobotMessage message = service.parseEncryptedCallbackMessage(
encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE, encrypted.get("encrypt").getAsString(),
TOKEN, AES_KEY, AI_BOT_ID);

assertEquals(message.getMsgId(), "msg_1");
assertEquals(message.getText().getContent(), "hello");
}

@Test
public void shouldReplyThroughResponseUrlWithoutAccessToken() throws Exception {
WxCpService cpService = mock(WxCpService.class);
when(cpService.postWithoutToken(anyString(), anyString())).thenReturn("ok");
WxCpIntelligentRobotServiceImpl service = new WxCpIntelligentRobotServiceImpl(cpService);
String responseUrl = "https://example.com/response";
String plainJson = "{\"msgtype\":\"text\"}";

assertEquals(service.replyMessage(responseUrl, plainJson, TOKEN, AES_KEY, AI_BOT_ID, TIMESTAMP, NONCE), "ok");

ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
verify(cpService).postWithoutToken(org.mockito.ArgumentMatchers.eq(responseUrl), bodyCaptor.capture());
JsonObject encrypted = GsonParser.parse(bodyCaptor.getValue());
WxCpIntelligentRobotCryptUtil cryptUtil = new WxCpIntelligentRobotCryptUtil(TOKEN, AES_KEY, AI_BOT_ID);
assertEquals(cryptUtil.decrypt(encrypted.get("msg_signature").getAsString(), TIMESTAMP, NONCE,
encrypted.get("encrypt").getAsString()), plainJson);
}
}
Loading