mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
fix(weixin): send generated files through weixin channel (#543)
Send agent-generated files as native WeChat attachments via the iLink upload flow, and fix the wire protocol for file uploads: dedicated wire ObjectMapper (bypasses the global Long-to-String serializer), md5/len fields and encrypt_type on media items, channel_version 1.0.2, and explicit business-error handling on ret != 0. The weixin adapter now routes generated-file URLs through GeneratedFileScrubber, matching WeCom/Feishu behavior. Fixes #307
This commit is contained in:
parent
d10ed9dd06
commit
1e2b7bbc2c
@ -1213,7 +1213,7 @@ public class ChannelManager {
|
|||||||
generatedFileCache, chatUploadLocationResolver);
|
generatedFileCache, chatUploadLocationResolver);
|
||||||
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
|
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper,
|
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper,
|
||||||
chatUploadLocationResolver);
|
chatUploadLocationResolver, generatedFileScrubber);
|
||||||
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
|
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
|
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
|
||||||
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);
|
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);
|
||||||
|
|||||||
@ -78,6 +78,15 @@ public class ChannelMessageRouter {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||||
|
|
||||||
|
/** Field-injected so the IM sync path can scrub hallucinated
|
||||||
|
* {@code /api/v1/files/generated/{id}} URLs (LLM wrote a UUID-shaped
|
||||||
|
* link without ever calling a render tool). The graph's FinalAnswerNode
|
||||||
|
* already does this, but the IM sync path accumulates {@code delta.content()}
|
||||||
|
* directly and bypasses FinalAnswerNode — without this scrub, the fake
|
||||||
|
* URL reaches the IM channel as a clickable link that 404s. */
|
||||||
|
@Autowired(required = false)
|
||||||
|
private vip.mate.tool.document.GeneratedFileCache generatedFileCache;
|
||||||
|
|
||||||
/** 队列条目:封装消息及其路由上下文 */
|
/** 队列条目:封装消息及其路由上下文 */
|
||||||
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
|
||||||
|
|
||||||
@ -829,6 +838,22 @@ public class ChannelMessageRouter {
|
|||||||
.blockLast(Duration.ofMinutes(10));
|
.blockLast(Duration.ofMinutes(10));
|
||||||
String reply = replyAccumulator.toString();
|
String reply = replyAccumulator.toString();
|
||||||
|
|
||||||
|
// The IM sync path bypasses FinalAnswerNode, so hallucinated
|
||||||
|
// /api/v1/files/generated/{id} URLs (LLM wrote a fake link
|
||||||
|
// without calling a render tool) reach here verbatim. Scrub
|
||||||
|
// them to the user-visible warning so IM clients don't see
|
||||||
|
// a clickable link that 404s. Real tool-produced URLs are
|
||||||
|
// left intact for the channel adapter's scrubber to upgrade
|
||||||
|
// into native attachments.
|
||||||
|
if (generatedFileCache != null) {
|
||||||
|
String scrubbed = generatedFileCache.scrubMissingReferences(reply);
|
||||||
|
if (!scrubbed.equals(reply)) {
|
||||||
|
log.info("[{}] Scrubbed hallucinated generated-file URL(s) from IM reply ({} -> {} chars)",
|
||||||
|
adapter.getChannelType(), reply.length(), scrubbed.length());
|
||||||
|
reply = scrubbed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查 chat 过程中是否产生了审批 pending
|
// 检查 chat 过程中是否产生了审批 pending
|
||||||
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
|
||||||
if (newPending != null) {
|
if (newPending != null) {
|
||||||
|
|||||||
@ -44,7 +44,8 @@ import java.util.*;
|
|||||||
public class ILinkClient {
|
public class ILinkClient {
|
||||||
|
|
||||||
public static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
public static final String DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
||||||
private static final String CHANNEL_VERSION = "2.0.1";
|
private static final String CHANNEL_VERSION = "1.0.2";
|
||||||
|
private static final ObjectMapper WIRE_OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
/** 长轮询超时(服务端最长 35s,客户端设 45s) */
|
/** 长轮询超时(服务端最长 35s,客户端设 45s) */
|
||||||
private static final Duration GETUPDATES_TIMEOUT = Duration.ofSeconds(45);
|
private static final Duration GETUPDATES_TIMEOUT = Duration.ofSeconds(45);
|
||||||
@ -226,14 +227,30 @@ public class ILinkClient {
|
|||||||
body.put("msg", msg);
|
body.put("msg", msg);
|
||||||
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
|
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
|
||||||
|
|
||||||
|
String requestJson = WIRE_OBJECT_MAPPER.writeValueAsString(body);
|
||||||
|
log.info("[weixin] sendMessage request: toUser={}, contextTokenPresent={}, itemSummary={}, payload={}",
|
||||||
|
maskId(String.valueOf(msg.get("to_user_id"))),
|
||||||
|
String.valueOf(msg.getOrDefault("context_token", "")).length() > 0,
|
||||||
|
summarizeItems(msg.get("item_list")), redactSendMessagePayload(body));
|
||||||
|
|
||||||
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
|
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
|
||||||
.uri(URI.create(baseUrl + "/ilink/bot/sendmessage"))
|
.uri(URI.create(baseUrl + "/ilink/bot/sendmessage"))
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
|
.POST(HttpRequest.BodyPublishers.ofString(requestJson)))
|
||||||
.timeout(DEFAULT_TIMEOUT)
|
.timeout(DEFAULT_TIMEOUT)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
log.info("[weixin] sendMessage response: status={}, body={}", response.statusCode(), response.body());
|
||||||
ensureOk(response, "sendMessage");
|
ensureOk(response, "sendMessage");
|
||||||
return objectMapper.readValue(response.body(), new TypeReference<>() {});
|
Map<String, Object> result = objectMapper.readValue(response.body(), new TypeReference<>() {});
|
||||||
|
Object ret = result.get("ret");
|
||||||
|
if (ret instanceof Number n && n.intValue() != 0) {
|
||||||
|
log.error("[weixin] sendMessage business error: ret={}, errmsg={}, toUser={}, itemSummary={}, body={}",
|
||||||
|
ret, result.get("errmsg"), maskId(String.valueOf(msg.get("to_user_id"))),
|
||||||
|
summarizeItems(msg.get("item_list")), response.body());
|
||||||
|
throw new RuntimeException("sendMessage business error: ret=" + ret
|
||||||
|
+ ", errmsg=" + result.get("errmsg"));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -376,14 +393,42 @@ public class ILinkClient {
|
|||||||
body.put("no_need_thumb", true);
|
body.put("no_need_thumb", true);
|
||||||
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
|
body.put("base_info", Map.of("channel_version", CHANNEL_VERSION));
|
||||||
|
|
||||||
|
String requestJson = WIRE_OBJECT_MAPPER.writeValueAsString(body);
|
||||||
|
log.info("[weixin] getUploadUrl request: mediaType={}, toUser={}, rawSize={}({}), encryptedSize={}({}), "
|
||||||
|
+ "rawMd5={}, aesKeyHexLen={}, noNeedThumb={}, channelVersion={}, payload={}",
|
||||||
|
mediaType, maskId(toUserId), rawSize, typeName(body.get("rawsize")),
|
||||||
|
fileSize, typeName(body.get("filesize")), rawFileMd5,
|
||||||
|
aesKeyHex == null ? 0 : aesKeyHex.length(), true, CHANNEL_VERSION,
|
||||||
|
redactUploadUrlPayload(body));
|
||||||
|
|
||||||
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
|
HttpRequest request = applyHeaders(HttpRequest.newBuilder()
|
||||||
.uri(URI.create(baseUrl + "/ilink/bot/getuploadurl"))
|
.uri(URI.create(baseUrl + "/ilink/bot/getuploadurl"))
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
|
.POST(HttpRequest.BodyPublishers.ofString(requestJson)))
|
||||||
.timeout(DEFAULT_TIMEOUT)
|
.timeout(DEFAULT_TIMEOUT)
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
log.info("[weixin] getUploadUrl response: status={}, body={}",
|
||||||
|
response.statusCode(), response.body());
|
||||||
ensureOk(response, "getUploadUrl");
|
ensureOk(response, "getUploadUrl");
|
||||||
return objectMapper.readValue(response.body(), new TypeReference<>() {});
|
Map<String, Object> result = objectMapper.readValue(response.body(), new TypeReference<>() {});
|
||||||
|
Object ret = result.get("ret");
|
||||||
|
if (ret instanceof Number n && n.intValue() != 0) {
|
||||||
|
log.error("[weixin] getUploadUrl business error: ret={}, errmsg={}, mediaType={}, toUser={}, "
|
||||||
|
+ "rawSize={}, encryptedSize={}, rawMd5={}, filekeyPrefix={}, body={}",
|
||||||
|
ret, result.get("errmsg"), mediaType, maskId(toUserId), rawSize, fileSize,
|
||||||
|
rawFileMd5, prefix(filekey, 8), response.body());
|
||||||
|
throw new RuntimeException("getUploadUrl business error: ret=" + ret
|
||||||
|
+ ", errmsg=" + result.get("errmsg"));
|
||||||
|
} else if (!result.containsKey("upload_full_url") && !result.containsKey("upload_param")) {
|
||||||
|
log.error("[weixin] getUploadUrl: missing upload_full_url and upload_param. Full response: {}",
|
||||||
|
response.body());
|
||||||
|
} else {
|
||||||
|
log.info("[weixin] getUploadUrl ok: mediaType={}, rawSize={}, hasFullUrl={}, hasUploadParam={}, "
|
||||||
|
+ "uploadParamLen={}",
|
||||||
|
mediaType, rawSize, result.containsKey("upload_full_url"), result.containsKey("upload_param"),
|
||||||
|
String.valueOf(result.getOrDefault("upload_param", "")).length());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -406,6 +451,8 @@ public class ILinkClient {
|
|||||||
// 1. 原始文件元数据
|
// 1. 原始文件元数据
|
||||||
long rawSize = fileBytes.length;
|
long rawSize = fileBytes.length;
|
||||||
String rawFileMd5 = md5Hex(fileBytes);
|
String rawFileMd5 = md5Hex(fileBytes);
|
||||||
|
log.info("[weixin] uploadMedia begin: mediaType={}, fileName={}, rawSize={}, rawMd5={}, toUser={}",
|
||||||
|
mediaType, fileName, rawSize, rawFileMd5, maskId(toUserId));
|
||||||
|
|
||||||
// 2. 生成 AES key 并加密
|
// 2. 生成 AES key 并加密
|
||||||
SecureRandom random = new SecureRandom();
|
SecureRandom random = new SecureRandom();
|
||||||
@ -418,11 +465,16 @@ public class ILinkClient {
|
|||||||
|
|
||||||
byte[] encryptedData = WeixinAesUtil.aesEcbEncrypt(fileBytes, aesKeyB64ForEncrypt);
|
byte[] encryptedData = WeixinAesUtil.aesEcbEncrypt(fileBytes, aesKeyB64ForEncrypt);
|
||||||
long encryptedSize = encryptedData.length;
|
long encryptedSize = encryptedData.length;
|
||||||
|
log.info("[weixin] uploadMedia encrypted: mediaType={}, rawSize={}, encryptedSize={}, paddingBytes={}, "
|
||||||
|
+ "aesKeyHexLen={}",
|
||||||
|
mediaType, rawSize, encryptedSize, encryptedSize - rawSize, aesKeyHex.length());
|
||||||
|
|
||||||
// 3. 生成 filekey(16 字节随机 hex)
|
// 3. 生成 filekey(16 字节随机 hex)
|
||||||
byte[] filekeyBytes = new byte[16];
|
byte[] filekeyBytes = new byte[16];
|
||||||
random.nextBytes(filekeyBytes);
|
random.nextBytes(filekeyBytes);
|
||||||
String filekey = bytesToHex(filekeyBytes);
|
String filekey = bytesToHex(filekeyBytes);
|
||||||
|
log.info("[weixin] uploadMedia filekey generated: mediaType={}, filekeyPrefix={}, filekeyLen={}",
|
||||||
|
mediaType, prefix(filekey, 8), filekey.length());
|
||||||
|
|
||||||
// 4. 获取上传 URL
|
// 4. 获取上传 URL
|
||||||
Map<String, Object> uploadUrlResp = getUploadUrl(filekey, mediaType, toUserId,
|
Map<String, Object> uploadUrlResp = getUploadUrl(filekey, mediaType, toUserId,
|
||||||
@ -440,6 +492,13 @@ public class ILinkClient {
|
|||||||
String encParam = URLEncoder.encode(uploadParam, StandardCharsets.UTF_8);
|
String encParam = URLEncoder.encode(uploadParam, StandardCharsets.UTF_8);
|
||||||
uploadUrl = CDN_BASE_URL + "/upload?encrypted_query_param=" + encParam + "&filekey=" + filekey;
|
uploadUrl = CDN_BASE_URL + "/upload?encrypted_query_param=" + encParam + "&filekey=" + filekey;
|
||||||
}
|
}
|
||||||
|
log.info("[weixin] CDN upload target resolved: mediaType={}, mode={}, uploadParamPresent={}, uploadParamLen={}, "
|
||||||
|
+ "uploadFullUrlPresent={}, uploadHost={}, uploadPath={}, filekeyPrefix={}",
|
||||||
|
mediaType, (fullUrl != null && !fullUrl.isBlank()) ? "upload_full_url" : "upload_param",
|
||||||
|
uploadUrlResp.get("upload_param") != null,
|
||||||
|
String.valueOf(uploadUrlResp.getOrDefault("upload_param", "")).length(),
|
||||||
|
fullUrl != null && !fullUrl.isBlank(),
|
||||||
|
URI.create(uploadUrl).getHost(), URI.create(uploadUrl).getPath(), prefix(filekey, 8));
|
||||||
|
|
||||||
// 5. POST 加密数据到 CDN(注意:使用 upload_param 时不需要 Authorization 头)
|
// 5. POST 加密数据到 CDN(注意:使用 upload_param 时不需要 Authorization 头)
|
||||||
HttpRequest.Builder cdnBuilder = HttpRequest.newBuilder()
|
HttpRequest.Builder cdnBuilder = HttpRequest.newBuilder()
|
||||||
@ -453,6 +512,12 @@ public class ILinkClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse<byte[]> cdnResponse = httpClient.send(cdnBuilder.build(), HttpResponse.BodyHandlers.ofByteArray());
|
HttpResponse<byte[]> cdnResponse = httpClient.send(cdnBuilder.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
log.info("[weixin] CDN upload response: status={}, bodyBytes={}, hasXEncryptedParam={}, headers={}",
|
||||||
|
cdnResponse.statusCode(),
|
||||||
|
cdnResponse.body() == null ? 0 : cdnResponse.body().length,
|
||||||
|
cdnResponse.headers().firstValue("x-encrypted-param")
|
||||||
|
.or(() -> cdnResponse.headers().firstValue("X-Encrypted-Param")).isPresent(),
|
||||||
|
cdnResponse.headers().map().keySet());
|
||||||
ensureOk(cdnResponse, "CDN upload");
|
ensureOk(cdnResponse, "CDN upload");
|
||||||
|
|
||||||
// 6. 从响应头提取 encrypt_query_param
|
// 6. 从响应头提取 encrypt_query_param
|
||||||
@ -469,7 +534,7 @@ public class ILinkClient {
|
|||||||
log.info("[weixin] Media uploaded: type={}, size={}KB, encryptedSize={}KB",
|
log.info("[weixin] Media uploaded: type={}, size={}KB, encryptedSize={}KB",
|
||||||
mediaType, rawSize / 1024, encryptedSize / 1024);
|
mediaType, rawSize / 1024, encryptedSize / 1024);
|
||||||
|
|
||||||
return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize);
|
return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize, rawFileMd5, rawSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -480,6 +545,9 @@ public class ILinkClient {
|
|||||||
* @param contextToken 上下文 token
|
* @param contextToken 上下文 token
|
||||||
*/
|
*/
|
||||||
public void sendImage(String toUserId, byte[] imageBytes, String contextToken) throws Exception {
|
public void sendImage(String toUserId, byte[] imageBytes, String contextToken) throws Exception {
|
||||||
|
log.info("[weixin] sendImage begin: toUser={}, imageBytes={}, contextTokenPresent={}",
|
||||||
|
maskId(toUserId), imageBytes == null ? 0 : imageBytes.length,
|
||||||
|
contextToken != null && !contextToken.isBlank());
|
||||||
UploadResult result = uploadMedia(imageBytes, "image.jpg", 1, toUserId);
|
UploadResult result = uploadMedia(imageBytes, "image.jpg", 1, toUserId);
|
||||||
|
|
||||||
Map<String, Object> imageItem = new LinkedHashMap<>();
|
Map<String, Object> imageItem = new LinkedHashMap<>();
|
||||||
@ -488,6 +556,7 @@ public class ILinkClient {
|
|||||||
"media", Map.of(
|
"media", Map.of(
|
||||||
"encrypt_query_param", result.encryptQueryParam(),
|
"encrypt_query_param", result.encryptQueryParam(),
|
||||||
"aes_key", result.aesKeyB64(),
|
"aes_key", result.aesKeyB64(),
|
||||||
|
"encrypt_type", 1,
|
||||||
"mid_size", result.fileSize()
|
"mid_size", result.fileSize()
|
||||||
)
|
)
|
||||||
));
|
));
|
||||||
@ -517,16 +586,21 @@ public class ILinkClient {
|
|||||||
* @param contextToken 上下文 token
|
* @param contextToken 上下文 token
|
||||||
*/
|
*/
|
||||||
public void sendFile(String toUserId, byte[] fileBytes, String fileName, String contextToken) throws Exception {
|
public void sendFile(String toUserId, byte[] fileBytes, String fileName, String contextToken) throws Exception {
|
||||||
|
log.info("[weixin] sendFile begin: toUser={}, fileName={}, fileBytes={}, contextTokenPresent={}",
|
||||||
|
maskId(toUserId), fileName, fileBytes == null ? 0 : fileBytes.length,
|
||||||
|
contextToken != null && !contextToken.isBlank());
|
||||||
UploadResult result = uploadMedia(fileBytes, fileName, 3, toUserId);
|
UploadResult result = uploadMedia(fileBytes, fileName, 3, toUserId);
|
||||||
|
|
||||||
Map<String, Object> fileItem = new LinkedHashMap<>();
|
Map<String, Object> fileItem = new LinkedHashMap<>();
|
||||||
fileItem.put("type", 4);
|
fileItem.put("type", 4);
|
||||||
fileItem.put("file_item", Map.of(
|
fileItem.put("file_item", Map.of(
|
||||||
"file_name", fileName,
|
"file_name", fileName,
|
||||||
"len", (long) fileBytes.length,
|
"md5", result.rawFileMd5(),
|
||||||
|
"len", String.valueOf(result.rawSize()),
|
||||||
"media", Map.of(
|
"media", Map.of(
|
||||||
"encrypt_query_param", result.encryptQueryParam(),
|
"encrypt_query_param", result.encryptQueryParam(),
|
||||||
"aes_key", result.aesKeyB64()
|
"aes_key", result.aesKeyB64(),
|
||||||
|
"encrypt_type", 1
|
||||||
)
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -539,6 +613,10 @@ public class ILinkClient {
|
|||||||
msg.put("context_token", contextToken);
|
msg.put("context_token", contextToken);
|
||||||
msg.put("item_list", List.of(fileItem));
|
msg.put("item_list", List.of(fileItem));
|
||||||
|
|
||||||
|
log.info("[weixin] sendFile message prepared: toUser={}, fileName={}, len={}, encryptParamLen={}, aesKeyLen={}",
|
||||||
|
maskId(toUserId), fileName, fileBytes.length,
|
||||||
|
result.encryptQueryParam() == null ? 0 : result.encryptQueryParam().length(),
|
||||||
|
result.aesKeyB64() == null ? 0 : result.aesKeyB64().length());
|
||||||
sendMessage(msg);
|
sendMessage(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -557,7 +635,8 @@ public class ILinkClient {
|
|||||||
videoItem.put("video_item", Map.of(
|
videoItem.put("video_item", Map.of(
|
||||||
"media", Map.of(
|
"media", Map.of(
|
||||||
"encrypt_query_param", result.encryptQueryParam(),
|
"encrypt_query_param", result.encryptQueryParam(),
|
||||||
"aes_key", result.aesKeyB64()
|
"aes_key", result.aesKeyB64(),
|
||||||
|
"encrypt_type", 1
|
||||||
)
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -610,6 +689,128 @@ public class ILinkClient {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String maskId(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int head = Math.min(12, value.length());
|
||||||
|
return value.substring(0, head) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String prefix(String value, int length) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.substring(0, Math.min(length, value.length()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String typeName(Object value) {
|
||||||
|
return value == null ? "null" : value.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String redactUploadUrlPayload(Map<String, Object> body) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> redacted = new LinkedHashMap<>(body);
|
||||||
|
redacted.put("to_user_id", maskId(String.valueOf(body.get("to_user_id"))));
|
||||||
|
redacted.put("filekey", prefix(String.valueOf(body.get("filekey")), 8) + "...");
|
||||||
|
redacted.put("aeskey", "len:" + String.valueOf(body.get("aeskey")).length());
|
||||||
|
return WIRE_OBJECT_MAPPER.writeValueAsString(redacted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "<redact-failed:" + e.getMessage() + ">";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String redactSendMessagePayload(Map<String, Object> body) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> redacted = new LinkedHashMap<>(body);
|
||||||
|
Object msgObj = redacted.get("msg");
|
||||||
|
if (msgObj instanceof Map<?, ?> msgMap) {
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msgMap.forEach((k, v) -> msg.put(String.valueOf(k), v));
|
||||||
|
msg.put("to_user_id", maskId(String.valueOf(msg.get("to_user_id"))));
|
||||||
|
if (msg.containsKey("context_token")) {
|
||||||
|
msg.put("context_token", "present:" + !String.valueOf(msg.get("context_token")).isBlank());
|
||||||
|
}
|
||||||
|
msg.put("item_list", redactItems(msg.get("item_list")));
|
||||||
|
redacted.put("msg", msg);
|
||||||
|
}
|
||||||
|
return WIRE_OBJECT_MAPPER.writeValueAsString(redacted);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "<redact-failed:" + e.getMessage() + ">";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object redactItems(Object itemListObj) {
|
||||||
|
if (!(itemListObj instanceof List<?> items)) {
|
||||||
|
return itemListObj;
|
||||||
|
}
|
||||||
|
List<Object> redacted = new ArrayList<>();
|
||||||
|
for (Object itemObj : items) {
|
||||||
|
if (!(itemObj instanceof Map<?, ?> itemMap)) {
|
||||||
|
redacted.add(itemObj);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
itemMap.forEach((k, v) -> item.put(String.valueOf(k), v));
|
||||||
|
Object fileObj = item.get("file_item");
|
||||||
|
if (fileObj instanceof Map<?, ?> fileMap) {
|
||||||
|
Map<String, Object> file = new LinkedHashMap<>();
|
||||||
|
fileMap.forEach((k, v) -> file.put(String.valueOf(k), v));
|
||||||
|
file.put("media", redactMedia(file.get("media")));
|
||||||
|
item.put("file_item", file);
|
||||||
|
}
|
||||||
|
Object imageObj = item.get("image_item");
|
||||||
|
if (imageObj instanceof Map<?, ?> imageMap) {
|
||||||
|
Map<String, Object> image = new LinkedHashMap<>();
|
||||||
|
imageMap.forEach((k, v) -> image.put(String.valueOf(k), v));
|
||||||
|
image.put("media", redactMedia(image.get("media")));
|
||||||
|
item.put("image_item", image);
|
||||||
|
}
|
||||||
|
redacted.add(item);
|
||||||
|
}
|
||||||
|
return redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object redactMedia(Object mediaObj) {
|
||||||
|
if (!(mediaObj instanceof Map<?, ?> mediaMap)) {
|
||||||
|
return mediaObj;
|
||||||
|
}
|
||||||
|
Map<String, Object> media = new LinkedHashMap<>();
|
||||||
|
mediaMap.forEach((k, v) -> media.put(String.valueOf(k), v));
|
||||||
|
media.put("encrypt_query_param", "len:" + String.valueOf(media.get("encrypt_query_param")).length());
|
||||||
|
media.put("aes_key", "len:" + String.valueOf(media.get("aes_key")).length());
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String summarizeItems(Object itemListObj) {
|
||||||
|
if (!(itemListObj instanceof List<?> items)) {
|
||||||
|
return "not-list";
|
||||||
|
}
|
||||||
|
List<String> summaries = new ArrayList<>();
|
||||||
|
for (Object itemObj : items) {
|
||||||
|
if (!(itemObj instanceof Map<?, ?> itemMap)) {
|
||||||
|
summaries.add("unknown");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object type = itemMap.get("type");
|
||||||
|
Object fileObj = itemMap.get("file_item");
|
||||||
|
if (fileObj instanceof Map<?, ?> fileMap) {
|
||||||
|
Object len = fileMap.get("len");
|
||||||
|
summaries.add("type=" + type + ",file,len=" + len + "(" + typeName(len) + ")");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object imageObj = itemMap.get("image_item");
|
||||||
|
if (imageObj instanceof Map<?, ?> imageMap) {
|
||||||
|
Object mediaObj = imageMap.get("media");
|
||||||
|
Object midSize = mediaObj instanceof Map<?, ?> mediaMap ? mediaMap.get("mid_size") : null;
|
||||||
|
summaries.add("type=" + type + ",image,midSize=" + midSize + "(" + typeName(midSize) + ")");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
summaries.add("type=" + type);
|
||||||
|
}
|
||||||
|
return String.join(";", summaries);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 内部模型 ====================
|
// ==================== 内部模型 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -619,7 +820,8 @@ public class ILinkClient {
|
|||||||
* @param aesKeyB64 AES key 的 base64(hex) 编码(用于 media.aes_key)
|
* @param aesKeyB64 AES key 的 base64(hex) 编码(用于 media.aes_key)
|
||||||
* @param fileSize 加密后文件大小
|
* @param fileSize 加密后文件大小
|
||||||
*/
|
*/
|
||||||
public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize) {}
|
public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize,
|
||||||
|
String rawFileMd5, long rawSize) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QR 码登录结果
|
* QR 码登录结果
|
||||||
|
|||||||
@ -155,24 +155,37 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
*/
|
*/
|
||||||
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel-shared scrubber that converts agent-emitted
|
||||||
|
* {@code /api/v1/files/generated/{id}} URLs into native WeChat attachments.
|
||||||
|
* Nullable for legacy callers / unit tests — when null, the URL passes
|
||||||
|
* through unchanged (legacy text-only behaviour).
|
||||||
|
*/
|
||||||
|
private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber;
|
||||||
|
|
||||||
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
super(channelEntity, messageRouter, objectMapper);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
|
this.generatedFileScrubber = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full constructor used by the production factory (ChannelManager). The
|
* Full constructor used by the production factory (ChannelManager). The
|
||||||
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
|
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
|
||||||
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
|
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
|
||||||
* behaviour.
|
* behaviour. The {@code generatedFileScrubber} upgrades
|
||||||
|
* {@code /api/v1/files/generated/{id}} URLs in agent replies into native
|
||||||
|
* WeChat file/image attachments.
|
||||||
*/
|
*/
|
||||||
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
|
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver,
|
||||||
|
vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber) {
|
||||||
super(channelEntity, messageRouter, objectMapper);
|
super(channelEntity, messageRouter, objectMapper);
|
||||||
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
this.chatUploadLocationResolver = chatUploadLocationResolver;
|
||||||
|
this.generatedFileScrubber = generatedFileScrubber;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -744,13 +757,27 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
// 停止输入提示
|
// 停止输入提示
|
||||||
stopTyping(toUserId);
|
stopTyping(toUserId);
|
||||||
|
|
||||||
|
// 文本 part 里可能携带 /api/v1/files/generated/{id} URL,需先 scrub。
|
||||||
|
// 收集所有命中的附件,待文本全部发完后再统一推送(保持"文本在前,附件在后"的顺序)
|
||||||
|
List<vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit> deferredAttachments =
|
||||||
|
new java.util.ArrayList<>();
|
||||||
|
|
||||||
for (MessageContentPart part : parts) {
|
for (MessageContentPart part : parts) {
|
||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
try {
|
try {
|
||||||
switch (part.getType()) {
|
switch (part.getType()) {
|
||||||
case "text" -> {
|
case "text" -> {
|
||||||
if (part.getText() != null && !part.getText().isBlank()) {
|
if (part.getText() != null && !part.getText().isBlank()) {
|
||||||
client.sendText(toUserId, part.getText(), contextToken);
|
String textToSend = part.getText();
|
||||||
|
if (generatedFileScrubber != null) {
|
||||||
|
vip.mate.channel.media.GeneratedFileScrubber.ScrubResult scrubbed =
|
||||||
|
generatedFileScrubber.scrub(part.getText());
|
||||||
|
textToSend = scrubbed.rewrittenText();
|
||||||
|
deferredAttachments.addAll(scrubbed.attachments());
|
||||||
|
}
|
||||||
|
if (!textToSend.isBlank()) {
|
||||||
|
client.sendText(toUserId, textToSend, contextToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "image" -> sendImagePart(toUserId, contextToken, part);
|
case "image" -> sendImagePart(toUserId, contextToken, part);
|
||||||
@ -769,17 +796,36 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
sendFallbackText(targetId, part);
|
sendFallbackText(targetId, part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 推送文本 part 里 scrub 出来的原生附件
|
||||||
|
sendAttachmentHits(toUserId, contextToken, deferredAttachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void renderAndSend(String targetId, String content) {
|
public void renderAndSend(String targetId, String content) {
|
||||||
// 停止输入提示
|
// 停止输入提示
|
||||||
String[] split = targetId.split("\\|", 2);
|
String[] split = targetId.split("\\|", 2);
|
||||||
|
String contextToken = split.length > 0 ? split[0] : "";
|
||||||
String toUserId = split.length > 1 ? split[1] : "";
|
String toUserId = split.length > 1 ? split[1] : "";
|
||||||
if (!toUserId.isBlank()) {
|
if (!toUserId.isBlank()) {
|
||||||
stopTyping(toUserId);
|
stopTyping(toUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 扫描 /api/v1/files/generated/{id} URL → 替换为 "📎 filename" 标记 + 收集附件字节
|
||||||
|
// 缺失此步会让 LLM 回复里的 URL 作为纯文本发到微信,用户无法点击下载
|
||||||
|
List<vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit> attachments = List.of();
|
||||||
|
String rewrittenContent = content;
|
||||||
|
if (generatedFileScrubber != null && content != null && !content.isBlank()) {
|
||||||
|
vip.mate.channel.media.GeneratedFileScrubber.ScrubResult scrubbed =
|
||||||
|
generatedFileScrubber.scrub(content);
|
||||||
|
rewrittenContent = scrubbed.rewrittenText();
|
||||||
|
attachments = scrubbed.attachments();
|
||||||
|
if (!attachments.isEmpty()) {
|
||||||
|
log.info("[weixin] renderAndSend: scrubbed {} attachment(s) from content",
|
||||||
|
attachments.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 调用父类默认渲染逻辑
|
// 调用父类默认渲染逻辑
|
||||||
boolean filterThinking = getConfigBoolean("filter_thinking", true);
|
boolean filterThinking = getConfigBoolean("filter_thinking", true);
|
||||||
boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true);
|
boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true);
|
||||||
@ -787,10 +833,57 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 2048);
|
int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 2048);
|
||||||
|
|
||||||
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
|
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
|
||||||
content, filterThinking, filterToolMessages, format, maxLen);
|
rewrittenContent, filterThinking, filterToolMessages, format, maxLen);
|
||||||
for (String segment : segments) {
|
for (String segment : segments) {
|
||||||
sendMessage(targetId, segment);
|
sendMessage(targetId, segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文本发完后,把缓存里的字节作为原生附件推送(与 WeCom/Feishu 行为对齐)
|
||||||
|
sendAttachmentHits(toUserId, contextToken, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 {@link GeneratedFileScrubber} 抓取到的附件字节,通过 iLink 原生
|
||||||
|
* file/image 通道发送给微信用户。图片走 {@link ILinkClient#sendImage},
|
||||||
|
* 其他类型走 {@link ILinkClient#sendFile}(保留原始 fileName)。
|
||||||
|
* 单个附件失败不阻断后续附件,仅记录 error 日志。
|
||||||
|
*/
|
||||||
|
private void sendAttachmentHits(String toUserId, String contextToken,
|
||||||
|
List<vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit> attachments) {
|
||||||
|
if (attachments == null || attachments.isEmpty() || client == null
|
||||||
|
|| toUserId == null || toUserId.isBlank()
|
||||||
|
|| contextToken == null || contextToken.isBlank()) {
|
||||||
|
log.info("[weixin] sendAttachmentHits skipped: attachments={}, clientReady={}, toUserPresent={}, contextTokenPresent={}",
|
||||||
|
attachments == null ? 0 : attachments.size(), client != null,
|
||||||
|
toUserId != null && !toUserId.isBlank(),
|
||||||
|
contextToken != null && !contextToken.isBlank());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("[weixin] sendAttachmentHits begin: count={}, toUser={}, contextTokenPresent={}",
|
||||||
|
attachments.size(), toUserId.substring(0, Math.min(12, toUserId.length())),
|
||||||
|
!contextToken.isBlank());
|
||||||
|
for (vip.mate.channel.media.GeneratedFileScrubber.AttachmentHit hit : attachments) {
|
||||||
|
try {
|
||||||
|
log.info("[weixin] sendAttachmentHit: mediaType={}, fileName={}, mimeType={}, bytes={}",
|
||||||
|
hit.mediaType(), hit.fileName(), hit.mimeType(),
|
||||||
|
hit.bytes() == null ? 0 : hit.bytes().length);
|
||||||
|
if ("image".equals(hit.mediaType())) {
|
||||||
|
client.sendImage(toUserId, hit.bytes(), contextToken);
|
||||||
|
log.info("[weixin] Generated image sent to {}: {} ({}bytes)",
|
||||||
|
toUserId.substring(0, Math.min(12, toUserId.length())),
|
||||||
|
hit.fileName(), hit.bytes().length);
|
||||||
|
} else {
|
||||||
|
client.sendFile(toUserId, hit.bytes(), hit.fileName(), contextToken);
|
||||||
|
log.info("[weixin] Generated file sent to {}: {} ({}bytes)",
|
||||||
|
toUserId.substring(0, Math.min(12, toUserId.length())),
|
||||||
|
hit.fileName(), hit.bytes().length);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[weixin] Failed to send generated attachment {} to {}: {}",
|
||||||
|
hit.fileName(), toUserId.substring(0, Math.min(12, toUserId.length())),
|
||||||
|
e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 媒体上传发送 ====================
|
// ==================== 媒体上传发送 ====================
|
||||||
|
|||||||
@ -0,0 +1,120 @@
|
|||||||
|
package vip.mate.channel.weixin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ILinkClientUploadUrlTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private HttpServer server;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (server != null) {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUploadUrlRequestsNoThumbnailForFileUploads() throws Exception {
|
||||||
|
AtomicReference<String> requestBody = new AtomicReference<>();
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/ilink/bot/getuploadurl", exchange -> handleUploadUrl(exchange, requestBody));
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
ObjectMapper appMapper = new ObjectMapper();
|
||||||
|
appMapper.getFactory().configure(JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS, true);
|
||||||
|
ILinkClient client = new ILinkClient("token", "http://127.0.0.1:" + server.getAddress().getPort(), appMapper);
|
||||||
|
|
||||||
|
client.getUploadUrl("file-key", 3, "user-1", 123, "md5", 144, "00112233445566778899aabbccddeeff");
|
||||||
|
|
||||||
|
JsonNode body = objectMapper.readTree(requestBody.get());
|
||||||
|
assertThat(body.get("media_type").asInt()).isEqualTo(3);
|
||||||
|
assertThat(body.get("rawsize").isNumber()).isTrue();
|
||||||
|
assertThat(body.get("filesize").isNumber()).isTrue();
|
||||||
|
assertThat(body.get("no_need_thumb").asBoolean()).isTrue();
|
||||||
|
assertThat(body.at("/base_info/channel_version").asText()).isEqualTo("1.0.2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUploadUrlThrowsBusinessErrorBeforeCheckingUploadParam() throws Exception {
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/ilink/bot/getuploadurl", exchange -> {
|
||||||
|
byte[] response = "{\"ret\":-2}".getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, response.length);
|
||||||
|
exchange.getResponseBody().write(response);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
ILinkClient client = new ILinkClient("token", "http://127.0.0.1:" + server.getAddress().getPort(), objectMapper);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> client.getUploadUrl("file-key", 3, "user-1", 123, "md5", 144,
|
||||||
|
"00112233445566778899aabbccddeeff"))
|
||||||
|
.isInstanceOf(RuntimeException.class)
|
||||||
|
.hasMessageContaining("getUploadUrl business error: ret=-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendMessageUsesWireJsonAndRejectsBusinessErrors() throws Exception {
|
||||||
|
AtomicReference<String> requestBody = new AtomicReference<>();
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
server.createContext("/ilink/bot/sendmessage", exchange -> {
|
||||||
|
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||||
|
byte[] response = "{\"ret\":-7}".getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, response.length);
|
||||||
|
exchange.getResponseBody().write(response);
|
||||||
|
exchange.close();
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
ObjectMapper appMapper = new ObjectMapper();
|
||||||
|
appMapper.getFactory().configure(JsonGenerator.Feature.WRITE_NUMBERS_AS_STRINGS, true);
|
||||||
|
ILinkClient client = new ILinkClient("token", "http://127.0.0.1:" + server.getAddress().getPort(), appMapper);
|
||||||
|
Map<String, Object> fileItem = Map.of(
|
||||||
|
"type", 4,
|
||||||
|
"file_item", Map.of(
|
||||||
|
"file_name", "report.pptx",
|
||||||
|
"md5", "abc",
|
||||||
|
"len", "123",
|
||||||
|
"media", Map.of("encrypt_query_param", "encrypted", "aes_key", "aes", "encrypt_type", 1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("to_user_id", "user-1");
|
||||||
|
msg.put("item_list", List.of(fileItem));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> client.sendMessage(msg))
|
||||||
|
.isInstanceOf(RuntimeException.class)
|
||||||
|
.hasMessageContaining("sendMessage business error: ret=-7");
|
||||||
|
|
||||||
|
JsonNode body = objectMapper.readTree(requestBody.get());
|
||||||
|
assertThat(body.at("/msg/item_list/0/file_item/len").isTextual()).isTrue();
|
||||||
|
assertThat(body.at("/msg/item_list/0/file_item/md5").asText()).isEqualTo("abc");
|
||||||
|
assertThat(body.at("/msg/item_list/0/file_item/media/encrypt_type").asInt()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleUploadUrl(HttpExchange exchange, AtomicReference<String> requestBody) throws IOException {
|
||||||
|
requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||||
|
byte[] response = "{\"ret\":0,\"upload_param\":\"encrypted\"}".getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.sendResponseHeaders(200, response.length);
|
||||||
|
exchange.getResponseBody().write(response);
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user