diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/InboundMediaDownloader.java b/mateclaw-server/src/main/java/vip/mate/channel/media/InboundMediaDownloader.java new file mode 100644 index 00000000..5e09be6d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/InboundMediaDownloader.java @@ -0,0 +1,286 @@ +package vip.mate.channel.media; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import vip.mate.channel.ExponentialBackoff; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Optional; +import java.util.function.Function; + +/** + * Shared inbound-media pipeline for IM channels. + * + *
Every channel that receives images / files / audio / video from users + * needs the same three steps after it knows how to fetch the raw bytes: + *
The channel-specific protocol (AES decryption, API auth, CDN URL shape)
+ * stays in the adapter and is supplied as a {@link ByteSource}. This class owns
+ * only the cross-channel concerns above.
+ */
+public final class InboundMediaDownloader {
+
+ private static final Logger log = LoggerFactory.getLogger(InboundMediaDownloader.class);
+
+ private InboundMediaDownloader() {
+ }
+
+ /**
+ * Fetches the raw (already-decrypted) bytes for a piece of media. May throw;
+ * the downloader retries a throwing source before giving up.
+ */
+ @FunctionalInterface
+ public interface ByteSource {
+ byte[] fetch() throws Exception;
+ }
+
+ /** A successfully downloaded and typed file on local disk. */
+ public record DownloadedMedia(
+ Path localPath,
+ String storedName,
+ String fileName,
+ String contentType,
+ long fileSize,
+ String fileUrl) {
+
+ public boolean isImage() {
+ return contentType != null && contentType.startsWith("image/");
+ }
+
+ public boolean isVideo() {
+ return contentType != null && contentType.startsWith("video/");
+ }
+
+ public boolean isAudio() {
+ return contentType != null && contentType.startsWith("audio/");
+ }
+ }
+
+ /** Total fetch attempts (1 initial + retries) before giving up. */
+ private static final int DEFAULT_MAX_ATTEMPTS = 3;
+ private static final long RETRY_INITIAL_DELAY_MS = 300;
+ private static final long RETRY_MAX_DELAY_MS = 3000;
+
+ /**
+ * Download with the default retry policy and no servable URL. See
+ * {@link #download(ByteSource, String, Path, String, String, int, Function)}.
+ */
+ public static Optional IM channels frequently deliver media without a reliable filename or
+ * Content-Type: forwarded files arrive nameless, and personal-WeChat images
+ * are saved with a fixed {@code image.jpg} hint regardless of the real format.
+ * Labelling a PNG / WEBP / HEIC photo as {@code image/jpeg} makes some
+ * multimodal model gateways reject the request, and a nameless PDF saved as
+ * {@code file.bin} stops PDF tools from firing. Sniffing the magic bytes
+ * recovers an accurate type so downstream routing and vision models work.
+ *
+ * Covers the formats users routinely send to bots: common raster images
+ * (incl. HEIC from iPhones and WEBP from screenshots), documents, archives,
+ * and audio / video containers. ZIP-based containers (DOCX/XLSX/PPTX/ODF/EPUB/
+ * JAR) share one magic number, so a successful ZIP match is refined by peeking
+ * at the first archive entries.
+ */
+public final class MediaTypeSniffer {
+
+ private MediaTypeSniffer() {
+ }
+
+ /** Sniff result: a leading-dot extension plus the matching MIME type. */
+ public record Sniffed(String extension, String contentType) {
+ /** Fallback when no signature matches. */
+ public static final Sniffed UNKNOWN = new Sniffed(".bin", "application/octet-stream");
+
+ public boolean isKnown() {
+ return !UNKNOWN.equals(this);
+ }
+
+ public boolean isImage() {
+ return contentType.startsWith("image/");
+ }
+
+ public boolean isVideo() {
+ return contentType.startsWith("video/");
+ }
+
+ public boolean isAudio() {
+ return contentType.startsWith("audio/");
+ }
+ }
+
+ /**
+ * Detect the type of {@code data} from its leading bytes.
+ *
+ * @param data the full file bytes (may be null/empty — returns
+ * {@link Sniffed#UNKNOWN}); only the first bytes are inspected,
+ * except for ZIP containers which are scanned a little deeper.
+ * @return the detected type, never null.
+ */
+ public static Sniffed sniff(byte[] data) {
+ if (data == null || data.length < 4) {
+ return Sniffed.UNKNOWN;
+ }
+
+ Sniffed basic = sniffHead(data);
+ // ZIP container needs a deeper look — DOCX/XLSX/PPTX/ODF/EPUB/JAR all
+ // share the PK\x03\x04 magic. Peek inside the first few entries.
+ if (".zip".equals(basic.extension())) {
+ return refineZipKind(data, basic);
+ }
+ return basic;
+ }
+
+ /** Signature match against the leading bytes only. */
+ private static Sniffed sniffHead(byte[] h) {
+ // PDF: %PDF
+ if (match(h, 0x25, 0x50, 0x44, 0x46)) {
+ return new Sniffed(".pdf", "application/pdf");
+ }
+ // PNG: 89 50 4E 47
+ if (match(h, 0x89, 0x50, 0x4E, 0x47)) {
+ return new Sniffed(".png", "image/png");
+ }
+ // JPEG: FF D8 FF
+ if (match(h, 0xFF, 0xD8, 0xFF)) {
+ return new Sniffed(".jpg", "image/jpeg");
+ }
+ // GIF: "GIF8"
+ if (match(h, 0x47, 0x49, 0x46, 0x38)) {
+ return new Sniffed(".gif", "image/gif");
+ }
+ // BMP: "BM"
+ if (match(h, 0x42, 0x4D)) {
+ return new Sniffed(".bmp", "image/bmp");
+ }
+ // TIFF: little-endian "II*\0" or big-endian "MM\0*"
+ if (match(h, 0x49, 0x49, 0x2A, 0x00) || match(h, 0x4D, 0x4D, 0x00, 0x2A)) {
+ return new Sniffed(".tiff", "image/tiff");
+ }
+ // RIFF container: bytes 0..3 = "RIFF", bytes 8..11 identify the payload.
+ // WEBP is the one users send (screenshots / phone photos); WAV is audio.
+ if (h.length >= 12 && match(h, 0x52, 0x49, 0x46, 0x46)) {
+ if (matchAt(h, 8, 0x57, 0x45, 0x42, 0x50)) { // "WEBP"
+ return new Sniffed(".webp", "image/webp");
+ }
+ if (matchAt(h, 8, 0x57, 0x41, 0x56, 0x45)) { // "WAVE"
+ return new Sniffed(".wav", "audio/wav");
+ }
+ }
+ // ISO Base Media (ftyp at bytes 4..7). The brand at bytes 8..11
+ // distinguishes HEIC photos / M4A audio / QuickTime from plain MP4 —
+ // critical because iPhone photos are HEIC, not video.
+ if (h.length >= 12 && matchAt(h, 4, 0x66, 0x74, 0x79, 0x70)) {
+ return classifyFtyp(brandAt(h, 8));
+ }
+ // ZIP-based container: PK\x03\x04 (refined by the caller).
+ if (match(h, 0x50, 0x4B, 0x03, 0x04)) {
+ return new Sniffed(".zip", "application/zip");
+ }
+ // Legacy Office (DOC/XLS/PPT): D0 CF 11 E0 A1 B1 1A E1
+ if (h.length >= 8 && match(h, 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1)) {
+ return new Sniffed(".doc", "application/msword");
+ }
+ // RTF: "{\rtf"
+ if (h.length >= 5 && match(h, 0x7B, 0x5C, 0x72, 0x74, 0x66)) {
+ return new Sniffed(".rtf", "application/rtf");
+ }
+ // 7z: 37 7A BC AF 27 1C
+ if (h.length >= 6 && match(h, 0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C)) {
+ return new Sniffed(".7z", "application/x-7z-compressed");
+ }
+ // RAR: "Rar!\x1A\x07"
+ if (h.length >= 6 && match(h, 0x52, 0x61, 0x72, 0x21, 0x1A, 0x07)) {
+ return new Sniffed(".rar", "application/x-rar-compressed");
+ }
+ // MP3: ID3v2 tag "ID3"
+ if (match(h, 0x49, 0x44, 0x33)) {
+ return new Sniffed(".mp3", "audio/mpeg");
+ }
+ // MP3: MPEG audio frame sync (0xFFFB / 0xFFF3 / 0xFFF2)
+ if ((h[0] & 0xFF) == 0xFF && (h[1] & 0xE0) == 0xE0) {
+ return new Sniffed(".mp3", "audio/mpeg");
+ }
+ // OGG: "OggS"
+ if (match(h, 0x4F, 0x67, 0x67, 0x53)) {
+ return new Sniffed(".ogg", "audio/ogg");
+ }
+ // AMR (WeChat / WeCom voice): "#!AMR"
+ if (h.length >= 5 && match(h, 0x23, 0x21, 0x41, 0x4D, 0x52)) {
+ return new Sniffed(".amr", "audio/amr");
+ }
+ // SILK (WeChat voice): "#!SILK"
+ if (h.length >= 6 && match(h, 0x23, 0x21, 0x53, 0x49, 0x4C, 0x4B)) {
+ return new Sniffed(".silk", "audio/silk");
+ }
+ return Sniffed.UNKNOWN;
+ }
+
+ /** Map an ISO-BMFF major brand to a concrete type. */
+ private static Sniffed classifyFtyp(String brand) {
+ if (brand == null) {
+ return new Sniffed(".mp4", "video/mp4");
+ }
+ // HEIF / HEIC still images (iPhone camera default).
+ switch (brand) {
+ case "heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs",
+ "mif1", "msf1" -> {
+ return new Sniffed(".heic", "image/heic");
+ }
+ case "avif", "avis" -> {
+ return new Sniffed(".avif", "image/avif");
+ }
+ case "qt " -> {
+ return new Sniffed(".mov", "video/quicktime");
+ }
+ case "M4A ", "M4B " -> {
+ return new Sniffed(".m4a", "audio/mp4");
+ }
+ case "M4V " -> {
+ return new Sniffed(".m4v", "video/x-m4v");
+ }
+ default -> {
+ return new Sniffed(".mp4", "video/mp4");
+ }
+ }
+ }
+
+ /**
+ * Peek inside a ZIP container to distinguish OOXML (DOCX/XLSX/PPTX), ODF
+ * (ODT/ODS/ODP), JAR, and EPUB from a plain ZIP. Reads local file headers
+ * in order; the discriminator entry is almost always within the first few
+ * entries, so iteration is capped at 16 to bound CPU. Returns the supplied
+ * {@code zipDefault} when nothing specific is detected.
+ */
+ private static Sniffed refineZipKind(byte[] data, Sniffed zipDefault) {
+ if (data == null || data.length < 30) {
+ return zipDefault;
+ }
+ String mimetypeContent = null;
+ try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(data))) {
+ ZipEntry entry;
+ int seen = 0;
+ while ((entry = zis.getNextEntry()) != null && seen < 16) {
+ seen++;
+ String name = entry.getName();
+ if (name.startsWith("word/")) {
+ return new Sniffed(".docx",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+ }
+ if (name.startsWith("xl/")) {
+ return new Sniffed(".xlsx",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ }
+ if (name.startsWith("ppt/")) {
+ return new Sniffed(".pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation");
+ }
+ if (name.startsWith("visio/")) {
+ return new Sniffed(".vsdx", "application/vnd.ms-visio.drawing");
+ }
+ if ("META-INF/MANIFEST.MF".equals(name)) {
+ return new Sniffed(".jar", "application/java-archive");
+ }
+ // EPUB always carries META-INF/container.xml.
+ if ("META-INF/container.xml".equals(name)) {
+ return new Sniffed(".epub", "application/epub+zip");
+ }
+ // ODF / EPUB also declare the type in a leading "mimetype" entry.
+ if ("mimetype".equals(name)) {
+ byte[] body = zis.readAllBytes();
+ mimetypeContent = new String(body, StandardCharsets.UTF_8).trim();
+ }
+ }
+ } catch (Exception e) {
+ return zipDefault;
+ }
+ // Match leniently (contains) — the mimetype body occasionally carries a
+ // trailing newline or charset noise.
+ if (mimetypeContent != null) {
+ if (mimetypeContent.contains("opendocument.text")) {
+ return new Sniffed(".odt", "application/vnd.oasis.opendocument.text");
+ }
+ if (mimetypeContent.contains("opendocument.spreadsheet")) {
+ return new Sniffed(".ods", "application/vnd.oasis.opendocument.spreadsheet");
+ }
+ if (mimetypeContent.contains("opendocument.presentation")) {
+ return new Sniffed(".odp", "application/vnd.oasis.opendocument.presentation");
+ }
+ if (mimetypeContent.contains("epub")) {
+ return new Sniffed(".epub", "application/epub+zip");
+ }
+ }
+ return zipDefault;
+ }
+
+ /** True when the leading bytes equal the given unsigned-byte signature. */
+ private static boolean match(byte[] data, int... signature) {
+ return matchAt(data, 0, signature);
+ }
+
+ /** True when bytes starting at {@code offset} equal the signature. */
+ private static boolean matchAt(byte[] data, int offset, int... signature) {
+ if (data.length < offset + signature.length) {
+ return false;
+ }
+ for (int i = 0; i < signature.length; i++) {
+ if ((data[offset + i] & 0xFF) != (signature[i] & 0xFF)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Read a 4-character ASCII brand at the given offset, or null. */
+ private static String brandAt(byte[] data, int offset) {
+ if (data.length < offset + 4) {
+ return null;
+ }
+ return new String(data, offset, 4, StandardCharsets.US_ASCII);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
index bc7f6f36..5243857c 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComChannelAdapter.java
@@ -6,13 +6,13 @@ import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
+import vip.mate.channel.media.InboundMediaDownloader;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
-import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
@@ -27,8 +27,6 @@ import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -2847,13 +2845,15 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
private MessageContentPart buildInboundImagePart(String url, String aesKey, String msgId,
String fileNameHint, String conversationId) {
if (getConfigBoolean("media_download_enabled", true)) {
- InboundMediaResult r = downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
+ InboundMediaDownloader.DownloadedMedia r =
+ downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
if (r != null) {
+ String localPath = r.localPath().toString();
MessageContentPart part = new MessageContentPart();
part.setType("image");
part.setFileName(r.fileName());
part.setStoredName(r.storedName());
- part.setPath(r.localPath());
+ part.setPath(localPath);
part.setFileUrl(r.fileUrl());
part.setFileSize(r.fileSize());
// Prefer the sniffed contentType (could be image/png) over a
@@ -2863,7 +2863,7 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
part.setContentType((ct != null && ct.startsWith("image/")) ? ct : "image/jpeg");
// mediaId mirrors path so callers that prefer it still resolve
// to the same on-disk file (matches Web upload's behaviour).
- part.setMediaId(r.localPath());
+ part.setMediaId(localPath);
return part;
}
}
@@ -2891,17 +2891,19 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
private MessageContentPart buildInboundFilePart(String url, String aesKey, String msgId,
String fileNameHint, String conversationId) {
if (getConfigBoolean("media_download_enabled", true)) {
- InboundMediaResult r = downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
+ InboundMediaDownloader.DownloadedMedia r =
+ downloadInboundMedia(url, aesKey, msgId, fileNameHint, conversationId);
if (r != null) {
+ String localPath = r.localPath().toString();
MessageContentPart part = new MessageContentPart();
part.setType("file");
part.setFileName(r.fileName());
part.setStoredName(r.storedName());
- part.setPath(r.localPath());
+ part.setPath(localPath);
part.setFileUrl(r.fileUrl());
part.setFileSize(r.fileSize());
part.setContentType(r.contentType());
- part.setMediaId(r.localPath());
+ part.setMediaId(localPath);
return part;
}
}
@@ -2916,285 +2918,43 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
}
/**
- * Inbound-media download result. Carries every field the bubble renderer
- * and the multimodal sidecar need so callers don't have to re-derive
- * storedName / fileUrl from scratch.
- *
- * @param localPath absolute filesystem path of the saved file
- * @param storedName the on-disk filename (matches the last segment of localPath)
- * @param fileUrl browser-servable URL: {@code /api/v1/chat/files/{convId}/{storedName}}
- * @param fileSize byte length after decryption
- * @param fileName human-readable display name (extension corrected by magic-byte sniff)
- * @param contentType MIME type derived from magic bytes (or {@code application/octet-stream})
+ * Download + decrypt an inbound media attachment via the shared media
+ * pipeline, stored under {@code data/chat-uploads/{conversationId}/} so the
+ * existing {@code /api/v1/chat/files/...} endpoint can serve it back to the
+ * chat bubble. Returns the persisted, type-detected file, or {@code null}
+ * on download/decrypt failure (callers fall back to URL-only).
*/
- record InboundMediaResult(String localPath, String storedName,
- String fileUrl, long fileSize, String fileName,
- String contentType) {}
-
- /** Magic-byte sniff result. */
- private record MagicSniff(String extension, String contentType) {
- static final MagicSniff UNKNOWN = new MagicSniff(".bin", "application/octet-stream");
- }
-
- /**
- * Best-effort MIME sniff from the first 12 bytes of a file. Covers the
- * formats users routinely forward to bots (PDF, Office, archives, common
- * image / audio / video). When nothing matches, returns
- * {@link MagicSniff#UNKNOWN} so the caller falls back to {@code .bin}.
- *
- * This exists because WeCom's {@code aibot_msg_callback} {@code file}
- * body sometimes omits {@code filename} entirely (forwarded files in
- * particular), and shipping the agent a part labelled {@code file.bin}
- * makes downstream tools mis-route the content. Sniffing recovers a
- * useful extension so PDF tools fire on PDFs.
- */
- private static MagicSniff sniffMagic(byte[] head) {
- if (head == null || head.length < 4) return MagicSniff.UNKNOWN;
- // PDF: %PDF
- if (head[0] == 0x25 && head[1] == 0x50 && head[2] == 0x44 && head[3] == 0x46) {
- return new MagicSniff(".pdf", "application/pdf");
- }
- // PNG: 89 50 4E 47
- if (head[0] == (byte) 0x89 && head[1] == 0x50 && head[2] == 0x4E && head[3] == 0x47) {
- return new MagicSniff(".png", "image/png");
- }
- // JPEG: FF D8 FF
- if (head[0] == (byte) 0xFF && head[1] == (byte) 0xD8 && head[2] == (byte) 0xFF) {
- return new MagicSniff(".jpg", "image/jpeg");
- }
- // GIF: "GIF8"
- if (head[0] == 0x47 && head[1] == 0x49 && head[2] == 0x46 && head[3] == 0x38) {
- return new MagicSniff(".gif", "image/gif");
- }
- // ZIP-based container: PK\x03\x04. Could be a plain ZIP, a JAR,
- // an OOXML document (DOCX/XLSX/PPTX), an ODF document (ODT/ODS/ODP),
- // or an EPUB. Magic-byte alone can't tell them apart — caller is
- // expected to follow up with refineZipKind(fullBytes) to pick a
- // specific type.
- if (head[0] == 0x50 && head[1] == 0x4B && head[2] == 0x03 && head[3] == 0x04) {
- return new MagicSniff(".zip", "application/zip");
- }
- // Legacy Office (DOC/XLS/PPT): D0 CF 11 E0 A1 B1 1A E1
- if (head.length >= 8
- && head[0] == (byte) 0xD0 && head[1] == (byte) 0xCF
- && head[2] == 0x11 && head[3] == (byte) 0xE0
- && head[4] == (byte) 0xA1 && head[5] == (byte) 0xB1
- && head[6] == 0x1A && head[7] == (byte) 0xE1) {
- return new MagicSniff(".doc", "application/msword");
- }
- // RTF: "{\rtf"
- if (head.length >= 5
- && head[0] == 0x7B && head[1] == 0x5C
- && head[2] == 0x72 && head[3] == 0x74 && head[4] == 0x66) {
- return new MagicSniff(".rtf", "application/rtf");
- }
- // 7z: 37 7A BC AF 27 1C
- if (head.length >= 6
- && head[0] == 0x37 && head[1] == 0x7A && head[2] == (byte) 0xBC
- && head[3] == (byte) 0xAF && head[4] == 0x27 && head[5] == 0x1C) {
- return new MagicSniff(".7z", "application/x-7z-compressed");
- }
- // RAR: "Rar!\x1A\x07"
- if (head.length >= 6
- && head[0] == 0x52 && head[1] == 0x61 && head[2] == 0x72
- && head[3] == 0x21 && head[4] == 0x1A && head[5] == 0x07) {
- return new MagicSniff(".rar", "application/x-rar-compressed");
- }
- // MP3: ID3v2 ("ID3") or MPEG sync 0xFFFB / 0xFFF3 / 0xFFF2
- if (head[0] == 0x49 && head[1] == 0x44 && head[2] == 0x33) {
- return new MagicSniff(".mp3", "audio/mpeg");
- }
- // MP4: "....ftyp" — bytes 4..7 == "ftyp"
- if (head.length >= 8
- && head[4] == 0x66 && head[5] == 0x74 && head[6] == 0x79 && head[7] == 0x70) {
- return new MagicSniff(".mp4", "video/mp4");
- }
- // OGG: "OggS"
- if (head[0] == 0x4F && head[1] == 0x67 && head[2] == 0x67 && head[3] == 0x53) {
- return new MagicSniff(".ogg", "audio/ogg");
- }
- return MagicSniff.UNKNOWN;
- }
-
- /**
- * Peek inside a ZIP container to distinguish OOXML (DOCX/XLSX/PPTX),
- * ODF (ODT/ODS/ODP), JAR, and EPUB from a plain ZIP. Reads the local
- * file headers in order via {@link ZipInputStream}; the discriminator
- * entry is almost always within the first few entries (OOXML places
- * {@code [Content_Types].xml} first, ODF places {@code mimetype} first),
- * so we cap iteration at 16 entries to bound CPU.
- *
- * Returns the original {@code zipDefault} sniff (plain
- * {@code application/zip}) when no specific kind is detected — that's
- * the right answer for actual ZIPs and unknown archive formats.
- */
- private static MagicSniff refineZipKind(byte[] fileData, MagicSniff zipDefault) {
- if (fileData == null || fileData.length < 30) return zipDefault;
- String mimetypeContent = null;
- try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileData))) {
- ZipEntry entry;
- int seen = 0;
- while ((entry = zis.getNextEntry()) != null && seen < 16) {
- String name = entry.getName();
- // OOXML — Office Open XML (Word/Excel/PowerPoint). Each format
- // has a distinct top-level directory; we match on prefix
- // because the entry order isn't guaranteed.
- if (name.startsWith("word/")) {
- return new MagicSniff(".docx",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
- }
- if (name.startsWith("xl/")) {
- return new MagicSniff(".xlsx",
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
- }
- if (name.startsWith("ppt/")) {
- return new MagicSniff(".pptx",
- "application/vnd.openxmlformats-officedocument.presentationml.presentation");
- }
- // Visio (rare but worth catching)
- if (name.startsWith("visio/")) {
- return new MagicSniff(".vsdx",
- "application/vnd.ms-visio.drawing");
- }
- // ODF marker: a {@code mimetype} entry that contains the full
- // application/vnd.oasis.opendocument.* string — read its body
- // and decide once we have it.
- if ("mimetype".equals(name)) {
- byte[] buf = zis.readAllBytes();
- mimetypeContent = new String(buf, java.nio.charset.StandardCharsets.UTF_8).trim();
- }
- // JAR
- if ("META-INF/MANIFEST.MF".equals(name)) {
- return new MagicSniff(".jar", "application/java-archive");
- }
- // EPUB always has META-INF/container.xml
- if ("META-INF/container.xml".equals(name)) {
- return new MagicSniff(".epub", "application/epub+zip");
- }
- seen++;
- }
- } catch (Exception e) {
- log.debug("[wecom] refineZipKind failed (treating as plain zip): {}", e.getMessage());
- return zipDefault;
- }
- if (mimetypeContent != null) {
- if (mimetypeContent.contains("opendocument.text")) {
- return new MagicSniff(".odt", "application/vnd.oasis.opendocument.text");
- }
- if (mimetypeContent.contains("opendocument.spreadsheet")) {
- return new MagicSniff(".ods", "application/vnd.oasis.opendocument.spreadsheet");
- }
- if (mimetypeContent.contains("opendocument.presentation")) {
- return new MagicSniff(".odp", "application/vnd.oasis.opendocument.presentation");
- }
- if (mimetypeContent.contains("epub")) {
- return new MagicSniff(".epub", "application/epub+zip");
- }
- }
- return zipDefault;
- }
-
- /**
- * Strip a trailing extension from a filename. {@code "image.jpg" → "image"};
- * {@code "no_ext" → "no_ext"}; {@code "" → ""}.
- */
- private static String stripExtension(String name) {
- if (name == null || name.isBlank()) return "";
- int dot = name.lastIndexOf('.');
- if (dot <= 0) return name;
- return name.substring(0, dot);
- }
-
- /**
- * Download + decrypt an inbound media attachment and stash it under
- * {@code data/chat-uploads/{conversationId}/} so the existing
- * {@code /api/v1/chat/files/...} endpoint can serve it back to the chat
- * bubble. Returns a fully-populated {@link InboundMediaResult} on success
- * or null on download/decrypt failure (callers fall back to URL-only).
- *
- * Storing under chat-uploads rather than {@code data/media} means
- * {@link MessageContentPart#getPath()} resolves to a real file for the
- * vision sidecar AND {@code fileUrl} renders as a thumbnail in the Web
- * mirror — instead of the WeCom-signed CDN URL whose 5-minute query-string
- * signature expires before the browser can fetch it.
- */
- private InboundMediaResult downloadInboundMedia(String url, String aesKey, String msgId,
+ private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId,
String fileNameHint, String conversationId) {
- try {
- // Mirror ChatController.uploadRoot ("data/chat-uploads") so the
- // serve endpoint at /api/v1/chat/files/{convId}/{storedName} works
- // without any extra wiring. The conversationId may contain ':'
- // (e.g. "wecom:XuZhanFu" or "wecom:group:abc"); Path resolution
- // tolerates this on macOS/Linux but Windows would reject the
- // colon — for now we keep parity with the existing chat-uploads
- // layout and revisit if Windows support comes up.
- Path uploadDir = Path.of("data", "chat-uploads", conversationId);
- Files.createDirectories(uploadDir);
-
- // 1. HTTP GET 下载文件
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .timeout(Duration.ofSeconds(30))
- .GET()
- .build();
-
- HttpResponse