feat(channel): add file upload support for WeChat and WeCom channels

This commit is contained in:
matevip 2026-04-06 10:51:07 +08:00
parent eb78032752
commit af40fbd7de
11 changed files with 1205 additions and 44 deletions

View File

@ -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;

View File

@ -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()));
}
/**
* 判断是否为私聊消息
*/

View File

@ -50,7 +50,7 @@ import java.util.concurrent.atomic.AtomicInteger;
* <li>bot_id: 机器人 ID</li>
* <li>secret: 机器人 Secret</li>
* <li>welcome_text: 欢迎消息可选</li>
* <li>media_download_enabled: 是否下载媒体文件默认 false</li>
* <li>media_download_enabled: 是否下载媒体文件默认 true</li>
* <li>media_dir: 媒体文件保存目录默认 data/media</li>
* </ul>
*
@ -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<String, Map<String, Object>> pendingFrames = new ConcurrentHashMap<>();
/** 媒体上传串行锁(每个适配器实例同一时间只允许一个上传) */
private final Semaphore uploadLock = new Semaphore(1);
/** 回复上下文replyToken -> (frameReqId, processingStreamId),用于 sendContentParts 回写 */
private final ConcurrentHashMap<String, WeComReplyContext> 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<String, Object> imgBody = (Map<String, Object>) 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 replyTokenuserId chatId
// 消费回复上下文如果有的话
WeComReplyContext ctx = replyContexts.remove(targetId);
// 先进行正常的内容渲染过滤 thinking分割长文本
boolean filterThinking = getConfigBoolean("filter_thinking", true);
@ -708,30 +733,160 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
List<String> 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<MessageContentPart> 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<byte[]> 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 分块上传文件到企业微信
* <p>
* 三阶段协议
* 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<String, Object> 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<String, Object> initFrame = Map.of(
"cmd", CMD_UPLOAD_INIT,
"headers", Map.of("req_id", initReqId),
"body", initBody
);
Map<String, Object> initAck = sendFrameWithAckBlocking(initReqId, initFrame, UPLOAD_ACK_TIMEOUT_MS);
Map<String, Object> initAckBody = (Map<String, Object>) 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<String, Object> chunkBody = new LinkedHashMap<>();
chunkBody.put("upload_id", uploadId);
chunkBody.put("chunk_index", i);
chunkBody.put("data", base64Data);
Map<String, Object> 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<String, Object> finishFrame = Map.of(
"cmd", CMD_UPLOAD_FINISH,
"headers", Map.of("req_id", finishReqId),
"body", Map.of("upload_id", uploadId)
);
Map<String, Object> finishAck = sendFrameWithAckBlocking(finishReqId, finishFrame, UPLOAD_ACK_TIMEOUT_MS);
Map<String, Object> finishAckBody = (Map<String, Object>) 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<String, Object> sendFrameWithAckBlocking(String reqId, Map<String, Object> frame, long timeoutMs) {
CompletableFuture<Map<String, Object>> 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 目标 IDuserId 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> frame, CompletableFuture<Map<String, Object>> future) {}

View File

@ -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;
/**
* 企业微信图片压缩工具
* <p>
* 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<ImageWriter> 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;
}
}

View File

@ -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<String, Object> getConfig(String ilinkUserId, String contextToken) throws Exception {
Map<String, Object> 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<String> 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<String, Object> sendTyping(String toUserId, String typingTicket, int status) throws Exception {
Map<String, Object> 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<String> 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 原始文件 MD532 字符 hex
* @param fileSize 加密后文件大小
* @param aesKeyHex AES key hex 编码32 字符
* @return API 响应 upload_full_url upload_param
*/
public Map<String, Object> getUploadUrl(String filekey, int mediaType, String toUserId,
long rawSize, String rawFileMd5, long fileSize,
String aesKeyHex) throws Exception {
Map<String, Object> 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<String> 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
* <p>
* 流程
* 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. 生成 filekey16 字节随机 hex
byte[] filekeyBytes = new byte[16];
random.nextBytes(filekeyBytes);
String filekey = bytesToHex(filekeyBytes);
// 4. 获取上传 URL
Map<String, Object> 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<byte[]> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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 码登录结果
*/

View File

@ -81,6 +81,29 @@ public final class WeixinAesUtil {
return decoded;
}
/**
* AES-128-ECB 加密用于上传媒体文件到 CDN
*
* @param data 明文数据
* @param keyBase64 AES keyBase64 编码的原始 16 <EFBFBD><EFBFBD>
* @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 <EFBFBD><EFBFBD><EFBFBD>
*/
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);

View File

@ -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;
* <ul>
* <li>bot_token: iLink Bot Token扫码登录获取</li>
* <li>base_url: API 基础地址默认 https://ilinkai.weixin.qq.com</li>
* <li>media_download_enabled: 是否下载媒体文件默认 false</li>
* <li>media_download_enabled: 是否下载媒体文件默认 true</li>
* <li>media_dir: 媒体文件保存目录默认 data/media</li>
* </ul>
*
@ -77,6 +83,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
/** 用户最新 context_token 缓存(用于主动推送) */
private final ConcurrentHashMap<String, String> userContextTokens = new ConcurrentHashMap<>();
// ==================== 输入中提示 ====================
/** 输入提示 ticket 缓存userId -> (ticket, expireTime) */
private final ConcurrentHashMap<String, TypingTicketEntry> typingTickets = new ConcurrentHashMap<>();
/** 输入提示刷新任务userId -> ScheduledFuture */
private final ConcurrentHashMap<String, ScheduledFuture<?>> 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<String> textParts = new ArrayList<>();
List<Map<String, Object>> itemList = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> fileItemMap = (Map<String, Object>) item.getOrDefault("file_item", Map.of());
String fileName = getStr(fileItemMap, "file_name");
if (fileName.isBlank()) fileName = "file.bin";
if (mediaDownloadEnabled) {
Map<String, Object> fileItem = (Map<String, Object>) 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<MessageContentPart> 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<String> 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<byte[]> 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 ticket24 小时 TTL
*/
private String getTypingTicket(String userId, String contextToken) {
TypingTicketEntry cached = typingTickets.get(userId);
if (cached != null && cached.isValid()) {
return cached.ticket();
}
try {
Map<String, Object> 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<String, Object> item, String itemKey) {
try {
Map<String, Object> mediaItem = (Map<String, Object>) item.getOrDefault(itemKey, Map.of());
Map<String, Object> media = (Map<String, Object>) 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<String, Object> map, String key) {
Object val = map.get(key);
return val != null ? val.toString() : "";

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",