mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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);
|
||||
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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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. 生成 filekey(16 字节随机 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 码登录结果
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 媒体上传发送 ====================
|
||||
|
||||
@ -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