From de98368b4e1a9634ba24eb7b50e6117397270f36 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 30 May 2026 07:30:38 +0800 Subject: [PATCH] feat(channel): shared inbound media pipeline with magic-byte typing and retry --- .../channel/media/InboundMediaDownloader.java | 286 +++++++++++++++ .../mate/channel/media/MediaTypeSniffer.java | 281 +++++++++++++++ .../channel/wecom/WeComChannelAdapter.java | 332 +++--------------- .../channel/weixin/WeixinChannelAdapter.java | 151 ++++---- .../media/InboundMediaDownloaderTest.java | 135 +++++++ .../channel/media/MediaTypeSnifferTest.java | 151 ++++++++ 6 files changed, 985 insertions(+), 351 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/InboundMediaDownloader.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaTypeSniffer.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/media/InboundMediaDownloaderTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/media/MediaTypeSnifferTest.java 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: + *

    + *
  1. fetch with retry + backoff (mobile uploads over flaky networks fail + * transiently — a single attempt drops the attachment);
  2. + *
  3. sniff the real type from magic bytes so the stored file and the + * {@code MessageContentPart} carry an accurate MIME (a screenshot saved + * as {@code image.jpg} but actually PNG/WEBP/HEIC otherwise gets a wrong + * Content-Type that some multimodal gateways reject);
  4. + *
  5. write to disk under a collision-resistant, URL-safe name.
  6. + *
