feat(channel): shared inbound media pipeline with magic-byte typing and retry

This commit is contained in:
matevip 2026-05-30 07:30:38 +08:00
parent 7e9f2ee54c
commit de98368b4e
6 changed files with 985 additions and 351 deletions

View File

@ -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.
*
* <p>Every channel that receives images / files / audio / video from users
* needs the same three steps after it knows how to fetch the raw bytes:
* <ol>
* <li>fetch with retry + backoff (mobile uploads over flaky networks fail
* transiently a single attempt drops the attachment);</li>
* <li>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);</li>
* <li>write to disk under a collision-resistant, URL-safe name.</li>
* </ol>
*
* <p>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<DownloadedMedia> 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<DownloadedMedia> 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<DownloadedMedia> download(ByteSource source,
String filenameHint,
Path targetDir,
String storedNamePrefix,
String dedupSeed,
Function<String, String> 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<DownloadedMedia> download(ByteSource source,
String filenameHint,
Path targetDir,
String storedNamePrefix,
String dedupSeed,
int maxAttempts,
Function<String, String> 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());
}
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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);
}
}

View File

@ -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}.
* <p>
* 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.
* <p>
* 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).
* <p>
* 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<InputStream> 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<InputStream> 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);
}
/**

View File

@ -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<Map<String, Object>> itemList = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> item, String itemKey, String filenameHint, String mediaDir) {
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");
String aesKey;
private InboundMediaDownloader.DownloadedMedia downloadMediaItem(
Map<String, Object> item, String itemKey, String filenameHint, String conversationId) {
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");
// 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];

View File

@ -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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> 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<InboundMediaDownloader.DownloadedMedia> result = InboundMediaDownloader.download(
always, null, dir, "weixin", "seed-5", 2);
assertTrue(result.isEmpty());
assertEquals(0, dir.toFile().listFiles().length, "no partial file should remain");
}
}

View File

@ -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("<types/>".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("word/document.xml"));
zos.write("<doc/>".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());
}
}