diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 4d3d7562..58e58704 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -253,9 +253,13 @@ public abstract class BaseAgent { if (part == null) continue; String partType = part.getType(); String contentType = part.getContentType(); + // image 类型的 part 可能没有精确 contentType,补全为 image/jpeg + if ("image".equals(partType) && (contentType == null || "image/*".equals(contentType))) { + contentType = "image/jpeg"; + } if (contentType == null) continue; - boolean isImage = "file".equals(partType) && contentType.startsWith("image/"); + boolean isImage = ("image".equals(partType) || "file".equals(partType)) && contentType.startsWith("image/"); boolean isVideo = ("video".equals(partType) || "file".equals(partType)) && contentType.startsWith("video/"); if (!isImage && !isVideo) continue; diff --git a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java index 0d160849..a144e3eb 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/AbstractChannelAdapter.java @@ -227,7 +227,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { // 清理 bot 前缀 String cleaned = cleanBotPrefix(message.getContent()); - if (cleaned.isBlank()) { + if (cleaned.isBlank() && !hasContentParts(message)) { log.debug("[{}] Empty message after prefix cleaning, ignoring", getChannelType()); return; } @@ -314,6 +314,17 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter { return content.trim(); } + /** + * 检查消息是否包含非文本内容部分(图片、文件、视频等) + */ + private boolean hasContentParts(ChannelMessage message) { + if (message.getContentParts() == null || message.getContentParts().isEmpty()) { + return false; + } + return message.getContentParts().stream() + .anyMatch(p -> p != null && !"text".equals(p.getType())); + } + /** * 判断是否为私聊消息 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java index eb2d11e2..b9ddbf45 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java @@ -50,7 +50,7 @@ import java.util.concurrent.atomic.AtomicInteger; *
  • bot_id: 机器人 ID
  • *
  • secret: 机器人 Secret
  • *
  • welcome_text: 欢迎消息(可选)
  • - *
  • media_download_enabled: 是否下载媒体文件(默认 false)
  • + *
  • media_download_enabled: 是否下载媒体文件(默认 true)
  • *
  • media_dir: 媒体文件保存目录(默认 data/media)
  • * * @@ -86,6 +86,18 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { private static final String CMD_CALLBACK = "aibot_msg_callback"; private static final String CMD_EVENT_CALLBACK = "aibot_event_callback"; + // ==================== 媒体上传命令常量 ==================== + + private static final String CMD_UPLOAD_INIT = "aibot_upload_media_init"; + private static final String CMD_UPLOAD_CHUNK = "aibot_upload_media_chunk"; + private static final String CMD_UPLOAD_FINISH = "aibot_upload_media_finish"; + + /** 上传分块大小:512KB */ + private static final int UPLOAD_CHUNK_SIZE = 512 * 1024; + + /** 上传 ACK 超时:30 秒(大文件上传需要更长超时) */ + private static final long UPLOAD_ACK_TIMEOUT_MS = 30_000; + // ==================== 运行时状态 ==================== private HttpClient httpClient; @@ -123,6 +135,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { /** 记录消息中 reqId -> frame 的映射,用于 reply_stream 回复 */ private final ConcurrentHashMap> pendingFrames = new ConcurrentHashMap<>(); + /** 媒体上传串行锁(每个适配器实例同一时间只允许一个上传) */ + private final Semaphore uploadLock = new Semaphore(1); + + /** 回复上下文:replyToken -> (frameReqId, processingStreamId),用于 sendContentParts 回写 */ + private final ConcurrentHashMap replyContexts = new ConcurrentHashMap<>(); + + private record WeComReplyContext(String frameReqId, String processingStreamId) {} + public WeComChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -196,6 +216,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { replyQueues.clear(); pendingFrames.clear(); processedMessageIds.clear(); + replyContexts.clear(); this.httpClient = null; log.info("[wecom] WeCom bot channel stopped"); @@ -222,6 +243,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { pendingAcks.clear(); replyQueues.clear(); pendingFrames.clear(); + replyContexts.clear(); missedPongCount.set(0); if (this.httpClient == null) { @@ -499,7 +521,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { Map imgBody = (Map) body.getOrDefault("image", Map.of()); String url = (String) imgBody.getOrDefault("url", ""); String aesKey = (String) imgBody.getOrDefault("aeskey", ""); - if (getConfigBoolean("media_download_enabled", false) && !url.isBlank()) { + if (getConfigBoolean("media_download_enabled", true) && !url.isBlank()) { String localPath = downloadAndDecryptMedia(url, aesKey, msgId, "image.jpg"); if (localPath != null) { contentParts.add(MessageContentPart.image(localPath, url)); @@ -526,7 +548,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { String url = (String) fileBody.getOrDefault("url", ""); String aesKey = (String) fileBody.getOrDefault("aeskey", ""); String filename = (String) fileBody.getOrDefault("filename", "file.bin"); - if (getConfigBoolean("media_download_enabled", false) && !url.isBlank()) { + if (getConfigBoolean("media_download_enabled", true) && !url.isBlank()) { String localPath = downloadAndDecryptMedia(url, aesKey, msgId, filename); if (localPath != null) { contentParts.add(MessageContentPart.file(localPath, filename, null)); @@ -609,6 +631,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { )) .build(); + // 保存回复上下文,供 sendContentParts / renderAndSend 使用 + String replyToken = isGroup ? chatId : senderId; + replyContexts.put(replyToken, new WeComReplyContext(frameReqId, processingStreamId)); + log.info("[wecom] Received message: sender={}, chatType={}, msgType={}, textLen={}", senderId.length() > 20 ? senderId.substring(0, 20) : senderId, chatType, msgType, @@ -695,9 +721,8 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { */ @Override public void renderAndSend(String targetId, String content) { - // 尝试查找匹配的 pending frame(通过 target 反查) - // renderAndSend 在 ChannelMessageRouter.processMessage() 中被调用 - // 此时 targetId 是 replyToken(userId 或 chatId) + // 消费回复上下文(如果有的话) + WeComReplyContext ctx = replyContexts.remove(targetId); // 先进行正常的内容渲染(过滤 thinking、分割长文本) boolean filterThinking = getConfigBoolean("filter_thinking", true); @@ -708,30 +733,160 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { List segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel( content, filterThinking, filterToolMessages, format, maxLen); + boolean first = true; for (String segment : segments) { - sendMessage(targetId, segment); + // 第一条分段用 processingStreamId 覆盖"思考中..." + if (first && ctx != null && ctx.processingStreamId() != null + && !ctx.processingStreamId().isBlank()) { + replyStream(ctx.frameReqId(), ctx.processingStreamId(), segment, true); + first = false; + } else { + sendMessage(targetId, segment); + } } } @Override public void sendContentParts(String targetId, List parts) { + WeComReplyContext ctx = replyContexts.remove(targetId); + boolean sentText = false; + boolean firstText = true; + for (MessageContentPart part : parts) { if (part == null) continue; - switch (part.getType()) { - case "text" -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } - case "image" -> { - String imgUrl = part.getFileUrl() != null ? part.getFileUrl() : part.getMediaId(); - if (imgUrl != null) { - sendMessage(targetId, "![image](" + imgUrl + ")"); + try { + switch (part.getType()) { + case "text" -> { + if (part.getText() != null && !part.getText().isBlank()) { + // 第一条文本用 processingStreamId 覆盖"思考中..." + if (firstText && ctx != null && ctx.processingStreamId() != null + && !ctx.processingStreamId().isBlank()) { + replyStream(ctx.frameReqId(), ctx.processingStreamId(), part.getText(), true); + firstText = false; + } else { + sendMessage(targetId, part.getText()); + } + sentText = true; + } + } + case "image" -> sendImagePart(targetId, part, ctx); + case "file" -> sendFilePart(targetId, part, ctx); + default -> { + if (part.getText() != null) { + sendMessage(targetId, part.getText()); + sentText = true; + } } } - case "file" -> { - String fileName = part.getFileName() != null ? part.getFileName() : "file"; - sendMessage(targetId, "[文件: " + fileName + "]"); - } - default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } + } catch (Exception e) { + log.error("[wecom] Failed to send content part ({}): {}", part.getType(), e.getMessage()); + sendFallbackText(targetId, part); } } + + // 如果没有发送文本但有处理指示器,清除"思考中..." + if (!sentText && ctx != null && ctx.processingStreamId() != null + && !ctx.processingStreamId().isBlank()) { + try { + replyStream(ctx.frameReqId(), ctx.processingStreamId(), "✅ Done", true); + } catch (Exception e) { + log.debug("[wecom] Failed to clear processing indicator: {}", e.getMessage()); + } + } + } + + /** + * 发送图片部分:压缩 → 上传 → 发送 media_id + */ + private void sendImagePart(String targetId, MessageContentPart part, WeComReplyContext ctx) { + byte[] imageBytes = resolveFileBytes(part); + if (imageBytes == null) { + sendFallbackText(targetId, part); + return; + } + + String fileName = part.getFileName() != null ? part.getFileName() : "image.jpg"; + imageBytes = WeComImageCompressor.compressIfNeeded(imageBytes, fileName); + + String mediaId = uploadMedia(imageBytes, fileName, "image"); + if (mediaId != null) { + String frameReqId = ctx != null ? ctx.frameReqId() : null; + sendMediaMessage(targetId, mediaId, "image", frameReqId); + } else { + sendFallbackText(targetId, part); + } + } + + /** + * 发送文件部分:上传 → 发送 media_id + */ + private void sendFilePart(String targetId, MessageContentPart part, WeComReplyContext ctx) { + byte[] fileBytes = resolveFileBytes(part); + if (fileBytes == null) { + sendFallbackText(targetId, part); + return; + } + + String fileName = part.getFileName() != null ? part.getFileName() : "file.bin"; + String mediaId = uploadMedia(fileBytes, fileName, "file"); + if (mediaId != null) { + String frameReqId = ctx != null ? ctx.frameReqId() : null; + sendMediaMessage(targetId, mediaId, "file", frameReqId); + } else { + sendFallbackText(targetId, part); + } + } + + /** + * 从 MessageContentPart 解析文件字节:优先本地路径,其次 URL 下载 + */ + private byte[] resolveFileBytes(MessageContentPart part) { + // 本地路径 + if (part.getPath() != null && !part.getPath().isBlank()) { + try { + Path p = Path.of(part.getPath()); + if (Files.exists(p)) { + return Files.readAllBytes(p); + } + } catch (Exception e) { + log.debug("[wecom] Failed to read local file {}: {}", part.getPath(), e.getMessage()); + } + } + // URL 下载 + String url = part.getFileUrl(); + if (url != null && !url.isBlank() && httpClient != null) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() == 200) { + return response.body(); + } + } catch (Exception e) { + log.debug("[wecom] Failed to download file from {}: {}", url, e.getMessage()); + } + } + return null; + } + + /** + * 降级发送:上传失败时退回 Markdown 文本 + */ + private void sendFallbackText(String targetId, MessageContentPart part) { + switch (part.getType()) { + case "image" -> { + String url = part.getFileUrl() != null ? part.getFileUrl() : part.getMediaId(); + if (url != null) sendMessage(targetId, "![image](" + url + ")"); + } + case "file" -> { + String name = part.getFileName() != null ? part.getFileName() : "file"; + sendMessage(targetId, "[文件: " + name + "]"); + } + default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } + } } // ==================== reply_stream 协议实现 ==================== @@ -782,6 +937,172 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { sendFrameWithAck(reqId, frame); } + // ==================== 媒体上传协议 ==================== + + /** + * 通过 WebSocket 分块上传文件到企业微信 + *

    + * 三阶段协议: + * 1. Init: 发送文件元数据 → 获得 upload_id + * 2. Chunks: 发送 base64 编码的 512KB 分块 + * 3. Finish: 完成上传 → 获得 media_id + * + * @param fileBytes 文件内容 + * @param fileName 文件名 + * @param mediaType 媒体类型:"image" / "file" / "voice" / "video" + * @return media_id,失败返回 null + */ + @SuppressWarnings("unchecked") + private String uploadMedia(byte[] fileBytes, String fileName, String mediaType) { + if (webSocket == null || fileBytes == null || fileBytes.length == 0) { + return null; + } + boolean acquired = false; + try { + acquired = uploadLock.tryAcquire(60, TimeUnit.SECONDS); + if (!acquired) { + log.warn("[wecom] Upload lock timeout, another upload may be in progress"); + return null; + } + + String md5 = md5Hex(fileBytes); + int totalChunks = (int) Math.ceil((double) fileBytes.length / UPLOAD_CHUNK_SIZE); + + // Phase 1: Init + String initReqId = generateReqId(CMD_UPLOAD_INIT); + Map initBody = new LinkedHashMap<>(); + initBody.put("type", mediaType); + initBody.put("filename", fileName); + initBody.put("total_size", fileBytes.length); + initBody.put("total_chunks", totalChunks); + initBody.put("md5", md5); + + Map initFrame = Map.of( + "cmd", CMD_UPLOAD_INIT, + "headers", Map.of("req_id", initReqId), + "body", initBody + ); + Map initAck = sendFrameWithAckBlocking(initReqId, initFrame, UPLOAD_ACK_TIMEOUT_MS); + Map initAckBody = (Map) initAck.getOrDefault("body", Map.of()); + String uploadId = (String) initAckBody.getOrDefault("upload_id", ""); + if (uploadId.isBlank()) { + log.error("[wecom] Upload init failed: empty upload_id"); + return null; + } + log.debug("[wecom] Upload init: upload_id={}, chunks={}", uploadId.substring(0, Math.min(20, uploadId.length())), totalChunks); + + // Phase 2: Chunks + for (int i = 0; i < totalChunks; i++) { + int offset = i * UPLOAD_CHUNK_SIZE; + int length = Math.min(UPLOAD_CHUNK_SIZE, fileBytes.length - offset); + byte[] chunk = Arrays.copyOfRange(fileBytes, offset, offset + length); + String base64Data = Base64.getEncoder().encodeToString(chunk); + + String chunkReqId = generateReqId(CMD_UPLOAD_CHUNK); + Map chunkBody = new LinkedHashMap<>(); + chunkBody.put("upload_id", uploadId); + chunkBody.put("chunk_index", i); + chunkBody.put("data", base64Data); + + Map chunkFrame = Map.of( + "cmd", CMD_UPLOAD_CHUNK, + "headers", Map.of("req_id", chunkReqId), + "body", chunkBody + ); + sendFrameWithAckBlocking(chunkReqId, chunkFrame, UPLOAD_ACK_TIMEOUT_MS); + } + + // Phase 3: Finish + String finishReqId = generateReqId(CMD_UPLOAD_FINISH); + Map finishFrame = Map.of( + "cmd", CMD_UPLOAD_FINISH, + "headers", Map.of("req_id", finishReqId), + "body", Map.of("upload_id", uploadId) + ); + Map finishAck = sendFrameWithAckBlocking(finishReqId, finishFrame, UPLOAD_ACK_TIMEOUT_MS); + Map finishAckBody = (Map) finishAck.getOrDefault("body", Map.of()); + String mediaId = (String) finishAckBody.getOrDefault("media_id", ""); + if (mediaId.isBlank()) { + log.error("[wecom] Upload finish failed: empty media_id"); + return null; + } + + log.info("[wecom] Upload completed: media_id={}, type={}, size={}KB", + mediaId.substring(0, Math.min(20, mediaId.length())), mediaType, fileBytes.length / 1024); + return mediaId; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("[wecom] Upload interrupted"); + return null; + } catch (Exception e) { + log.error("[wecom] Upload failed for {}: {}", fileName, e.getMessage(), e); + return null; + } finally { + if (acquired) { + uploadLock.release(); + } + } + } + + /** + * 发送帧并阻塞等待 ACK 响应(用于上传协议需要读取返回值的场景) + * + * @return ACK 帧 Map + * @throws RuntimeException 超时或错误 + */ + private Map sendFrameWithAckBlocking(String reqId, Map frame, long timeoutMs) { + CompletableFuture> ackFuture = new CompletableFuture<>(); + pendingAcks.put(reqId, ackFuture); + sendFrame(frame); + try { + return ackFuture.get(timeoutMs, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + pendingAcks.remove(reqId); + throw new RuntimeException("Upload ACK timeout for reqId=" + reqId, e); + } catch (ExecutionException e) { + throw new RuntimeException("Upload ACK error for reqId=" + reqId, e.getCause()); + } catch (InterruptedException e) { + pendingAcks.remove(reqId); + Thread.currentThread().interrupt(); + throw new RuntimeException("Upload interrupted", e); + } + } + + /** + * 使用 media_id 发送媒体消息 + * + * @param targetId 目标 ID(userId 或 chatId) + * @param mediaId 上传后获得的 media_id + * @param mediaType 媒体类型(image / file / voice / video) + * @param frameReqId 原始消息 frameReqId(非 null 时通过 reply 路径,null 时通过主动推送) + */ + private void sendMediaMessage(String targetId, String mediaId, String mediaType, String frameReqId) { + Map mediaBody = new LinkedHashMap<>(); + mediaBody.put("msgtype", mediaType); + mediaBody.put(mediaType, Map.of("media_id", mediaId)); + + if (frameReqId != null && !frameReqId.isBlank()) { + // Reply 路径:使用 aibot_respond_msg + Map frame = Map.of( + "cmd", CMD_RESPONSE, + "headers", Map.of("req_id", frameReqId), + "body", mediaBody + ); + sendFrameWithAck(frameReqId, frame); + } else { + // 主动推送路径:使用 aibot_send_msg + mediaBody.put("chatid", targetId); + String reqId = generateReqId(CMD_SEND_MSG); + Map frame = Map.of( + "cmd", CMD_SEND_MSG, + "headers", Map.of("req_id", reqId), + "body", mediaBody + ); + sendFrameWithAck(reqId, frame); + } + } + // ==================== 帧发送基础设施 ==================== /** @@ -955,16 +1276,33 @@ public class WeComChannelAdapter extends AbstractChannelAdapter { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] hash = md.digest(input.getBytes()); - StringBuilder sb = new StringBuilder(); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); + return bytesToHex(hash); } catch (Exception e) { return Integer.toHexString(input.hashCode()); } } + /** + * MD5 哈希(字节数组输入) + */ + private String md5Hex(byte[] input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] hash = md.digest(input); + return bytesToHex(hash); + } catch (Exception e) { + return "0"; + } + } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + // ==================== 回复队列内部类 ==================== private record ReplyTask(Map frame, CompletableFuture> future) {} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComImageCompressor.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComImageCompressor.java new file mode 100644 index 00000000..583587f5 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComImageCompressor.java @@ -0,0 +1,140 @@ +package vip.mate.channel.wecom; + +import lombok.extern.slf4j.Slf4j; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Iterator; + +/** + * 企业微信图片压缩工具 + *

    + * WeCom 上传限制 2MB,安全阈值 1.9MB。 + * 策略:PNG/RGBA → JPEG RGB → 逐级降低质量 → 逐级缩放尺寸。 + */ +@Slf4j +class WeComImageCompressor { + + private static final long MAX_UPLOAD_SIZE = 1_900_000; // 1.9MB + private static final float[] QUALITY_STEPS = {0.85f, 0.70f, 0.50f, 0.30f}; + private static final double[] SCALE_STEPS = {0.75, 0.50, 0.25}; + + private WeComImageCompressor() {} + + /** + * 压缩图片以满足 WeCom 上传大小限制 + * + * @param imageBytes 原始图片字节 + * @param fileName 原始文件名(用于判断格式) + * @return 压缩后的 JPEG 字节(如已小于阈值则返回原始数据) + */ + static byte[] compressIfNeeded(byte[] imageBytes, String fileName) { + if (imageBytes == null || imageBytes.length == 0) { + return imageBytes; + } + if (imageBytes.length <= MAX_UPLOAD_SIZE) { + return imageBytes; + } + + log.info("[wecom] compress_image: original size {}KB > limit {}KB", + imageBytes.length / 1024, MAX_UPLOAD_SIZE / 1024); + + try { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (img == null) { + log.warn("[wecom] compress_image: failed to read image, returning original"); + return imageBytes; + } + + // Convert to RGB (drop alpha for JPEG) + BufferedImage rgbImg = toRgb(img); + + // Try progressive quality reduction + for (float quality : QUALITY_STEPS) { + byte[] compressed = writeJpeg(rgbImg, quality); + if (compressed.length <= MAX_UPLOAD_SIZE) { + log.info("[wecom] compress_image: compressed to {}KB (quality={})", + compressed.length / 1024, quality); + return compressed; + } + } + + // Try resize + quality + int w = rgbImg.getWidth(); + int h = rgbImg.getHeight(); + byte[] smallest = null; + for (double scale : SCALE_STEPS) { + BufferedImage resized = resize(rgbImg, (int) (w * scale), (int) (h * scale)); + byte[] compressed = writeJpeg(resized, 0.50f); + smallest = compressed; + if (compressed.length <= MAX_UPLOAD_SIZE) { + log.info("[wecom] compress_image: resized to {}x{}, {}KB", + (int) (w * scale), (int) (h * scale), compressed.length / 1024); + return compressed; + } + } + + // Return smallest we got + log.warn("[wecom] compress_image: could not compress below limit, returning smallest ({}KB)", + smallest != null ? smallest.length / 1024 : 0); + return smallest != null ? smallest : imageBytes; + + } catch (Exception e) { + log.error("[wecom] compress_image failed, returning original: {}", e.getMessage()); + return imageBytes; + } + } + + private static BufferedImage toRgb(BufferedImage img) { + if (img.getType() == BufferedImage.TYPE_INT_RGB) { + return img; + } + BufferedImage rgb = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = rgb.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, img.getWidth(), img.getHeight()); + g.drawImage(img, 0, 0, null); + g.dispose(); + return rgb; + } + + private static byte[] writeJpeg(BufferedImage img, float quality) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IllegalStateException("No JPEG ImageWriter found"); + } + ImageWriter writer = writers.next(); + try { + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality); + + try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { + writer.setOutput(ios); + writer.write(null, new IIOImage(img, null, null), param); + } + } finally { + writer.dispose(); + } + return baos.toByteArray(); + } + + private static BufferedImage resize(BufferedImage img, int newWidth, int newHeight) { + BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = resized.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setColor(Color.WHITE); + g.fillRect(0, 0, newWidth, newHeight); + g.drawImage(img, 0, 0, newWidth, newHeight, null); + g.dispose(); + return resized; + } +} 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 ba893528..45e4be32 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 @@ -11,6 +11,10 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.SecureRandom; import java.time.Duration; import java.util.*; @@ -47,6 +51,10 @@ public class ILinkClient { private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); /** 媒体下载超时 */ private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60); + /** CDN 上传超时 */ + private static final Duration UPLOAD_TIMEOUT = Duration.ofSeconds(120); + /** 微信 CDN 基础地址 */ + private static final String CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"; @Setter private String botToken; @@ -271,8 +279,320 @@ public class ILinkClient { return data; } + // ==================== 输入中提示 API ==================== + + /** + * 获取用户配置(含 typing_ticket) + * + * @param ilinkUserId 用户 ID + * @param contextToken 上下文 token + * @return API 响应(含 typing_ticket 等字段) + */ + public Map getConfig(String ilinkUserId, String contextToken) throws Exception { + Map body = new LinkedHashMap<>(); + body.put("ilink_user_id", ilinkUserId); + body.put("context_token", contextToken); + body.put("base_info", Map.of("channel_version", CHANNEL_VERSION)); + + HttpRequest request = applyHeaders(HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ilink/bot/getconfig")) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))) + .timeout(DEFAULT_TIMEOUT) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("getConfig failed: HTTP " + response.statusCode()); + } + return objectMapper.readValue(response.body(), new TypeReference<>() {}); + } + + /** + * 发送输入中状态 + * + * @param toUserId 收件人 ID + * @param typingTicket 从 getConfig 获取的 ticket + * @param status 1=开始输入, 2=停止输入 + * @return API 响应 + */ + public Map sendTyping(String toUserId, String typingTicket, int status) throws Exception { + Map body = new LinkedHashMap<>(); + body.put("ilink_user_id", toUserId); + body.put("typing_ticket", typingTicket); + body.put("status", status); + body.put("base_info", Map.of("channel_version", CHANNEL_VERSION)); + + HttpRequest request = applyHeaders(HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ilink/bot/sendtyping")) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))) + .timeout(DEFAULT_TIMEOUT) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("sendTyping failed: HTTP " + response.statusCode()); + } + return objectMapper.readValue(response.body(), new TypeReference<>() {}); + } + + // ==================== 媒体上传 API ==================== + + /** + * 获取 CDN 上传 URL + * + * @param filekey 16 字节随机 hex 字符串(唯一文件 ID) + * @param mediaType 1=image, 2=video, 3=file, 4=voice + * @param toUserId 收件人 ID + * @param rawSize 原始文件大小 + * @param rawFileMd5 原始文件 MD5(32 字符 hex) + * @param fileSize 加密后文件大小 + * @param aesKeyHex AES key 的 hex 编码(32 字符) + * @return API 响应(含 upload_full_url 或 upload_param) + */ + public Map getUploadUrl(String filekey, int mediaType, String toUserId, + long rawSize, String rawFileMd5, long fileSize, + String aesKeyHex) throws Exception { + Map body = new LinkedHashMap<>(); + body.put("filekey", filekey); + body.put("media_type", mediaType); + body.put("to_user_id", toUserId); + body.put("rawsize", rawSize); + body.put("rawfilemd5", rawFileMd5); + body.put("filesize", fileSize); + body.put("aeskey", aesKeyHex); + body.put("no_need_thumb", true); + body.put("base_info", Map.of("channel_version", CHANNEL_VERSION)); + + HttpRequest request = applyHeaders(HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/ilink/bot/getuploadurl")) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))) + .timeout(DEFAULT_TIMEOUT) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("getUploadUrl failed: HTTP " + response.statusCode()); + } + return objectMapper.readValue(response.body(), new TypeReference<>() {}); + } + + /** + * 上传媒体文件到微信 CDN + *

    + * 流程: + * 1. 读取文件 → 计算 MD5 + * 2. 生成 AES-128 key → 加密文件 + * 3. 调用 getUploadUrl 获取 CDN 上传地址 + * 4. POST 加密数据到 CDN → 从响应头提取 encrypt_query_param + * 5. 返回 UploadResult(含 encrypt_query_param, aes_key_b64, filesize) + * + * @param fileBytes 文件字节 + * @param fileName 文件名 + * @param mediaType 1=image, 2=video, 3=file, 4=voice + * @param toUserId 收件人 ID + * @return 上传结果,失败返回 null + */ + public UploadResult uploadMedia(byte[] fileBytes, String fileName, int mediaType, String toUserId) throws Exception { + // 1. 原始文件元数据 + long rawSize = fileBytes.length; + String rawFileMd5 = md5Hex(fileBytes); + + // 2. 生成 AES key 并加密 + SecureRandom random = new SecureRandom(); + byte[] aesKeyRawBytes = new byte[16]; + random.nextBytes(aesKeyRawBytes); + String aesKeyHex = bytesToHex(aesKeyRawBytes); + String aesKeyB64ForEncrypt = Base64.getEncoder().encodeToString(aesKeyRawBytes); + // 消息中的 aes_key: base64(hex_string) — CoPaw 的 Format B 编码 + String aesKeyB64ForMsg = Base64.getEncoder().encodeToString(aesKeyHex.getBytes(StandardCharsets.UTF_8)); + + byte[] encryptedData = WeixinAesUtil.aesEcbEncrypt(fileBytes, aesKeyB64ForEncrypt); + long encryptedSize = encryptedData.length; + + // 3. 生成 filekey(16 字节随机 hex) + byte[] filekeyBytes = new byte[16]; + random.nextBytes(filekeyBytes); + String filekey = bytesToHex(filekeyBytes); + + // 4. 获取上传 URL + Map uploadUrlResp = getUploadUrl(filekey, mediaType, toUserId, + rawSize, rawFileMd5, encryptedSize, aesKeyHex); + + String uploadUrl; + String fullUrl = (String) uploadUrlResp.get("upload_full_url"); + if (fullUrl != null && !fullUrl.isBlank()) { + uploadUrl = fullUrl; + } else { + String uploadParam = (String) uploadUrlResp.get("upload_param"); + if (uploadParam == null || uploadParam.isBlank()) { + throw new RuntimeException("uploadMedia: no upload_full_url or upload_param in response"); + } + String encParam = URLEncoder.encode(uploadParam, StandardCharsets.UTF_8); + uploadUrl = CDN_BASE_URL + "/upload?encrypted_query_param=" + encParam + "&filekey=" + filekey; + } + + // 5. POST 加密数据到 CDN(注意:使用 upload_param 时不需要 Authorization 头) + HttpRequest.Builder cdnBuilder = HttpRequest.newBuilder() + .uri(URI.create(uploadUrl)) + .POST(HttpRequest.BodyPublishers.ofByteArray(encryptedData)) + .header("Content-Type", "application/octet-stream") + .timeout(UPLOAD_TIMEOUT); + // 仅在 upload_full_url 模式下附加 auth 头 + if (fullUrl != null && !fullUrl.isBlank()) { + applyHeaders(cdnBuilder); + } + + HttpResponse cdnResponse = httpClient.send(cdnBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + if (cdnResponse.statusCode() != 200) { + throw new RuntimeException("CDN upload failed: HTTP " + cdnResponse.statusCode()); + } + + // 6. 从响应头提取 encrypt_query_param + String encryptQueryParam = cdnResponse.headers().firstValue("x-encrypted-param") + .or(() -> cdnResponse.headers().firstValue("X-Encrypted-Param")) + .orElse(""); + + if (encryptQueryParam.isBlank()) { + log.error("[weixin] CDN upload: missing encrypt_query_param in response headers. Headers: {}", + cdnResponse.headers().map()); + throw new RuntimeException("uploadMedia: empty encrypt_query_param from CDN (file would appear blank)"); + } + + log.info("[weixin] Media uploaded: type={}, size={}KB, encryptedSize={}KB", + mediaType, rawSize / 1024, encryptedSize / 1024); + + return new UploadResult(encryptQueryParam, aesKeyB64ForMsg, encryptedSize); + } + + /** + * 发送图片消息 + * + * @param toUserId 收件人 ID + * @param imageBytes 图片字节 + * @param contextToken 上下文 token + */ + public void sendImage(String toUserId, byte[] imageBytes, String contextToken) throws Exception { + UploadResult result = uploadMedia(imageBytes, "image.jpg", 1, toUserId); + + Map imageItem = new LinkedHashMap<>(); + imageItem.put("type", 2); + imageItem.put("image_item", Map.of( + "media", Map.of( + "encrypt_query_param", result.encryptQueryParam(), + "aes_key", result.aesKeyB64(), + "mid_size", result.fileSize() + ) + )); + + Map msg = new LinkedHashMap<>(); + msg.put("from_user_id", ""); + msg.put("to_user_id", toUserId); + msg.put("client_id", UUID.randomUUID().toString()); + msg.put("message_type", 2); + msg.put("message_state", 2); + msg.put("context_token", contextToken); + msg.put("item_list", List.of(imageItem)); + + log.debug("[weixin] Sending image: encryptQueryParam={}..., aesKey={}...", + result.encryptQueryParam().substring(0, Math.min(20, result.encryptQueryParam().length())), + result.aesKeyB64().substring(0, Math.min(20, result.aesKeyB64().length()))); + + sendMessage(msg); + } + + /** + * 发送文件消息 + * + * @param toUserId 收件人 ID + * @param fileBytes 文件字节 + * @param fileName 文件名 + * @param contextToken 上下文 token + */ + public void sendFile(String toUserId, byte[] fileBytes, String fileName, String contextToken) throws Exception { + 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, + "media", Map.of( + "encrypt_query_param", result.encryptQueryParam(), + "aes_key", result.aesKeyB64() + ) + )); + + Map msg = new LinkedHashMap<>(); + msg.put("from_user_id", ""); + msg.put("to_user_id", toUserId); + msg.put("client_id", UUID.randomUUID().toString()); + msg.put("message_type", 2); + msg.put("message_state", 2); + msg.put("context_token", contextToken); + msg.put("item_list", List.of(fileItem)); + + sendMessage(msg); + } + + /** + * 发送视频消息 + * + * @param toUserId 收件人 ID + * @param videoBytes 视频字节 + * @param contextToken 上下文 token + */ + public void sendVideo(String toUserId, byte[] videoBytes, String contextToken) throws Exception { + UploadResult result = uploadMedia(videoBytes, "video.mp4", 2, toUserId); + + Map videoItem = new LinkedHashMap<>(); + videoItem.put("type", 5); + videoItem.put("video_item", Map.of( + "media", Map.of( + "encrypt_query_param", result.encryptQueryParam(), + "aes_key", result.aesKeyB64() + ) + )); + + Map msg = new LinkedHashMap<>(); + msg.put("from_user_id", ""); + msg.put("to_user_id", toUserId); + msg.put("client_id", UUID.randomUUID().toString()); + msg.put("message_type", 2); + msg.put("message_state", 2); + msg.put("context_token", contextToken); + msg.put("item_list", List.of(videoItem)); + + sendMessage(msg); + } + + // ==================== 工具方法 ==================== + + private static String md5Hex(byte[] input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] hash = md.digest(input); + return bytesToHex(hash); + } catch (Exception e) { + return "0"; + } + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + // ==================== 内部模型 ==================== + /** + * 媒体上传结果 + * + * @param encryptQueryParam CDN 加密查询参数(用于 sendmessage 中的 media) + * @param aesKeyB64 AES key 的 base64(hex) 编码(用于 media.aes_key) + * @param fileSize 加密后文件大小 + */ + public record UploadResult(String encryptQueryParam, String aesKeyB64, long fileSize) {} + /** * QR 码登录结果 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinAesUtil.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinAesUtil.java index 8c2fe360..52ece28f 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinAesUtil.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinAesUtil.java @@ -81,6 +81,29 @@ public final class WeixinAesUtil { return decoded; } + /** + * AES-128-ECB 加密(用于上传媒体文件到 CDN) + * + * @param data 明文数据 + * @param keyBase64 AES key(Base64 编码的原始 16 字��) + * @return 加密后的数据(含 PKCS5 填充) + */ + public static byte[] aesEcbEncrypt(byte[] data, String keyBase64) throws Exception { + byte[] key = Base64.getDecoder().decode(keyBase64); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES")); + return cipher.doFinal(data); + } + + /** + * 生成随机 AES-128 密钥(16 字节,Base64 ���码) + */ + public static String generateAesKeyBase64() { + byte[] key = new byte[16]; + new java.security.SecureRandom().nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + private static boolean isHex(String s) { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); 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 8f3f743c..1ba10b6a 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 @@ -9,12 +9,18 @@ import vip.mate.channel.model.ChannelEntity; import vip.mate.workspace.conversation.model.MessageContentPart; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -39,7 +45,7 @@ import java.util.concurrent.atomic.AtomicBoolean; *

      *
    • bot_token: iLink Bot Token(扫码登录获取)
    • *
    • base_url: API 基础地址(默认 https://ilinkai.weixin.qq.com)
    • - *
    • media_download_enabled: 是否下载媒体文件(默认 false)
    • + *
    • media_download_enabled: 是否下载媒体文件(默认 true)
    • *
    • media_dir: 媒体文件保存目录(默认 data/media)
    • *
    * @@ -77,6 +83,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { /** 用户最新 context_token 缓存(用于主动推送) */ private final ConcurrentHashMap userContextTokens = new ConcurrentHashMap<>(); + // ==================== 输入中提示 ==================== + + /** 输入提示 ticket 缓存:userId -> (ticket, expireTime) */ + private final ConcurrentHashMap typingTickets = new ConcurrentHashMap<>(); + + /** 输入提示刷新任务:userId -> ScheduledFuture */ + private final ConcurrentHashMap> typingTasks = new ConcurrentHashMap<>(); + + /** 输入提示调度器 */ + private ScheduledExecutorService typingScheduler; + + /** Typing ticket 缓存 24 小时 */ + private static final long TYPING_TICKET_TTL_MS = 24 * 60 * 60 * 1000L; + + /** 输入提示刷新间隔 5 秒 */ + private static final long TYPING_REFRESH_INTERVAL_MS = 5_000; + + private record TypingTicketEntry(String ticket, long expireAt) { + boolean isValid() { return !ticket.isBlank() && System.currentTimeMillis() < expireAt; } + } + + // ==================== 文件上传 ==================== + + /** 用于文件 URL 下载的 HttpClient */ + private HttpClient uploadHttpClient; + public WeixinChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -100,6 +132,12 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { } client = new ILinkClient(botToken, baseUrl, objectMapper); + uploadHttpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + typingScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "weixin-typing-" + channelEntity.getId()); + t.setDaemon(true); + return t; + }); // 启动长轮询线程 stopSignal.set(false); @@ -115,6 +153,16 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { @Override protected void doStop() { stopSignal.set(true); + + // 停止所有输入提示任务 + typingTasks.values().forEach(f -> f.cancel(false)); + typingTasks.clear(); + typingTickets.clear(); + if (typingScheduler != null) { + typingScheduler.shutdownNow(); + typingScheduler = null; + } + if (pollThread != null) { pollThread.interrupt(); try { @@ -125,6 +173,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { pollThread = null; } client = null; + uploadHttpClient = null; log.info("[weixin] Channel stopped: {}", channelEntity.getName()); } @@ -217,7 +266,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { List textParts = new ArrayList<>(); List> itemList = (List>) msg.getOrDefault("item_list", List.of()); - boolean mediaDownloadEnabled = getConfigBoolean("media_download_enabled", false); + boolean mediaDownloadEnabled = getConfigBoolean("media_download_enabled", true); String mediaDir = getConfigString("media_dir", "data/media"); for (Map item : itemList) { @@ -243,10 +292,22 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { part.setContentType("image/*"); contentParts.add(part); } else { - textParts.add("[图片: 下载失败]"); + // 下载失败,尝试构建 CDN URL + String cdnUrl = buildCdnUrl(item, "image_item"); + if (cdnUrl != null) { + contentParts.add(MessageContentPart.image(cdnUrl, cdnUrl)); + } else { + textParts.add("[图片: 下载失败]"); + } } } else { - textParts.add("[图片]"); + // 未启用下载,但仍然传递 CDN URL(供多模态分析) + String cdnUrl = buildCdnUrl(item, "image_item"); + if (cdnUrl != null) { + contentParts.add(MessageContentPart.image(cdnUrl, cdnUrl)); + } else { + textParts.add("[图片]"); + } } } case 3 -> { @@ -262,10 +323,10 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { } case 4 -> { // File + Map fileItemMap = (Map) item.getOrDefault("file_item", Map.of()); + String fileName = getStr(fileItemMap, "file_name"); + if (fileName.isBlank()) fileName = "file.bin"; if (mediaDownloadEnabled) { - Map fileItem = (Map) item.getOrDefault("file_item", Map.of()); - String fileName = getStr(fileItem, "file_name"); - if (fileName.isBlank()) fileName = "file.bin"; String path = downloadMediaItem(item, "file_item", fileName, mediaDir); if (path != null) { MessageContentPart part = new MessageContentPart(); @@ -274,10 +335,10 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { part.setFileName(fileName); contentParts.add(part); } else { - textParts.add("[文件: 下载失败]"); + textParts.add("[文件: " + fileName + " 下载失败]"); } } else { - textParts.add("[文件]"); + textParts.add("[文件: " + fileName + "]"); } } case 5 -> { @@ -291,7 +352,17 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { part.setContentType("video/*"); contentParts.add(part); } else { - textParts.add("[视频: 下载失败]"); + // 尝试构建 CDN URL + String cdnUrl = buildCdnUrl(item, "video_item"); + if (cdnUrl != null) { + MessageContentPart part = new MessageContentPart(); + part.setType("video"); + part.setFileUrl(cdnUrl); + part.setContentType("video/*"); + contentParts.add(part); + } else { + textParts.add("[视频: 下载失败]"); + } } } else { textParts.add("[视频]"); @@ -340,6 +411,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { groupId.length() > 20 ? groupId.substring(0, 20) : groupId, textContent.length()); + // 启动"输入中..."提示 + startTyping(fromUserId, contextToken); + onMessage(channelMessage); } @@ -401,12 +475,243 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { return; } + // 发送前停止输入提示,发送后重新启动(模拟连续输入) + stopTyping(toUserId); client.sendText(toUserId, content, contextToken); } catch (Exception e) { log.error("[weixin] Failed to send message: {}", e.getMessage(), e); } } + @Override + public void sendContentParts(String targetId, List parts) { + if (client == null || parts == null || parts.isEmpty()) { + return; + } + + String[] split = targetId.split("\\|", 2); + String contextToken = split.length > 0 ? split[0] : ""; + String toUserId = split.length > 1 ? split[1] : ""; + + if (toUserId.isBlank() || contextToken.isBlank()) { + log.warn("[weixin] sendContentParts: missing userId or contextToken"); + return; + } + + // 停止输入提示 + stopTyping(toUserId); + + 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); + } + } + case "image" -> sendImagePart(toUserId, contextToken, part); + case "file" -> sendFilePart(toUserId, contextToken, part); + case "video" -> sendVideoPart(toUserId, contextToken, part); + default -> { + if (part.getText() != null && !part.getText().isBlank()) { + client.sendText(toUserId, part.getText(), contextToken); + } + } + } + } catch (Exception e) { + log.error("[weixin] Failed to send content part ({}): {}", part.getType(), e.getMessage()); + // 降级为文本 + sendFallbackText(targetId, part); + } + } + } + + @Override + public void renderAndSend(String targetId, String content) { + // 停止输入提示 + String[] split = targetId.split("\\|", 2); + String toUserId = split.length > 1 ? split[1] : ""; + if (!toUserId.isBlank()) { + stopTyping(toUserId); + } + + // 调用父类默认渲染逻辑 + boolean filterThinking = getConfigBoolean("filter_thinking", true); + boolean filterToolMessages = getConfigBoolean("filter_tool_messages", true); + String format = getConfigString("message_format", "auto"); + int maxLen = vip.mate.channel.ChannelMessageRenderer.PLATFORM_LIMITS.getOrDefault(getChannelType(), 2048); + + List segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel( + content, filterThinking, filterToolMessages, format, maxLen); + for (String segment : segments) { + sendMessage(targetId, segment); + } + } + + // ==================== 媒体上传发送 ==================== + + private void sendImagePart(String toUserId, String contextToken, MessageContentPart part) throws Exception { + byte[] imageBytes = resolveFileBytes(part); + if (imageBytes == null) { + sendFallbackText(contextToken + "|" + toUserId, part); + return; + } + client.sendImage(toUserId, imageBytes, contextToken); + log.info("[weixin] Image sent to {}: {}bytes", toUserId.substring(0, Math.min(12, toUserId.length())), imageBytes.length); + } + + private void sendFilePart(String toUserId, String contextToken, MessageContentPart part) throws Exception { + byte[] fileBytes = resolveFileBytes(part); + if (fileBytes == null) { + sendFallbackText(contextToken + "|" + toUserId, part); + return; + } + String fileName = part.getFileName() != null ? part.getFileName() : "file.bin"; + client.sendFile(toUserId, fileBytes, fileName, contextToken); + log.info("[weixin] File sent to {}: {} ({}bytes)", toUserId.substring(0, Math.min(12, toUserId.length())), fileName, fileBytes.length); + } + + private void sendVideoPart(String toUserId, String contextToken, MessageContentPart part) throws Exception { + byte[] videoBytes = resolveFileBytes(part); + if (videoBytes == null) { + sendFallbackText(contextToken + "|" + toUserId, part); + return; + } + client.sendVideo(toUserId, videoBytes, contextToken); + log.info("[weixin] Video sent to {}: {}bytes", toUserId.substring(0, Math.min(12, toUserId.length())), videoBytes.length); + } + + /** + * 从 MessageContentPart 解析文件字节:优先本地路径,其次 URL 下载 + */ + private byte[] resolveFileBytes(MessageContentPart part) { + if (part.getPath() != null && !part.getPath().isBlank()) { + try { + Path p = Path.of(part.getPath()); + if (Files.exists(p)) { + return Files.readAllBytes(p); + } + } catch (Exception e) { + log.debug("[weixin] Failed to read local file {}: {}", part.getPath(), e.getMessage()); + } + } + String url = part.getFileUrl(); + if (url != null && !url.isBlank() && uploadHttpClient != null) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .GET().build(); + HttpResponse resp = uploadHttpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (resp.statusCode() == 200) { + return resp.body(); + } + } catch (Exception e) { + log.debug("[weixin] Failed to download from {}: {}", url, e.getMessage()); + } + } + return null; + } + + private void sendFallbackText(String targetId, MessageContentPart part) { + switch (part.getType()) { + case "image" -> sendMessage(targetId, "[图片]"); + case "file" -> sendMessage(targetId, "[文件: " + (part.getFileName() != null ? part.getFileName() : "file") + "]"); + case "video" -> sendMessage(targetId, "[视频]"); + default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } + } + } + + // ==================== 输入中提示 ==================== + + /** + * 启动输入中提示(每 5 秒刷新一次) + */ + private void startTyping(String userId, String contextToken) { + if (client == null || userId.isBlank()) return; + + // 先停止旧的 + stopTyping(userId); + + try { + String ticket = getTypingTicket(userId, contextToken); + if (ticket == null || ticket.isBlank()) { + log.debug("[weixin] No typing ticket for user {}", userId.substring(0, Math.min(12, userId.length()))); + return; + } + + // 立即发送一次 + client.sendTyping(userId, ticket, 1); + + // 定时刷新 + if (typingScheduler != null && !typingScheduler.isShutdown()) { + ScheduledFuture future = typingScheduler.scheduleAtFixedRate(() -> { + try { + if (client != null) { + client.sendTyping(userId, ticket, 1); + } + } catch (Exception e) { + log.debug("[weixin] Typing refresh failed: {}", e.getMessage()); + } + }, TYPING_REFRESH_INTERVAL_MS, TYPING_REFRESH_INTERVAL_MS, TimeUnit.MILLISECONDS); + typingTasks.put(userId, future); + } + + log.debug("[weixin] Typing started for {}", userId.substring(0, Math.min(12, userId.length()))); + } catch (Exception e) { + log.debug("[weixin] Failed to start typing: {}", e.getMessage()); + } + } + + /** + * 停止输入中提示 + */ + private void stopTyping(String userId) { + ScheduledFuture future = typingTasks.remove(userId); + if (future != null) { + future.cancel(false); + } + + // 发送停止状态 + TypingTicketEntry entry = typingTickets.get(userId); + if (entry != null && entry.isValid() && client != null) { + try { + client.sendTyping(userId, entry.ticket(), 2); + log.debug("[weixin] Typing stopped for {}", userId.substring(0, Math.min(12, userId.length()))); + } catch (Exception e) { + log.debug("[weixin] Failed to stop typing: {}", e.getMessage()); + } + } + } + + /** + * 获取或缓存 typing ticket(24 小时 TTL) + */ + private String getTypingTicket(String userId, String contextToken) { + TypingTicketEntry cached = typingTickets.get(userId); + if (cached != null && cached.isValid()) { + return cached.ticket(); + } + + try { + Map configResp = client.getConfig(userId, contextToken); + int ret = configResp.get("ret") instanceof Number n ? n.intValue() : -1; + if (ret != 0) { + log.debug("[weixin] getConfig ret={} for typing ticket", ret); + return null; + } + String ticket = (String) configResp.getOrDefault("typing_ticket", ""); + if (!ticket.isBlank()) { + typingTickets.put(userId, new TypingTicketEntry(ticket, System.currentTimeMillis() + TYPING_TICKET_TTL_MS)); + } + return ticket; + } catch (Exception e) { + log.debug("[weixin] Failed to get typing ticket: {}", e.getMessage()); + return null; + } + } + // ==================== 主动推送 ==================== @Override @@ -468,6 +773,26 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { // ==================== 工具方法 ==================== + /** + * 从消息 item 中构建 CDN 下载 URL(不下载,仅构建 URL 供多模态分析使用) + */ + @SuppressWarnings("unchecked") + private String buildCdnUrl(Map item, String itemKey) { + try { + Map mediaItem = (Map) item.getOrDefault(itemKey, Map.of()); + Map media = (Map) mediaItem.getOrDefault("media", Map.of()); + String encryptQueryParam = getStr(media, "encrypt_query_param"); + if (encryptQueryParam.isBlank()) return null; + + String cdnBase = "https://novac2c.cdn.weixin.qq.com/c2c"; + return cdnBase + "/download?encrypted_query_param=" + + java.net.URLEncoder.encode(encryptQueryParam, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + log.debug("[weixin] Failed to build CDN URL: {}", e.getMessage()); + return null; + } + } + private static String getStr(Map map, String key) { Object val = map.get(key); return val != null ? val.toString() : ""; diff --git a/mateclaw-server/src/main/resources/db/data-en.sql b/mateclaw-server/src/main/resources/db/data-en.sql index b24c8cb5..57118bbb 100644 --- a/mateclaw-server/src/main/resources/db/data-en.sql +++ b/mateclaw-server/src/main/resources/db/data-en.sql @@ -1115,7 +1115,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ "bot_id": "", "secret": "", "welcome_text": "", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", @@ -1154,7 +1154,7 @@ KEY (id) VALUES (1000000008, 'WeChat', 'weixin', 1000000001, '', '{ "bot_token": "", "base_url": "https://ilinkai.weixin.qq.com", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", diff --git a/mateclaw-server/src/main/resources/db/data-mysql-en.sql b/mateclaw-server/src/main/resources/db/data-mysql-en.sql index a3d476eb..71649f9e 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-en.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-en.sql @@ -1113,7 +1113,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ "bot_id": "", "secret": "", "welcome_text": "", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", @@ -1152,7 +1152,7 @@ INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_j VALUES (1000000008, 'WeChat', 'weixin', 1000000001, '', '{ "bot_token": "", "base_url": "https://ilinkai.weixin.qq.com", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", diff --git a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql index 0a5d77e3..497526b7 100644 --- a/mateclaw-server/src/main/resources/db/data-mysql-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-mysql-zh.sql @@ -1115,7 +1115,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ "bot_id": "", "secret": "", "welcome_text": "", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", @@ -1154,7 +1154,7 @@ INSERT INTO mate_channel (id, name, channel_type, agent_id, bot_prefix, config_j VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{ "bot_token": "", "base_url": "https://ilinkai.weixin.qq.com", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", diff --git a/mateclaw-server/src/main/resources/db/data-zh.sql b/mateclaw-server/src/main/resources/db/data-zh.sql index fb4296b7..c2cb505b 100644 --- a/mateclaw-server/src/main/resources/db/data-zh.sql +++ b/mateclaw-server/src/main/resources/db/data-zh.sql @@ -1119,7 +1119,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{ "bot_id": "", "secret": "", "welcome_text": "", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open", @@ -1158,7 +1158,7 @@ KEY (id) VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{ "bot_token": "", "base_url": "https://ilinkai.weixin.qq.com", - "media_download_enabled": false, + "media_download_enabled": true, "media_dir": "data/media", "dm_policy": "open", "group_policy": "open",