mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(channel): shared inbound media pipeline with magic-byte typing and retry
This commit is contained in:
parent
7e9f2ee54c
commit
de98368b4e
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user