mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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;
|
||||
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;
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为私聊消息
|
||||
*/
|
||||
|
||||
@ -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 是 replyToken(userId 或 chatId)
|
||||
// 消费回复上下文(如果有的话)
|
||||
WeComReplyContext ctx = replyContexts.remove(targetId);
|
||||
|
||||
// 先进行正常的内容渲染(过滤 thinking、分割长文本)
|
||||
boolean filterThinking = getConfigBoolean("filter_thinking", true);
|
||||
@ -708,30 +733,160 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
|
||||
List<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, "");
|
||||
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, "");
|
||||
}
|
||||
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 目标 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 {
|
||||
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) {}
|
||||
|
||||
@ -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.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 原始文件 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 码登录结果
|
||||
*/
|
||||
|
||||
@ -81,6 +81,29 @@ public final class WeixinAesUtil {
|
||||
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) {
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
|
||||
@ -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 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
|
||||
@ -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() : "";
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user