mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(channel): add file upload support for WeChat and WeCom channels
This commit is contained in:
parent
eb78032752
commit
af40fbd7de
@ -253,9 +253,13 @@ public abstract class BaseAgent {
|
|||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
String partType = part.getType();
|
String partType = part.getType();
|
||||||
String contentType = part.getContentType();
|
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;
|
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/");
|
boolean isVideo = ("video".equals(partType) || "file".equals(partType)) && contentType.startsWith("video/");
|
||||||
|
|
||||||
if (!isImage && !isVideo) continue;
|
if (!isImage && !isVideo) continue;
|
||||||
|
|||||||
@ -227,7 +227,7 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
|||||||
|
|
||||||
// 清理 bot 前缀
|
// 清理 bot 前缀
|
||||||
String cleaned = cleanBotPrefix(message.getContent());
|
String cleaned = cleanBotPrefix(message.getContent());
|
||||||
if (cleaned.isBlank()) {
|
if (cleaned.isBlank() && !hasContentParts(message)) {
|
||||||
log.debug("[{}] Empty message after prefix cleaning, ignoring", getChannelType());
|
log.debug("[{}] Empty message after prefix cleaning, ignoring", getChannelType());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -314,6 +314,17 @@ public abstract class AbstractChannelAdapter implements ChannelAdapter {
|
|||||||
return content.trim();
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为私聊消息
|
* 判断是否为私聊消息
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -50,7 +50,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
* <li>bot_id: 机器人 ID</li>
|
* <li>bot_id: 机器人 ID</li>
|
||||||
* <li>secret: 机器人 Secret</li>
|
* <li>secret: 机器人 Secret</li>
|
||||||
* <li>welcome_text: 欢迎消息(可选)</li>
|
* <li>welcome_text: 欢迎消息(可选)</li>
|
||||||
* <li>media_download_enabled: 是否下载媒体文件(默认 false)</li>
|
* <li>media_download_enabled: 是否下载媒体文件(默认 true)</li>
|
||||||
* <li>media_dir: 媒体文件保存目录(默认 data/media)</li>
|
* <li>media_dir: 媒体文件保存目录(默认 data/media)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
@ -86,6 +86,18 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
private static final String CMD_CALLBACK = "aibot_msg_callback";
|
private static final String CMD_CALLBACK = "aibot_msg_callback";
|
||||||
private static final String CMD_EVENT_CALLBACK = "aibot_event_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;
|
private HttpClient httpClient;
|
||||||
@ -123,6 +135,14 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
/** 记录消息中 reqId -> frame 的映射,用于 reply_stream 回复 */
|
/** 记录消息中 reqId -> frame 的映射,用于 reply_stream 回复 */
|
||||||
private final ConcurrentHashMap<String, Map<String, Object>> pendingFrames = new ConcurrentHashMap<>();
|
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,
|
public WeComChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@ -196,6 +216,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
replyQueues.clear();
|
replyQueues.clear();
|
||||||
pendingFrames.clear();
|
pendingFrames.clear();
|
||||||
processedMessageIds.clear();
|
processedMessageIds.clear();
|
||||||
|
replyContexts.clear();
|
||||||
|
|
||||||
this.httpClient = null;
|
this.httpClient = null;
|
||||||
log.info("[wecom] WeCom bot channel stopped");
|
log.info("[wecom] WeCom bot channel stopped");
|
||||||
@ -222,6 +243,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
pendingAcks.clear();
|
pendingAcks.clear();
|
||||||
replyQueues.clear();
|
replyQueues.clear();
|
||||||
pendingFrames.clear();
|
pendingFrames.clear();
|
||||||
|
replyContexts.clear();
|
||||||
missedPongCount.set(0);
|
missedPongCount.set(0);
|
||||||
|
|
||||||
if (this.httpClient == null) {
|
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());
|
Map<String, Object> imgBody = (Map<String, Object>) body.getOrDefault("image", Map.of());
|
||||||
String url = (String) imgBody.getOrDefault("url", "");
|
String url = (String) imgBody.getOrDefault("url", "");
|
||||||
String aesKey = (String) imgBody.getOrDefault("aeskey", "");
|
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");
|
String localPath = downloadAndDecryptMedia(url, aesKey, msgId, "image.jpg");
|
||||||
if (localPath != null) {
|
if (localPath != null) {
|
||||||
contentParts.add(MessageContentPart.image(localPath, url));
|
contentParts.add(MessageContentPart.image(localPath, url));
|
||||||
@ -526,7 +548,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
String url = (String) fileBody.getOrDefault("url", "");
|
String url = (String) fileBody.getOrDefault("url", "");
|
||||||
String aesKey = (String) fileBody.getOrDefault("aeskey", "");
|
String aesKey = (String) fileBody.getOrDefault("aeskey", "");
|
||||||
String filename = (String) fileBody.getOrDefault("filename", "file.bin");
|
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);
|
String localPath = downloadAndDecryptMedia(url, aesKey, msgId, filename);
|
||||||
if (localPath != null) {
|
if (localPath != null) {
|
||||||
contentParts.add(MessageContentPart.file(localPath, filename, null));
|
contentParts.add(MessageContentPart.file(localPath, filename, null));
|
||||||
@ -609,6 +631,10 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// 保存回复上下文,供 sendContentParts / renderAndSend 使用
|
||||||
|
String replyToken = isGroup ? chatId : senderId;
|
||||||
|
replyContexts.put(replyToken, new WeComReplyContext(frameReqId, processingStreamId));
|
||||||
|
|
||||||
log.info("[wecom] Received message: sender={}, chatType={}, msgType={}, textLen={}",
|
log.info("[wecom] Received message: sender={}, chatType={}, msgType={}, textLen={}",
|
||||||
senderId.length() > 20 ? senderId.substring(0, 20) : senderId,
|
senderId.length() > 20 ? senderId.substring(0, 20) : senderId,
|
||||||
chatType, msgType,
|
chatType, msgType,
|
||||||
@ -695,9 +721,8 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void renderAndSend(String targetId, String content) {
|
public void renderAndSend(String targetId, String content) {
|
||||||
// 尝试查找匹配的 pending frame(通过 target 反查)
|
// 消费回复上下文(如果有的话)
|
||||||
// renderAndSend 在 ChannelMessageRouter.processMessage() 中被调用
|
WeComReplyContext ctx = replyContexts.remove(targetId);
|
||||||
// 此时 targetId 是 replyToken(userId 或 chatId)
|
|
||||||
|
|
||||||
// 先进行正常的内容渲染(过滤 thinking、分割长文本)
|
// 先进行正常的内容渲染(过滤 thinking、分割长文本)
|
||||||
boolean filterThinking = getConfigBoolean("filter_thinking", true);
|
boolean filterThinking = getConfigBoolean("filter_thinking", true);
|
||||||
@ -708,30 +733,160 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
|
List<String> segments = vip.mate.channel.ChannelMessageRenderer.renderForChannel(
|
||||||
content, filterThinking, filterToolMessages, format, maxLen);
|
content, filterThinking, filterToolMessages, format, maxLen);
|
||||||
|
|
||||||
|
boolean first = true;
|
||||||
for (String segment : segments) {
|
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
|
@Override
|
||||||
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
|
public void sendContentParts(String targetId, List<MessageContentPart> parts) {
|
||||||
|
WeComReplyContext ctx = replyContexts.remove(targetId);
|
||||||
|
boolean sentText = false;
|
||||||
|
boolean firstText = true;
|
||||||
|
|
||||||
for (MessageContentPart part : parts) {
|
for (MessageContentPart part : parts) {
|
||||||
if (part == null) continue;
|
if (part == null) continue;
|
||||||
switch (part.getType()) {
|
try {
|
||||||
case "text" -> { if (part.getText() != null) sendMessage(targetId, part.getText()); }
|
switch (part.getType()) {
|
||||||
case "image" -> {
|
case "text" -> {
|
||||||
String imgUrl = part.getFileUrl() != null ? part.getFileUrl() : part.getMediaId();
|
if (part.getText() != null && !part.getText().isBlank()) {
|
||||||
if (imgUrl != null) {
|
// 第一条文本用 processingStreamId 覆盖"思考中..."
|
||||||
sendMessage(targetId, "");
|
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" -> {
|
} catch (Exception e) {
|
||||||
String fileName = part.getFileName() != null ? part.getFileName() : "file";
|
log.error("[wecom] Failed to send content part ({}): {}", part.getType(), e.getMessage());
|
||||||
sendMessage(targetId, "[文件: " + fileName + "]");
|
sendFallbackText(targetId, part);
|
||||||
}
|
|
||||||
default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果没有发送文本但有处理指示器,清除"思考中..."
|
||||||
|
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, "");
|
||||||
|
}
|
||||||
|
case "file" -> {
|
||||||
|
String name = part.getFileName() != null ? part.getFileName() : "file";
|
||||||
|
sendMessage(targetId, "[文件: " + name + "]");
|
||||||
|
}
|
||||||
|
default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== reply_stream 协议实现 ====================
|
// ==================== reply_stream 协议实现 ====================
|
||||||
@ -782,6 +937,172 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
|||||||
sendFrameWithAck(reqId, frame);
|
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 目标 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<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 {
|
try {
|
||||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||||
byte[] hash = md.digest(input.getBytes());
|
byte[] hash = md.digest(input.getBytes());
|
||||||
StringBuilder sb = new StringBuilder();
|
return bytesToHex(hash);
|
||||||
for (byte b : hash) {
|
|
||||||
sb.append(String.format("%02x", b));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return Integer.toHexString(input.hashCode());
|
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) {}
|
private record ReplyTask(Map<String, Object> frame, CompletableFuture<Map<String, Object>> future) {}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,10 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
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.time.Duration;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@ -47,6 +51,10 @@ public class ILinkClient {
|
|||||||
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15);
|
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15);
|
||||||
/** 媒体下载超时 */
|
/** 媒体下载超时 */
|
||||||
private static final Duration DOWNLOAD_TIMEOUT = Duration.ofSeconds(60);
|
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
|
@Setter
|
||||||
private String botToken;
|
private String botToken;
|
||||||
@ -271,8 +279,320 @@ public class ILinkClient {
|
|||||||
return data;
|
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 原始文件 MD5(32 字符 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. 生成 filekey(16 字节随机 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 码登录结果
|
* QR 码登录结果
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -81,6 +81,29 @@ public final class WeixinAesUtil {
|
|||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-128-ECB 加密(用于上传媒体文件到 CDN)
|
||||||
|
*
|
||||||
|
* @param data 明文数据
|
||||||
|
* @param keyBase64 AES key(Base64 编码的原始 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) {
|
private static boolean isHex(String s) {
|
||||||
for (int i = 0; i < s.length(); i++) {
|
for (int i = 0; i < s.length(); i++) {
|
||||||
char c = s.charAt(i);
|
char c = s.charAt(i);
|
||||||
|
|||||||
@ -9,12 +9,18 @@ import vip.mate.channel.model.ChannelEntity;
|
|||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||||
|
|
||||||
import java.io.IOException;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,7 +45,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>bot_token: iLink Bot Token(扫码登录获取)</li>
|
* <li>bot_token: iLink Bot Token(扫码登录获取)</li>
|
||||||
* <li>base_url: API 基础地址(默认 https://ilinkai.weixin.qq.com)</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>
|
* <li>media_dir: 媒体文件保存目录(默认 data/media)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
@ -77,6 +83,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
/** 用户最新 context_token 缓存(用于主动推送) */
|
/** 用户最新 context_token 缓存(用于主动推送) */
|
||||||
private final ConcurrentHashMap<String, String> userContextTokens = new ConcurrentHashMap<>();
|
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,
|
public WeixinChannelAdapter(ChannelEntity channelEntity,
|
||||||
ChannelMessageRouter messageRouter,
|
ChannelMessageRouter messageRouter,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@ -100,6 +132,12 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client = new ILinkClient(botToken, baseUrl, objectMapper);
|
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);
|
stopSignal.set(false);
|
||||||
@ -115,6 +153,16 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
@Override
|
@Override
|
||||||
protected void doStop() {
|
protected void doStop() {
|
||||||
stopSignal.set(true);
|
stopSignal.set(true);
|
||||||
|
|
||||||
|
// 停止所有输入提示任务
|
||||||
|
typingTasks.values().forEach(f -> f.cancel(false));
|
||||||
|
typingTasks.clear();
|
||||||
|
typingTickets.clear();
|
||||||
|
if (typingScheduler != null) {
|
||||||
|
typingScheduler.shutdownNow();
|
||||||
|
typingScheduler = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (pollThread != null) {
|
if (pollThread != null) {
|
||||||
pollThread.interrupt();
|
pollThread.interrupt();
|
||||||
try {
|
try {
|
||||||
@ -125,6 +173,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
pollThread = null;
|
pollThread = null;
|
||||||
}
|
}
|
||||||
client = null;
|
client = null;
|
||||||
|
uploadHttpClient = null;
|
||||||
log.info("[weixin] Channel stopped: {}", channelEntity.getName());
|
log.info("[weixin] Channel stopped: {}", channelEntity.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,7 +266,7 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
List<String> textParts = new ArrayList<>();
|
List<String> textParts = new ArrayList<>();
|
||||||
|
|
||||||
List<Map<String, Object>> itemList = (List<Map<String, Object>>) msg.getOrDefault("item_list", List.of());
|
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");
|
String mediaDir = getConfigString("media_dir", "data/media");
|
||||||
|
|
||||||
for (Map<String, Object> item : itemList) {
|
for (Map<String, Object> item : itemList) {
|
||||||
@ -243,10 +292,22 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
part.setContentType("image/*");
|
part.setContentType("image/*");
|
||||||
contentParts.add(part);
|
contentParts.add(part);
|
||||||
} else {
|
} else {
|
||||||
textParts.add("[图片: 下载失败]");
|
// 下载失败,尝试构建 CDN URL
|
||||||
|
String cdnUrl = buildCdnUrl(item, "image_item");
|
||||||
|
if (cdnUrl != null) {
|
||||||
|
contentParts.add(MessageContentPart.image(cdnUrl, cdnUrl));
|
||||||
|
} else {
|
||||||
|
textParts.add("[图片: 下载失败]");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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 -> {
|
case 3 -> {
|
||||||
@ -262,10 +323,10 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
}
|
}
|
||||||
case 4 -> {
|
case 4 -> {
|
||||||
// File
|
// 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) {
|
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);
|
String path = downloadMediaItem(item, "file_item", fileName, mediaDir);
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
MessageContentPart part = new MessageContentPart();
|
MessageContentPart part = new MessageContentPart();
|
||||||
@ -274,10 +335,10 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
part.setFileName(fileName);
|
part.setFileName(fileName);
|
||||||
contentParts.add(part);
|
contentParts.add(part);
|
||||||
} else {
|
} else {
|
||||||
textParts.add("[文件: 下载失败]");
|
textParts.add("[文件: " + fileName + " 下载失败]");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
textParts.add("[文件]");
|
textParts.add("[文件: " + fileName + "]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 5 -> {
|
case 5 -> {
|
||||||
@ -291,7 +352,17 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
part.setContentType("video/*");
|
part.setContentType("video/*");
|
||||||
contentParts.add(part);
|
contentParts.add(part);
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
textParts.add("[视频]");
|
textParts.add("[视频]");
|
||||||
@ -340,6 +411,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
groupId.length() > 20 ? groupId.substring(0, 20) : groupId,
|
groupId.length() > 20 ? groupId.substring(0, 20) : groupId,
|
||||||
textContent.length());
|
textContent.length());
|
||||||
|
|
||||||
|
// 启动"输入中..."提示
|
||||||
|
startTyping(fromUserId, contextToken);
|
||||||
|
|
||||||
onMessage(channelMessage);
|
onMessage(channelMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,12 +475,243 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送前停止输入提示,发送后重新启动(模拟连续输入)
|
||||||
|
stopTyping(toUserId);
|
||||||
client.sendText(toUserId, content, contextToken);
|
client.sendText(toUserId, content, contextToken);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[weixin] Failed to send message: {}", e.getMessage(), 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 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<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
|
@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) {
|
private static String getStr(Map<String, Object> map, String key) {
|
||||||
Object val = map.get(key);
|
Object val = map.get(key);
|
||||||
return val != null ? val.toString() : "";
|
return val != null ? val.toString() : "";
|
||||||
|
|||||||
@ -1115,7 +1115,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{
|
|||||||
"bot_id": "",
|
"bot_id": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"welcome_text": "",
|
"welcome_text": "",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_policy": "open",
|
"group_policy": "open",
|
||||||
@ -1154,7 +1154,7 @@ KEY (id)
|
|||||||
VALUES (1000000008, 'WeChat', 'weixin', 1000000001, '', '{
|
VALUES (1000000008, 'WeChat', 'weixin', 1000000001, '', '{
|
||||||
"bot_token": "",
|
"bot_token": "",
|
||||||
"base_url": "https://ilinkai.weixin.qq.com",
|
"base_url": "https://ilinkai.weixin.qq.com",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_policy": "open",
|
"group_policy": "open",
|
||||||
|
|||||||
@ -1113,7 +1113,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{
|
|||||||
"bot_id": "",
|
"bot_id": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"welcome_text": "",
|
"welcome_text": "",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_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, '', '{
|
VALUES (1000000008, 'WeChat', 'weixin', 1000000001, '', '{
|
||||||
"bot_token": "",
|
"bot_token": "",
|
||||||
"base_url": "https://ilinkai.weixin.qq.com",
|
"base_url": "https://ilinkai.weixin.qq.com",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_policy": "open",
|
"group_policy": "open",
|
||||||
|
|||||||
@ -1115,7 +1115,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{
|
|||||||
"bot_id": "",
|
"bot_id": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"welcome_text": "",
|
"welcome_text": "",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_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, '', '{
|
VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{
|
||||||
"bot_token": "",
|
"bot_token": "",
|
||||||
"base_url": "https://ilinkai.weixin.qq.com",
|
"base_url": "https://ilinkai.weixin.qq.com",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_policy": "open",
|
"group_policy": "open",
|
||||||
|
|||||||
@ -1119,7 +1119,7 @@ VALUES (1000000006, 'WeCom Bot', 'wecom', 1000000001, '', '{
|
|||||||
"bot_id": "",
|
"bot_id": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"welcome_text": "",
|
"welcome_text": "",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_policy": "open",
|
"group_policy": "open",
|
||||||
@ -1158,7 +1158,7 @@ KEY (id)
|
|||||||
VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{
|
VALUES (1000000008, '微信', 'weixin', 1000000001, '', '{
|
||||||
"bot_token": "",
|
"bot_token": "",
|
||||||
"base_url": "https://ilinkai.weixin.qq.com",
|
"base_url": "https://ilinkai.weixin.qq.com",
|
||||||
"media_download_enabled": false,
|
"media_download_enabled": true,
|
||||||
"media_dir": "data/media",
|
"media_dir": "data/media",
|
||||||
"dm_policy": "open",
|
"dm_policy": "open",
|
||||||
"group_policy": "open",
|
"group_policy": "open",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user