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 分块上传文件到企业微信
+ *
+ * 三阶段协议:
+ * 1. Init: 发送文件元数据 → 获得 upload_id
+ * 2. Chunks: 发送 base64 编码的 512KB 分块
+ * 3. Finish: 完成上传 → 获得 media_id
+ *
+ * @param fileBytes 文件内容
+ * @param fileName 文件名
+ * @param mediaType 媒体类型:"image" / "file" / "voice" / "video"
+ * @return media_id,失败返回 null
+ */
+ @SuppressWarnings("unchecked")
+ private String uploadMedia(byte[] fileBytes, String fileName, String mediaType) {
+ if (webSocket == null || fileBytes == null || fileBytes.length == 0) {
+ return null;
+ }
+ boolean acquired = false;
+ try {
+ acquired = uploadLock.tryAcquire(60, TimeUnit.SECONDS);
+ if (!acquired) {
+ log.warn("[wecom] Upload lock timeout, another upload may be in progress");
+ return null;
+ }
+
+ String md5 = md5Hex(fileBytes);
+ int totalChunks = (int) Math.ceil((double) fileBytes.length / UPLOAD_CHUNK_SIZE);
+
+ // Phase 1: Init
+ String initReqId = generateReqId(CMD_UPLOAD_INIT);
+ Map initBody = new LinkedHashMap<>();
+ initBody.put("type", mediaType);
+ initBody.put("filename", fileName);
+ initBody.put("total_size", fileBytes.length);
+ initBody.put("total_chunks", totalChunks);
+ initBody.put("md5", md5);
+
+ Map initFrame = Map.of(
+ "cmd", CMD_UPLOAD_INIT,
+ "headers", Map.of("req_id", initReqId),
+ "body", initBody
+ );
+ Map initAck = sendFrameWithAckBlocking(initReqId, initFrame, UPLOAD_ACK_TIMEOUT_MS);
+ Map initAckBody = (Map) initAck.getOrDefault("body", Map.of());
+ String uploadId = (String) initAckBody.getOrDefault("upload_id", "");
+ if (uploadId.isBlank()) {
+ log.error("[wecom] Upload init failed: empty upload_id");
+ return null;
+ }
+ log.debug("[wecom] Upload init: upload_id={}, chunks={}", uploadId.substring(0, Math.min(20, uploadId.length())), totalChunks);
+
+ // Phase 2: Chunks
+ for (int i = 0; i < totalChunks; i++) {
+ int offset = i * UPLOAD_CHUNK_SIZE;
+ int length = Math.min(UPLOAD_CHUNK_SIZE, fileBytes.length - offset);
+ byte[] chunk = Arrays.copyOfRange(fileBytes, offset, offset + length);
+ String base64Data = Base64.getEncoder().encodeToString(chunk);
+
+ String chunkReqId = generateReqId(CMD_UPLOAD_CHUNK);
+ Map chunkBody = new LinkedHashMap<>();
+ chunkBody.put("upload_id", uploadId);
+ chunkBody.put("chunk_index", i);
+ chunkBody.put("data", base64Data);
+
+ Map chunkFrame = Map.of(
+ "cmd", CMD_UPLOAD_CHUNK,
+ "headers", Map.of("req_id", chunkReqId),
+ "body", chunkBody
+ );
+ sendFrameWithAckBlocking(chunkReqId, chunkFrame, UPLOAD_ACK_TIMEOUT_MS);
+ }
+
+ // Phase 3: Finish
+ String finishReqId = generateReqId(CMD_UPLOAD_FINISH);
+ Map finishFrame = Map.of(
+ "cmd", CMD_UPLOAD_FINISH,
+ "headers", Map.of("req_id", finishReqId),
+ "body", Map.of("upload_id", uploadId)
+ );
+ Map finishAck = sendFrameWithAckBlocking(finishReqId, finishFrame, UPLOAD_ACK_TIMEOUT_MS);
+ Map finishAckBody = (Map) finishAck.getOrDefault("body", Map.of());
+ String mediaId = (String) finishAckBody.getOrDefault("media_id", "");
+ if (mediaId.isBlank()) {
+ log.error("[wecom] Upload finish failed: empty media_id");
+ return null;
+ }
+
+ log.info("[wecom] Upload completed: media_id={}, type={}, size={}KB",
+ mediaId.substring(0, Math.min(20, mediaId.length())), mediaType, fileBytes.length / 1024);
+ return mediaId;
+
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.error("[wecom] Upload interrupted");
+ return null;
+ } catch (Exception e) {
+ log.error("[wecom] Upload failed for {}: {}", fileName, e.getMessage(), e);
+ return null;
+ } finally {
+ if (acquired) {
+ uploadLock.release();
+ }
+ }
+ }
+
+ /**
+ * 发送帧并阻塞等待 ACK 响应(用于上传协议需要读取返回值的场景)
+ *
+ * @return ACK 帧 Map
+ * @throws RuntimeException 超时或错误
+ */
+ private Map sendFrameWithAckBlocking(String reqId, Map frame, long timeoutMs) {
+ CompletableFuture