+ * + *

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 download(ByteSource source, + String filenameHint, + Path targetDir, + String storedNamePrefix, + String dedupSeed) { + return download(source, filenameHint, targetDir, storedNamePrefix, dedupSeed, + DEFAULT_MAX_ATTEMPTS, null); + } + + /** + * Download with a custom attempt count and no servable URL. See + * {@link #download(ByteSource, String, Path, String, String, int, Function)}. + */ + public static Optional download(ByteSource source, + String filenameHint, + Path targetDir, + String storedNamePrefix, + String dedupSeed, + int maxAttempts) { + return download(source, filenameHint, targetDir, storedNamePrefix, dedupSeed, maxAttempts, null); + } + + /** + * Download with the default retry policy and a servable-URL builder. See + * {@link #download(ByteSource, String, Path, String, String, int, Function)}. + */ + public static Optional download(ByteSource source, + String filenameHint, + Path targetDir, + String storedNamePrefix, + String dedupSeed, + Function fileUrlBuilder) { + return download(source, filenameHint, targetDir, storedNamePrefix, dedupSeed, + DEFAULT_MAX_ATTEMPTS, fileUrlBuilder); + } + + /** + * Fetch the bytes (with retry), detect the real type, and persist the file. + * + * @param source fetches the decrypted bytes; retried on failure + * @param filenameHint the real user-supplied filename when known, else + * {@code null}/blank. A name with a meaningful + * extension is kept; a blank, extension-less, or + * {@code .bin} hint is replaced with the sniffed + * extension + * @param targetDir directory to write into (created if absent) + * @param storedNamePrefix short channel tag prefixed to the stored file + * name (e.g. {@code "weixin"}) + * @param dedupSeed stable string (e.g. the source URL / media key) + * hashed into the stored name so the same media maps + * to the same file + * @param maxAttempts total fetch attempts (>= 1) + * @param fileUrlBuilder optional mapping from the stored filename to a + * browser-servable URL (e.g. + * {@code name -> "/api/v1/chat/files/" + convId + "/" + name}); + * {@code null} leaves {@link DownloadedMedia#fileUrl()} + * null for channels with no serve path + * @return the stored file, or empty when every attempt failed + */ + public static Optional download(ByteSource source, + String filenameHint, + Path targetDir, + String storedNamePrefix, + String dedupSeed, + int maxAttempts, + Function fileUrlBuilder) { + byte[] data = fetchWithRetry(source, Math.max(1, maxAttempts), filenameHint); + if (data == null || data.length == 0) { + return Optional.empty(); + } + try { + Files.createDirectories(targetDir); + + MediaTypeSniffer.Sniffed sniff = MediaTypeSniffer.sniff(data); + + // Derive a display name. The hint is authoritative only when the + // caller passed a real user-supplied filename with a meaningful + // extension; for media the caller passes null/blank and we + // synthesize a name from the sniffed type. ".bin" is treated as + // "no real extension" since it is the universal unknown-binary + // placeholder. This keeps the contract channel-agnostic — no + // per-channel sentinel names leak into this shared layer. + String safeName = sanitize(filenameHint); + boolean hintIsGeneric = "media".equals(safeName) + || !safeName.contains(".") + || safeName.toLowerCase().endsWith(".bin"); + String fileName = safeName; + if (hintIsGeneric && sniff.isKnown()) { + fileName = stripExtension(safeName) + sniff.extension(); + } + + String seed = (dedupSeed == null || dedupSeed.isBlank()) ? fileName : dedupSeed; + String hash = md5Short(seed); + String prefix = (storedNamePrefix == null || storedNamePrefix.isBlank()) + ? "media" : sanitize(storedNamePrefix); + String storedName = prefix + "_" + hash + "_" + fileName; + + Path filePath = targetDir.resolve(storedName); + Files.write(filePath, data); + + // Prefer the sniffed MIME; fall back to extension-based guess only + // when sniffing was inconclusive. + String contentType = sniff.isKnown() ? sniff.contentType() : mimeFromExtension(fileName); + + String fileUrl = null; + if (fileUrlBuilder != null) { + try { + fileUrl = fileUrlBuilder.apply(storedName); + } catch (Exception e) { + log.warn("[media] fileUrl builder failed for {}: {}", storedName, e.getMessage()); + } + } + + log.info("[media] Inbound media saved: {} ({} bytes, type={}, sniffed={})", + filePath, data.length, contentType, sniff.isKnown()); + return Optional.of(new DownloadedMedia( + filePath.toAbsolutePath(), + storedName, + fileName, + contentType, + data.length, + fileUrl)); + } catch (Exception e) { + log.error("[media] Failed to persist inbound media (hint={}): {}", filenameHint, e.getMessage(), e); + return Optional.empty(); + } + } + + private static byte[] fetchWithRetry(ByteSource source, int maxAttempts, String hint) { + ExponentialBackoff backoff = new ExponentialBackoff( + RETRY_INITIAL_DELAY_MS, RETRY_MAX_DELAY_MS, 2.0, maxAttempts, 0.2); + Exception last = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + byte[] data = source.fetch(); + if (data != null && data.length > 0) { + return data; + } + log.warn("[media] Download attempt {}/{} returned empty (hint={})", attempt, maxAttempts, hint); + } catch (Exception e) { + last = e; + log.warn("[media] Download attempt {}/{} failed (hint={}): {}", + attempt, maxAttempts, hint, e.getMessage()); + } + if (attempt < maxAttempts) { + try { + Thread.sleep(backoff.nextDelayMs()); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + if (last != null) { + log.error("[media] Download exhausted after {} attempts (hint={}): {}", + maxAttempts, hint, last.getMessage()); + } + return null; + } + + /** Strip everything except a safe, file-system-friendly character set. */ + private static String sanitize(String name) { + String raw = (name == null) ? "" : name.trim(); + String safe = raw.replaceAll("[^a-zA-Z0-9._-]", "_"); + return safe.isBlank() ? "media" : safe; + } + + private static String stripExtension(String name) { + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + private static String mimeFromExtension(String fileName) { + String lower = fileName.toLowerCase(); + int dot = lower.lastIndexOf('.'); + String ext = dot >= 0 ? lower.substring(dot + 1) : ""; + return switch (ext) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "heic", "heif" -> "image/heic"; + case "bmp" -> "image/bmp"; + case "mp4" -> "video/mp4"; + case "mov" -> "video/quicktime"; + case "mp3" -> "audio/mpeg"; + case "amr" -> "audio/amr"; + case "wav" -> "audio/wav"; + case "pdf" -> "application/pdf"; + default -> "application/octet-stream"; + }; + } + + private static String md5Short(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 4; i++) { + sb.append(String.format("%02x", digest[i])); + } + return sb.toString(); + } catch (Exception e) { + return Integer.toHexString(input.hashCode()); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaTypeSniffer.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaTypeSniffer.java new file mode 100644 index 00000000..0c1ce104 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaTypeSniffer.java @@ -0,0 +1,281 @@ +package vip.mate.channel.media; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Best-effort MIME / extension detection from a file's leading bytes. + * + *

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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - byte[] encryptedData = response.body().readAllBytes(); - - byte[] fileData; - // 2. AES 解密(如果提供了 aesKey) - if (aesKey != null && !aesKey.isBlank()) { - fileData = decryptAes256Cbc(encryptedData, aesKey); - } else { - fileData = encryptedData; - } - - // 3. Magic-byte sniff to recover a real extension when WeCom - // didn't include filename in the body (forwarded files often - // arrive nameless — saving them as "file.bin" misroutes the - // agent because every PDF tool keys off the .pdf extension). - byte[] head = new byte[Math.min(12, fileData.length)]; - System.arraycopy(fileData, 0, head, 0, head.length); - MagicSniff sniff = sniffMagic(head); - // 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 to pick the specific kind. - if (".zip".equals(sniff.extension())) { - sniff = refineZipKind(fileData, sniff); - } - - // 4. Compose a URL-safe storedName. If the hint is generic - // (e.g. "file.bin"), prefer the sniffed extension. - String urlHash = md5Hex(url).substring(0, 8); - String hintRaw = (fileNameHint == null ? "media" : fileNameHint).trim(); - String safeName = hintRaw.replaceAll("[^a-zA-Z0-9._-]", "_"); - if (safeName.isBlank()) safeName = "media"; - // "file.bin" is the WeCom-no-filename sentinel; if magic gave us - // something better, replace the extension. Same when hint had no - // extension at all. - boolean hintIsGeneric = safeName.equals("file.bin") || safeName.equals("media") - || !safeName.contains("."); - if (hintIsGeneric && !".bin".equals(sniff.extension())) { - safeName = stripExtension(safeName) + sniff.extension(); - } - String storedName = "wecom_" + urlHash + "_" + safeName; - Path filePath = uploadDir.resolve(storedName); - Files.write(filePath, fileData); - - String fileUrl = "/api/v1/chat/files/" + conversationId + "/" + storedName; - log.info("[wecom] Inbound media saved: {} ({} bytes, sniffed={}), serve URL={}", - filePath, fileData.length, sniff.contentType(), fileUrl); - return new InboundMediaResult( - filePath.toAbsolutePath().toString(), - storedName, - fileUrl, - fileData.length, - safeName, - sniff.contentType()); - } catch (Exception e) { - log.error("[wecom] Failed to download inbound media: {}", e.getMessage(), e); - return null; - } + // Store under data/chat-uploads/{conversationId} so the existing + // /api/v1/chat/files/{convId}/{storedName} endpoint serves the file + // back to the chat bubble — the WeCom CDN URL carries a short-lived + // signature that expires before a browser can fetch it. The shared + // pipeline owns retry/backoff, magic-byte type detection, and the + // dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here + // inside the byte source so a fetch + decrypt is retried as one unit. + Path uploadDir = Path.of("data", "chat-uploads", conversationId); + String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint; + return InboundMediaDownloader.download( + () -> { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + byte[] encrypted = response.body().readAllBytes(); + return (aesKey != null && !aesKey.isBlank()) + ? decryptAes256Cbc(encrypted, aesKey) + : encrypted; + }, + hint, + uploadDir, + "wecom", + url, + storedName -> "/api/v1/chat/files/" + conversationId + "/" + storedName) + .orElse(null); } /** diff --git a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java index d550302b..79e825f0 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/weixin/WeixinChannelAdapter.java @@ -6,6 +6,7 @@ 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.channel.weixin.error.TokenExpiredException; import vip.mate.common.security.SecretEquals; @@ -18,7 +19,6 @@ 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; @@ -406,7 +406,13 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { List> itemList = (List>) msg.getOrDefault("item_list", List.of()); boolean mediaDownloadEnabled = getConfigBoolean("media_download_enabled", true); - String mediaDir = getConfigString("media_dir", "data/media"); + // Inbound conversation id (see class doc): private "weixin:{user}", + // group "weixin:group:{group}". Downloaded media is stored under + // data/chat-uploads/{convId} so the /api/v1/chat/files endpoint can + // serve it back to the chat bubble / Web mirror. + String inboundConvId = !groupId.isBlank() + ? "weixin:group:" + groupId + : "weixin:" + fromUserId; for (Map item : itemList) { int itemType = item.get("type") instanceof Number n ? n.intValue() : 0; @@ -423,12 +429,21 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { case 2 -> { // Image if (mediaDownloadEnabled) { - String path = downloadMediaItem(item, "image_item", "image.jpg", mediaDir); - if (path != null) { + InboundMediaDownloader.DownloadedMedia dl = + downloadMediaItem(item, "image_item", null, inboundConvId); + if (dl != null) { MessageContentPart part = new MessageContentPart(); part.setType("image"); - part.setPath(path); - part.setContentType("image/*"); + part.setPath(dl.localPath().toString()); + part.setStoredName(dl.storedName()); + part.setFileUrl(dl.fileUrl()); + part.setMediaId(dl.localPath().toString()); + part.setFileName(dl.fileName()); + // Use the sniffed MIME (image/png, image/webp, image/heic, …) + // so vision gateways get an accurate Content-Type. Fall back + // to a concrete jpeg only when sniffing was inconclusive. + part.setContentType(dl.isImage() ? dl.contentType() : "image/jpeg"); + part.setFileSize(dl.fileSize()); contentParts.add(part); } else { // 下载失败,尝试构建 CDN URL @@ -484,15 +499,21 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { // ASR 为空:可能是语音过短、噪音、或 iLink API 字段变更 // 尝试下载语音文件保存到本地(供后续调试 / 自有 STT 使用) if (mediaDownloadEnabled) { - String voicePath = downloadMediaItem(item, "voice_item", "voice.amr", mediaDir); - if (voicePath != null) { - // 保存为 audio content part,即使无 ASR 文本 + InboundMediaDownloader.DownloadedMedia dl = + downloadMediaItem(item, "voice_item", null, inboundConvId); + if (dl != null) { + // Persist as an audio content part even without ASR text MessageContentPart audioPart = new MessageContentPart(); audioPart.setType("audio"); - audioPart.setPath(voicePath); - audioPart.setFileName("voice.amr"); + audioPart.setPath(dl.localPath().toString()); + audioPart.setStoredName(dl.storedName()); + audioPart.setFileUrl(dl.fileUrl()); + audioPart.setMediaId(dl.localPath().toString()); + audioPart.setFileName(dl.fileName()); + audioPart.setContentType(dl.contentType()); + audioPart.setFileSize(dl.fileSize()); contentParts.add(audioPart); - log.info("[weixin] Voice audio downloaded (no ASR): {}", voicePath); + log.info("[weixin] Voice audio downloaded (no ASR): {}", dl.localPath()); } } textParts.add("[语音消息]"); @@ -506,12 +527,18 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { String fileName = getStr(fileItemMap, "file_name"); if (fileName.isBlank()) fileName = "file.bin"; if (mediaDownloadEnabled) { - String path = downloadMediaItem(item, "file_item", fileName, mediaDir); - if (path != null) { + InboundMediaDownloader.DownloadedMedia dl = + downloadMediaItem(item, "file_item", fileName, inboundConvId); + if (dl != null) { MessageContentPart part = new MessageContentPart(); part.setType("file"); - part.setPath(path); - part.setFileName(fileName); + part.setPath(dl.localPath().toString()); + part.setStoredName(dl.storedName()); + part.setFileUrl(dl.fileUrl()); + part.setMediaId(dl.localPath().toString()); + part.setFileName(dl.fileName()); + part.setContentType(dl.contentType()); + part.setFileSize(dl.fileSize()); contentParts.add(part); } else { textParts.add("[文件: " + fileName + " 下载失败]"); @@ -523,12 +550,18 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { case 5 -> { // Video if (mediaDownloadEnabled) { - String path = downloadMediaItem(item, "video_item", "video.mp4", mediaDir); - if (path != null) { + InboundMediaDownloader.DownloadedMedia dl = + downloadMediaItem(item, "video_item", null, inboundConvId); + if (dl != null) { MessageContentPart part = new MessageContentPart(); part.setType("video"); - part.setPath(path); - part.setContentType("video/*"); + part.setPath(dl.localPath().toString()); + part.setStoredName(dl.storedName()); + part.setFileUrl(dl.fileUrl()); + part.setMediaId(dl.localPath().toString()); + part.setFileName(dl.fileName()); + part.setContentType(dl.isVideo() ? dl.contentType() : "video/mp4"); + part.setFileSize(dl.fileSize()); contentParts.add(part); } else { // 尝试构建 CDN URL @@ -604,42 +637,44 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { // ==================== 媒体下载 ==================== + /** + * Download an inbound media item via the shared media pipeline (retry + + * backoff, magic-byte type detection, dedup-named persistence). The iLink + * AES key extraction stays here because it is protocol-specific; the + * decrypted bytes are handed to {@link InboundMediaDownloader}. + * + * @return the stored, type-detected file, or {@code null} on failure + */ @SuppressWarnings("unchecked") - private String downloadMediaItem(Map item, String itemKey, String filenameHint, String mediaDir) { - try { - Map mediaItem = (Map) item.getOrDefault(itemKey, Map.of()); - Map media = (Map) mediaItem.getOrDefault("media", Map.of()); - String encryptQueryParam = getStr(media, "encrypt_query_param"); - String aesKey; + private InboundMediaDownloader.DownloadedMedia downloadMediaItem( + Map item, String itemKey, String filenameHint, String conversationId) { + Map mediaItem = (Map) item.getOrDefault(itemKey, Map.of()); + Map media = (Map) mediaItem.getOrDefault("media", Map.of()); + String encryptQueryParam = getStr(media, "encrypt_query_param"); - // image_item 有顶级 aeskey (hex) - String aeskeyHex = getStr(mediaItem, "aeskey"); - if (!aeskeyHex.isBlank()) { - aesKey = Base64.getEncoder().encodeToString(hexToBytes(aeskeyHex)); - } else { - aesKey = getStr(media, "aes_key"); - } + // image_item carries a top-level hex aeskey; other items use media.aes_key + final String aesKey; + String aeskeyHex = getStr(mediaItem, "aeskey"); + if (!aeskeyHex.isBlank()) { + aesKey = Base64.getEncoder().encodeToString(hexToBytes(aeskeyHex)); + } else { + aesKey = getStr(media, "aes_key"); + } - if (encryptQueryParam.isBlank()) { - log.warn("[weixin] No encrypt_query_param for media download"); - return null; - } - - byte[] data = client.downloadMedia("", aesKey, encryptQueryParam); - - // 保存到本地 - Path dir = Path.of(mediaDir); - Files.createDirectories(dir); - String safeFilename = filenameHint.replaceAll("[^a-zA-Z0-9._-]", ""); - if (safeFilename.isBlank()) safeFilename = "media"; - String urlHash = md5Short(encryptQueryParam); - Path filePath = dir.resolve("weixin_" + urlHash + "_" + safeFilename); - Files.write(filePath, data); - return filePath.toString(); - } catch (Exception e) { - log.error("[weixin] Media download failed: {}", e.getMessage(), e); + if (encryptQueryParam.isBlank()) { + log.warn("[weixin] No encrypt_query_param for media download"); return null; } + + Path uploadDir = Path.of("data", "chat-uploads", conversationId); + return InboundMediaDownloader.download( + () -> client.downloadMedia("", aesKey, encryptQueryParam), + filenameHint, + uploadDir, + "weixin", + encryptQueryParam, + storedName -> "/api/v1/chat/files/" + conversationId + "/" + storedName) + .orElse(null); } // ==================== 发送消息 ==================== @@ -1001,20 +1036,6 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter { return val != null ? val.toString() : ""; } - private static String md5Short(String input) { - try { - MessageDigest md = MessageDigest.getInstance("MD5"); - byte[] digest = md.digest(input.getBytes()); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 4; i++) { - sb.append(String.format("%02x", digest[i])); - } - return sb.toString(); - } catch (Exception e) { - return String.valueOf(input.hashCode()); - } - } - private static byte[] hexToBytes(String hex) { int len = hex.length(); byte[] data = new byte[len / 2]; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/InboundMediaDownloaderTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/InboundMediaDownloaderTest.java new file mode 100644 index 00000000..999cf98b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/InboundMediaDownloaderTest.java @@ -0,0 +1,135 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundMediaDownloaderTest { + + /** PNG header followed by filler. */ + private static byte[] pngBytes() { + byte[] data = new byte[32]; + data[0] = (byte) 0x89; + data[1] = 0x50; + data[2] = 0x4E; + data[3] = 0x47; + return data; + } + + @Test + @DisplayName("Null hint + PNG bytes → synthesized name and accurate MIME") + void synthesizesNameFromSniff(@TempDir Path dir) { + Optional result = InboundMediaDownloader.download( + InboundMediaDownloaderTest::pngBytes, null, dir, "weixin", "seed-1"); + + assertTrue(result.isPresent()); + InboundMediaDownloader.DownloadedMedia m = result.get(); + assertEquals("image/png", m.contentType()); + assertTrue(m.isImage()); + assertTrue(m.fileName().endsWith(".png"), "synthesized name should carry sniffed ext"); + assertTrue(m.storedName().startsWith("weixin_"), "stored name should carry channel prefix"); + assertTrue(m.localPath().toFile().exists()); + assertEquals(32, m.fileSize()); + } + + @Test + @DisplayName("Wrong .jpg extension on PNG bytes is NOT corrected (real names respected)") + void keepsAuthoritativeName(@TempDir Path dir) { + // A meaningful, user-supplied extension is kept verbatim — only the + // MIME comes from sniffing. This is the documented contract: pass null + // for placeholders, a real name only when it is real. + Optional result = InboundMediaDownloader.download( + InboundMediaDownloaderTest::pngBytes, "report.jpg", dir, "weixin", "seed-2"); + + assertTrue(result.isPresent()); + InboundMediaDownloader.DownloadedMedia m = result.get(); + assertEquals("report.jpg", m.fileName()); + assertEquals("image/png", m.contentType(), "MIME still comes from magic bytes"); + } + + @Test + @DisplayName(".bin hint is treated as generic and replaced with sniffed ext") + void binHintIsGeneric(@TempDir Path dir) { + Optional result = InboundMediaDownloader.download( + InboundMediaDownloaderTest::pngBytes, "file.bin", dir, "wecom", "seed-3"); + + assertTrue(result.isPresent()); + assertTrue(result.get().fileName().endsWith(".png")); + } + + @Test + @DisplayName("Transient failures are retried, then succeed") + void retriesThenSucceeds(@TempDir Path dir) { + AtomicInteger calls = new AtomicInteger(); + InboundMediaDownloader.ByteSource flaky = () -> { + if (calls.incrementAndGet() < 3) { + throw new RuntimeException("transient"); + } + return pngBytes(); + }; + + Optional result = InboundMediaDownloader.download( + flaky, null, dir, "weixin", "seed-4", 3); + + assertTrue(result.isPresent()); + assertEquals(3, calls.get(), "should have retried up to the 3rd attempt"); + } + + @Test + @DisplayName("fileUrlBuilder maps storedName to a servable URL") + void buildsFileUrl(@TempDir Path dir) { + Optional result = InboundMediaDownloader.download( + InboundMediaDownloaderTest::pngBytes, null, dir, "weixin", "seed-url", + storedName -> "/api/v1/chat/files/weixin:bob/" + storedName); + + assertTrue(result.isPresent()); + InboundMediaDownloader.DownloadedMedia m = result.get(); + assertEquals("/api/v1/chat/files/weixin:bob/" + m.storedName(), m.fileUrl()); + } + + @Test + @DisplayName("A throwing fileUrlBuilder degrades to null fileUrl, file still saved") + void fileUrlBuilderFailureDegrades(@TempDir Path dir) { + Optional result = InboundMediaDownloader.download( + InboundMediaDownloaderTest::pngBytes, null, dir, "weixin", "seed-url-fail", + storedName -> { + throw new RuntimeException("url build boom"); + }); + + assertTrue(result.isPresent(), "a broken URL builder must not fail the download"); + InboundMediaDownloader.DownloadedMedia m = result.get(); + assertNull(m.fileUrl()); + assertTrue(m.localPath().toFile().exists(), "file should still be persisted"); + } + + @Test + @DisplayName("No builder → null fileUrl") + void noBuilderLeavesNullUrl(@TempDir Path dir) { + Optional result = InboundMediaDownloader.download( + InboundMediaDownloaderTest::pngBytes, null, dir, "weixin", "seed-no-url"); + + assertTrue(result.isPresent()); + assertNull(result.get().fileUrl()); + } + + @Test + @DisplayName("Exhausted retries return empty, no file written") + void exhaustedReturnsEmpty(@TempDir Path dir) { + InboundMediaDownloader.ByteSource always = () -> { + throw new RuntimeException("down"); + }; + Optional result = InboundMediaDownloader.download( + always, null, dir, "weixin", "seed-5", 2); + + assertTrue(result.isEmpty()); + assertEquals(0, dir.toFile().listFiles().length, "no partial file should remain"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/MediaTypeSnifferTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/MediaTypeSnifferTest.java new file mode 100644 index 00000000..851b3825 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/MediaTypeSnifferTest.java @@ -0,0 +1,151 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Magic-byte detection tests. The cases that matter most for IM image + * reception are PNG / WEBP / HEIC: phone photos and screenshots are routinely + * not JPEG, and a wrong Content-Type makes multimodal gateways reject them. + */ +class MediaTypeSnifferTest { + + private static byte[] bytes(int... values) { + byte[] out = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + out[i] = (byte) values[i]; + } + return out; + } + + /** Build an ISO-BMFF header: [size][ftyp][brand]. */ + private static byte[] ftyp(String brand) { + byte[] brandBytes = brand.getBytes(StandardCharsets.US_ASCII); + byte[] head = new byte[12]; + head[0] = 0x00; + head[1] = 0x00; + head[2] = 0x00; + head[3] = 0x18; + head[4] = 'f'; + head[5] = 't'; + head[6] = 'y'; + head[7] = 'p'; + System.arraycopy(brandBytes, 0, head, 8, 4); + return head; + } + + @Test + @DisplayName("PNG signature → image/png") + void detectsPng() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(bytes(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A)); + assertEquals("image/png", s.contentType()); + assertEquals(".png", s.extension()); + assertTrue(s.isImage()); + } + + @Test + @DisplayName("JPEG signature → image/jpeg") + void detectsJpeg() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(bytes(0xFF, 0xD8, 0xFF, 0xE0)); + assertEquals("image/jpeg", s.contentType()); + assertEquals(".jpg", s.extension()); + } + + @Test + @DisplayName("RIFF…WEBP → image/webp (common for screenshots)") + void detectsWebp() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff( + bytes(0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50)); + assertEquals("image/webp", s.contentType()); + assertEquals(".webp", s.extension()); + assertTrue(s.isImage()); + } + + @Test + @DisplayName("HEIC ftyp brand → image/heic, NOT video/mp4 (iPhone photos)") + void detectsHeicNotMp4() { + for (String brand : new String[]{"heic", "heix", "mif1", "heim"}) { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(ftyp(brand)); + assertEquals("image/heic", s.contentType(), "brand=" + brand); + assertTrue(s.isImage(), "brand=" + brand + " should be an image"); + assertFalse(s.isVideo(), "brand=" + brand + " must not be classified as video"); + } + } + + @Test + @DisplayName("Plain MP4 ftyp brand → video/mp4") + void detectsMp4() { + for (String brand : new String[]{"isom", "mp41", "mp42", "avc1"}) { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(ftyp(brand)); + assertEquals("video/mp4", s.contentType(), "brand=" + brand); + assertTrue(s.isVideo(), "brand=" + brand); + } + } + + @Test + @DisplayName("QuickTime ftyp brand → video/quicktime") + void detectsQuickTime() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(ftyp("qt ")); + assertEquals("video/quicktime", s.contentType()); + assertEquals(".mov", s.extension()); + } + + @Test + @DisplayName("PDF signature → application/pdf") + void detectsPdf() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(bytes(0x25, 0x50, 0x44, 0x46, 0x2D)); + assertEquals("application/pdf", s.contentType()); + assertEquals(".pdf", s.extension()); + } + + @Test + @DisplayName("GIF signature → image/gif") + void detectsGif() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(bytes(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)); + assertEquals("image/gif", s.contentType()); + } + + @Test + @DisplayName("AMR voice signature → audio/amr") + void detectsAmr() { + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(bytes(0x23, 0x21, 0x41, 0x4D, 0x52, 0x0A)); + assertEquals("audio/amr", s.contentType()); + assertTrue(s.isAudio()); + } + + @Test + @DisplayName("DOCX (zip container) refined from plain zip") + void refinesDocx() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry("[Content_Types].xml")); + zos.write("".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("word/document.xml")); + zos.write("".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(baos.toByteArray()); + assertEquals(".docx", s.extension()); + assertTrue(s.contentType().contains("wordprocessingml")); + } + + @Test + @DisplayName("Unknown / too-short bytes → octet-stream, not crash") + void handlesUnknownAndShort() { + assertEquals(MediaTypeSniffer.Sniffed.UNKNOWN, MediaTypeSniffer.sniff(null)); + assertEquals(MediaTypeSniffer.Sniffed.UNKNOWN, MediaTypeSniffer.sniff(bytes(0x01))); + MediaTypeSniffer.Sniffed s = MediaTypeSniffer.sniff(bytes(0x01, 0x02, 0x03, 0x04, 0x05)); + assertFalse(s.isKnown()); + assertEquals("application/octet-stream", s.contentType()); + } +}