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:
jack 2026-07-20 20:34:59 +08:00 committed by GitHub
parent d10ed9dd06
commit 1e2b7bbc2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 455 additions and 15 deletions

View File

@ -1213,7 +1213,7 @@ public class ChannelManager {
generatedFileCache, chatUploadLocationResolver);
case "qq" -> new QQChannelAdapter(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 "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);

View File

@ -78,6 +78,15 @@ public class ChannelMessageRouter {
@Autowired(required = false)
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) {}
@ -829,6 +838,22 @@ public class ChannelMessageRouter {
.blockLast(Duration.ofMinutes(10));
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
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {

View File

@ -44,7 +44,8 @@ import java.util.*;
public class ILinkClient {
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 */
private static final Duration GETUPDATES_TIMEOUT = Duration.ofSeconds(45);
@ -226,14 +227,30 @@ public class ILinkClient {
body.put("msg", msg);
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()
.uri(URI.create(baseUrl + "/ilink/bot/sendmessage"))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
.POST(HttpRequest.BodyPublishers.ofString(requestJson)))
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("[weixin] sendMessage response: status={}, body={}", response.statusCode(), response.body());
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("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()
.uri(URI.create(baseUrl + "/ilink/bot/getuploadurl"))
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))))
.POST(HttpRequest.BodyPublishers.ofString(requestJson)))
.timeout(DEFAULT_TIMEOUT)
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("[weixin] getUploadUrl response: status={}, body={}",
response.statusCode(), response.body());
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. 原始文件元数据
long rawSize = fileBytes.length;
String rawFileMd5 = md5Hex(fileBytes);
log.info("[weixin] uploadMedia begin: mediaType={}, fileName={}, rawSize={}, rawMd5={}, toUser={}",
mediaType, fileName, rawSize, rawFileMd5, maskId(toUserId));
// 2. 生成 AES key 并加密
SecureRandom random = new SecureRandom();
@ -418,11 +465,16 @@ public class ILinkClient {
byte[] encryptedData = WeixinAesUtil.aesEcbEncrypt(fileBytes, aesKeyB64ForEncrypt);
long encryptedSize = encryptedData.length;
log.info("[weixin] uploadMedia encrypted: mediaType={}, rawSize={}, encryptedSize={}, paddingBytes={}, "
+ "aesKeyHexLen={}",
mediaType, rawSize, encryptedSize, encryptedSize - rawSize, aesKeyHex.length());
// 3. 生成 filekey16 字节随机 hex
byte[] filekeyBytes = new byte[16];
random.nextBytes(filekeyBytes);
String filekey = bytesToHex(filekeyBytes);
log.info("[weixin] uploadMedia filekey generated: mediaType={}, filekeyPrefix={}, filekeyLen={}",
mediaType, prefix(filekey, 8), filekey.length());
// 4. 获取上传 URL
Map<String, Object> uploadUrlResp = getUploadUrl(filekey, mediaType, toUserId,
@ -440,6 +492,13 @@ public class ILinkClient {
String encParam = URLEncoder.encode(uploadParam, StandardCharsets.UTF_8);
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
HttpRequest.Builder cdnBuilder = HttpRequest.newBuilder()
@ -453,6 +512,12 @@ public class ILinkClient {
}
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");
// 6. 从响应头提取 encrypt_query_param
@ -469,7 +534,7 @@ public class ILinkClient {
log.info("[weixin] Media uploaded: type={}, size={}KB, encryptedSize={}KB",
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
*/
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);
Map<String, Object> imageItem = new LinkedHashMap<>();
@ -488,6 +556,7 @@ public class ILinkClient {
"media", Map.of(
"encrypt_query_param", result.encryptQueryParam(),
"aes_key", result.aesKeyB64(),
"encrypt_type", 1,
"mid_size", result.fileSize()
)
));
@ -517,16 +586,21 @@ public class ILinkClient {
* @param contextToken 上下文 token
*/
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);
Map<String, Object> fileItem = new LinkedHashMap<>();
fileItem.put("type", 4);
fileItem.put("file_item", Map.of(
"file_name", fileName,
"len", (long) fileBytes.length,
"md5", result.rawFileMd5(),
"len", String.valueOf(result.rawSize()),
"media", Map.of(
"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("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);
}
@ -557,7 +635,8 @@ public class ILinkClient {
videoItem.put("video_item", Map.of(
"media", Map.of(
"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();
}
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 fileSize 加密后文件大小
*/
public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize) {}
public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize,
String rawFileMd5, long rawSize) {}
/**
* QR 码登录结果

View File

@ -155,24 +155,37 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
*/
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,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
this.generatedFileScrubber = null;
}
/**
* Full constructor used by the production factory (ChannelManager). The
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
* 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,
ChannelMessageRouter messageRouter,
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);
this.chatUploadLocationResolver = chatUploadLocationResolver;
this.generatedFileScrubber = generatedFileScrubber;
}
@Override
@ -744,13 +757,27 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
// 停止输入提示
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) {
if (part == null) continue;
try {
switch (part.getType()) {
case "text" -> {
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);
@ -769,17 +796,36 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
sendFallbackText(targetId, part);
}
}
// 推送文本 part scrub 出来的原生附件
sendAttachmentHits(toUserId, contextToken, deferredAttachments);
}
@Override
public void renderAndSend(String targetId, String content) {
// 停止输入提示
String[] split = targetId.split("\\|", 2);
String contextToken = split.length > 0 ? split[0] : "";
String toUserId = split.length > 1 ? split[1] : "";
if (!toUserId.isBlank()) {
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 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);
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
content, filterThinking, filterToolMessages, format, maxLen);
rewrittenContent, filterThinking, filterToolMessages, format, maxLen);
for (String segment : segments) {
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);
}
}
}
// ==================== 媒体上传发送 ====================

View File

@ -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();
}
}