diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index dc3bd96d..5d30c771 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 8f68c183..1402e1ec 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java index 8ae53315..be3a6e1e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/ILinkClient.java @@ -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 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 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 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 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 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 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 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 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 body) { + try { + Map 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 ""; + } + } + + private static String redactSendMessagePayload(Map body) { + try { + Map redacted = new LinkedHashMap<>(body); + Object msgObj = redacted.get("msg"); + if (msgObj instanceof Map msgMap) { + Map 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 ""; + } + } + + private static Object redactItems(Object itemListObj) { + if (!(itemListObj instanceof List items)) { + return itemListObj; + } + List redacted = new ArrayList<>(); + for (Object itemObj : items) { + if (!(itemObj instanceof Map itemMap)) { + redacted.add(itemObj); + continue; + } + Map 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 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 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 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 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 码登录结果 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index d7050524..1f53b2d3 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -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 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 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 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 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); + } + } } // ==================== 媒体上传发送 ==================== diff --git a/mateclaw-server/src/test/java/vip/mate/channel/weixin/ILinkClientUploadUrlTest.java b/mateclaw-server/src/test/java/vip/mate/channel/weixin/ILinkClientUploadUrlTest.java new file mode 100644 index 00000000..6c9e0394 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/weixin/ILinkClientUploadUrlTest.java @@ -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 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 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 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 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 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(); + } +}