From 0e1b8ca564412ecb893e6e716a0b7f944c4abbc4 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 11:23:33 +0800 Subject: [PATCH] sync: componentized media upload SPI + Feishu SDK-backed file sender --- .../java/vip/mate/channel/ChannelManager.java | 20 +- .../channel/feishu/FeishuChannelAdapter.java | 161 ++++++++- .../channel/feishu/FeishuClientFactory.java | 160 ++++++++ .../channel/feishu/FeishuMediaUploader.java | 342 ++++++++++++++++++ .../mate/channel/feishu/FeishuSizePolicy.java | 107 ++++++ .../channel/media/GeneratedFileScrubber.java | 103 ++++++ .../mate/channel/media/ImageCompressor.java | 164 +++++++++ .../mate/channel/media/MediaSizeDecision.java | 42 +++ .../mate/channel/media/MediaSizePolicy.java | 30 ++ .../vip/mate/channel/media/MediaSource.java | 46 +++ .../channel/media/MediaUploadException.java | 22 ++ .../channel/media/MediaUploadRequest.java | 48 +++ .../mate/channel/media/MediaUploadResult.java | 37 ++ .../vip/mate/channel/media/MediaUploader.java | 41 +++ .../mate/channel/service/ChannelService.java | 28 ++ .../channel/ChannelManagerReconcileTest.java | 2 + .../channel/feishu/FeishuMediaWiringIT.java | 67 ++++ .../channel/feishu/FeishuSizePolicyTest.java | 126 +++++++ .../media/GeneratedFileScrubberTest.java | 96 +++++ .../channel/media/ImageCompressorTest.java | 82 +++++ .../mate/channel/media/MediaSourceTest.java | 59 +++ 21 files changed, 1772 insertions(+), 11 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuClientFactory.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuMediaUploader.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuSizePolicy.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/ImageCompressor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizeDecision.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizePolicy.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaSource.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadRequest.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploader.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index ca3bfffa..9400ecd8 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -75,6 +75,23 @@ public class ChannelManager { */ private final vip.mate.channel.wecom.WeComKeepaliveScheduler weComKeepaliveScheduler; + /** + * Feishu SDK-backed media uploader. Wired into + * {@link vip.mate.channel.feishu.FeishuChannelAdapter} so every + * outbound image / file / audio / video flows through + * {@code oapi-sdk} multipart and the per-platform size policy + * instead of hand-rolled HTTP. + */ + private final vip.mate.channel.feishu.FeishuMediaUploader feishuMediaUploader; + + /** + * Channel-shared scrubber that converts agent-emitted + * {@code /api/v1/files/generated/{id}} URLs into native channel + * attachments. Same instance is also injected into WeCom in a + * follow-up patch; today only Feishu consumes it. + */ + private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber; + /** * Distributed leader election. Channels whose adapter reports * {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so @@ -1140,7 +1157,8 @@ public class ChannelManager { return switch (type) { case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper); case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); - case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper); + case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, + feishuMediaUploader, generatedFileScrubber); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 153c3e57..c39d64ca 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -9,6 +9,11 @@ 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.GeneratedFileScrubber; +import vip.mate.channel.media.MediaSource; +import vip.mate.channel.media.MediaUploadException; +import vip.mate.channel.media.MediaUploadRequest; +import vip.mate.channel.media.MediaUploadResult; import vip.mate.channel.model.ChannelEntity; import vip.mate.workspace.conversation.model.MessageContentPart; @@ -117,11 +122,27 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { /** Negative-cache window for {@link #getBotOpenId()} failures (60 s). */ private static final long BOT_OPENID_FAILURE_BACKOFF_MS = 60_000L; + /** SDK-backed uploader for image / file / audio / video parts. Nullable for legacy callers. */ + private final FeishuMediaUploader mediaUploader; + + /** Scrubs {@code /api/v1/files/generated/{id}} URLs into native attachments. Nullable for legacy callers. */ + private final GeneratedFileScrubber generatedFileScrubber; + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { + this(channelEntity, messageRouter, objectMapper, null, null); + } + + public FeishuChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + FeishuMediaUploader mediaUploader, + GeneratedFileScrubber generatedFileScrubber) { super(channelEntity, messageRouter, objectMapper); - // 飞书 WebSocket 重连:2s→4s→8s→16s→30s,无限重试 + this.mediaUploader = mediaUploader; + this.generatedFileScrubber = generatedFileScrubber; + // Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); } @@ -1626,24 +1647,29 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { log.warn("[feishu] Channel not started, cannot send message"); return; } + if (parts == null || parts.isEmpty()) return; ensureTokenValid(); + // Carry an explicit channelId for media uploads. Available on + // the entity since CRUD; only null in degenerate test stubs. + Long channelId = channelEntity != null ? channelEntity.getId() : null; + for (MessageContentPart part : parts) { if (part == null) continue; try { switch (part.getType()) { - case "text" -> sendMessage(targetId, part.getText() != null ? part.getText() : ""); - case "image" -> { - if (part.getMediaId() != null) { - sendFeishuMedia(targetId, "image", Map.of("image_key", part.getMediaId())); - } - } - case "file" -> { - if (part.getMediaId() != null) { - sendFeishuMedia(targetId, "file", Map.of("file_key", part.getMediaId())); + case "text" -> handleTextPart(targetId, channelId, part); + case "refusal" -> { + String refusal = part.getText(); + if (refusal != null && !refusal.isBlank()) { + sendMessage(targetId, "⚠️ " + refusal); } } + case "image" -> handleMediaPart(targetId, channelId, part, "image", "image.jpg"); + case "file" -> handleMediaPart(targetId, channelId, part, "file", "file.bin"); + case "audio" -> handleMediaPart(targetId, channelId, part, "audio", "voice_reply.opus"); + case "video" -> handleMediaPart(targetId, channelId, part, "video", "video.mp4"); default -> { if (part.getText() != null) sendMessage(targetId, part.getText()); } @@ -1654,6 +1680,121 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter { } } + /** + * Handle one text part: if the scrubber is wired, rewrite any + * {@code /api/v1/files/generated/{id}} URL to a {@code "📎 filename"} + * marker and upload the cached bytes as a native attachment; if not, + * fall back to sending the text as-is (legacy behavior). + */ + private void handleTextPart(String targetId, Long channelId, MessageContentPart part) { + String text = part.getText() != null ? part.getText() : ""; + if (generatedFileScrubber == null || mediaUploader == null || channelId == null) { + sendMessage(targetId, text); + return; + } + GeneratedFileScrubber.ScrubResult scrubbed = generatedFileScrubber.scrub(text); + sendMessage(targetId, scrubbed.rewrittenText()); + for (GeneratedFileScrubber.AttachmentHit hit : scrubbed.attachments()) { + uploadAndSendAttachment(targetId, channelId, + new MediaSource.Bytes(hit.bytes()), + hit.fileName(), + hit.mediaType(), + hit.mimeType(), + null); + } + } + + /** + * Handle one image / file / audio / video part. If {@code mediaId} is + * already a Feishu key (image_key / file_key) — e.g. echoed back + * from an earlier inbound message — send directly. Otherwise build a + * {@link MediaSource} from {@code path} / {@code fileUrl} / + * {@code text}(base64 not yet supported here) and upload via the SDK + * uploader first. + */ + private void handleMediaPart(String targetId, Long channelId, MessageContentPart part, + String mediaType, String defaultFileName) { + // 1. mediaId is already a Feishu key → send straight away + String existingKey = part.getMediaId(); + if (existingKey != null && (existingKey.startsWith("img_") || existingKey.startsWith("file_"))) { + String keyField = "image".equals(mediaType) ? "image_key" : "file_key"; + sendFeishuMedia(targetId, mediaType, Map.of(keyField, existingKey)); + return; + } + if (mediaUploader == null || channelId == null) { + sendFallbackText(targetId, part, mediaType); + return; + } + MediaSource source = resolveSource(part); + if (source == null) { + sendFallbackText(targetId, part, mediaType); + return; + } + String fileName = part.getFileName() != null ? part.getFileName() : defaultFileName; + uploadAndSendAttachment(targetId, channelId, source, fileName, + mediaType, part.getContentType(), null); + } + + /** + * Resolve a {@link MessageContentPart}'s payload source — prefer + * already-on-disk path, fall back to fileUrl. Returns {@code null} + * when neither is usable (e.g. the part only carries a placeholder). + */ + private static MediaSource resolveSource(MessageContentPart part) { + if (part.getPath() != null && !part.getPath().isBlank()) { + java.nio.file.Path p = java.nio.file.Path.of(part.getPath()); + if (java.nio.file.Files.exists(p)) { + return new MediaSource.LocalPath(p); + } + } + String url = part.getFileUrl(); + if (url != null && !url.isBlank()) { + return new MediaSource.RemoteUrl(url); + } + return null; + } + + /** + * Upload via the SDK-backed uploader and dispatch the resulting + * key as a Feishu media message. Surfaces any rejection / + * downgrade note as a follow-up text bubble so users understand + * why a bubble isn't native. + */ + private void uploadAndSendAttachment(String targetId, Long channelId, + MediaSource source, String fileName, + String mediaType, String contentType, + Integer durationMillis) { + try { + MediaUploadResult result = mediaUploader.upload(new MediaUploadRequest( + channelId, source, fileName, mediaType, contentType, durationMillis)); + String finalType = result.finalMediaType(); + String keyField = "image".equals(finalType) ? "image_key" : "file_key"; + sendFeishuMedia(targetId, finalType, Map.of(keyField, result.mediaId())); + if (result.downgradeNote() != null) { + sendMessage(targetId, "ℹ️ " + result.downgradeNote()); + } + } catch (MediaUploadException e) { + log.warn("[feishu] Upload rejected for {} ({}): {}", fileName, mediaType, e.getMessage()); + sendMessage(targetId, "⚠️ " + e.getMessage()); + } catch (Exception e) { + log.error("[feishu] Upload failed for {} ({}): {}", fileName, mediaType, e.getMessage(), e); + sendMessage(targetId, "⚠️ 附件 " + fileName + " 发送失败:" + e.getMessage()); + } + } + + /** + * Last-ditch text representation when neither a Feishu key nor a + * usable source could be derived from the part. Keeps the bubble + * informative instead of swallowing the message. + */ + private void sendFallbackText(String targetId, MessageContentPart part, String mediaType) { + StringBuilder sb = new StringBuilder("⚠️ 无法发送 "); + sb.append(mediaType); + if (part.getFileName() != null) sb.append(":").append(part.getFileName()); + if (part.getFileUrl() != null) sb.append(" (").append(part.getFileUrl()).append(")"); + sendMessage(targetId, sb.toString()); + } + private void sendFeishuMedia(String targetId, String msgType, Map content) { String apiBase = getApiBaseUrl(); String receiveIdType = resolveReceiveIdType(targetId); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuClientFactory.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuClientFactory.java new file mode 100644 index 00000000..7a34f386 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuClientFactory.java @@ -0,0 +1,160 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lark.oapi.Client; +import com.lark.oapi.core.enums.BaseUrlEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.repository.ChannelMapper; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-channelId cache of {@link Client} instances backed by Feishu's + * official {@code oapi-sdk}. The SDK manages the + * {@code tenant_access_token} lifecycle internally — callers never + * touch tokens. + * + *

Invalidation: when a Feishu channel row is mutated + * (credential rotation, domain switch, deletion), {@code ChannelService} + * calls {@link #evict(Long)} to drop the stale client; the next + * {@link #client(Long)} call rebuilds from current config. + * + *

Each cache entry also carries a {@code fingerprint} (appId + + * secret hash + domain). A subtle in-place edit that misses the + * eviction hook is still caught on next lookup: the fingerprint + * mismatch forces a rebuild. + * + *

Why a dedicated factory rather than building on the adapter's + * existing hand-rolled HTTP path: the SDK handles multipart uploads, + * CardKit streaming, contact lookups, calendar/docx, reactions, and + * message updates uniformly — the adapter's hand-rolled + * {@code HttpClient} only covers basic message send. Every new send + * path in this codebase should go through this factory and the SDK. + * The adapter's pre-existing hand-rolled paths stay untouched + * (surgical principle) — two token caches coexisting is a negligible + * memory cost. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FeishuClientFactory { + + private static final String CHANNEL_TYPE = "feishu"; + + private final ChannelMapper channelMapper; + private final ObjectMapper objectMapper; + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** Cache entry — fingerprint guards against missed-eviction in-place edits. */ + private record Cached(String fingerprint, Client client) {} + + /** + * Get (building lazily if absent) the SDK client for the given + * Feishu channel. + * + * @throws IllegalArgumentException if the channel does not exist + * or is not of type {@code feishu} + * @throws IllegalStateException if the channel row is missing + * {@code app_id} or {@code app_secret} + */ + public Client client(Long channelId) { + if (channelId == null) { + throw new IllegalArgumentException("channelId must not be null"); + } + ChannelEntity ch = channelMapper.selectById(channelId); + if (ch == null) { + throw new IllegalArgumentException("Channel not found: " + channelId); + } + if (!CHANNEL_TYPE.equals(ch.getChannelType())) { + throw new IllegalArgumentException( + "Channel " + channelId + " is type=" + ch.getChannelType() + + ", not " + CHANNEL_TYPE); + } + Map cfg = parseConfig(ch.getConfigJson()); + String appId = asString(cfg.get("app_id")); + String appSecret = asString(cfg.get("app_secret")); + String domain = asStringOr(cfg.get("domain"), "feishu"); + if (appId == null || appSecret == null) { + throw new IllegalStateException( + "Feishu channel " + channelId + " missing app_id / app_secret"); + } + String fp = fingerprint(appId, appSecret, domain); + Cached existing = cache.get(channelId); + if (existing != null && existing.fingerprint().equals(fp)) { + return existing.client(); + } + Client built = build(appId, appSecret, domain); + cache.put(channelId, new Cached(fp, built)); + if (existing != null) { + log.info("[feishu-client-factory] Rebuilt client for channel {} (config changed)", channelId); + } else { + log.info("[feishu-client-factory] Built client for channel {} (domain={})", channelId, domain); + } + return built; + } + + /** + * Drop the cached client for {@code channelId}. Idempotent — safe + * to call for non-feishu channels (no-op) and for channels with no + * cached client. Called by {@code ChannelService} on every + * Feishu channel update / delete / toggle. + */ + public void evict(Long channelId) { + if (channelId == null) return; + if (cache.remove(channelId) != null) { + log.info("[feishu-client-factory] Evicted client for channel {}", channelId); + } + } + + /** Test hook — visible for assertion. */ + int cachedCount() { + return cache.size(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private Client build(String appId, String appSecret, String domain) { + BaseUrlEnum baseUrl = "lark".equalsIgnoreCase(domain) + ? BaseUrlEnum.LarkSuite + : BaseUrlEnum.FeiShu; + return Client.newBuilder(appId, appSecret) + .openBaseUrl(baseUrl) + .build(); + } + + private static String fingerprint(String appId, String appSecret, String domain) { + return appId + '|' + Objects.hash(appSecret) + '|' + domain.toLowerCase(); + } + + private Map parseConfig(String configJson) { + if (configJson == null || configJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(configJson, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("[feishu-client-factory] Failed to parse configJson: {}", e.getMessage()); + return Map.of(); + } + } + + private static String asString(Object v) { + if (v == null) return null; + String s = v.toString().trim(); + return s.isEmpty() ? null : s; + } + + private static String asStringOr(Object v, String fallback) { + String s = asString(v); + return s == null ? fallback : s; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuMediaUploader.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuMediaUploader.java new file mode 100644 index 00000000..33ea1d3a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuMediaUploader.java @@ -0,0 +1,342 @@ +package vip.mate.channel.feishu; + +import com.lark.oapi.Client; +import com.lark.oapi.service.im.v1.model.CreateFileReq; +import com.lark.oapi.service.im.v1.model.CreateFileReqBody; +import com.lark.oapi.service.im.v1.model.CreateFileResp; +import com.lark.oapi.service.im.v1.model.CreateImageReq; +import com.lark.oapi.service.im.v1.model.CreateImageReqBody; +import com.lark.oapi.service.im.v1.model.CreateImageResp; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.channel.media.ImageCompressor; +import vip.mate.channel.media.MediaSizeDecision; +import vip.mate.channel.media.MediaSource; +import vip.mate.channel.media.MediaUploadException; +import vip.mate.channel.media.MediaUploadRequest; +import vip.mate.channel.media.MediaUploadResult; +import vip.mate.channel.media.MediaUploader; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URLConnection; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; + +/** + * Feishu implementation of {@link MediaUploader}. Routes through the + * {@code oapi-sdk} so {@code tenant_access_token}, multipart framing, + * retries, and domain (feishu / lark) are all handled by the SDK — + * no hand-rolled HTTP for any new send path. + * + *

Flow: + *

    + *
  1. Resolve the {@link MediaSource} into bytes (in-memory for + * URL/Path sources are normalised the same way the SDK needs).
  2. + *
  3. Consult {@link FeishuSizePolicy}. If the decision rejects, + * throw {@link MediaUploadException}. If it downgrades, switch + * to the file endpoint and carry the user-facing note on the + * result.
  4. + *
  5. If still {@code image} and oversized but under hard ceiling, + * run {@link ImageCompressor} so the original payload fits.
  6. + *
  7. Stage to a temp file (the SDK signatures take + * {@code java.io.File}, not streams), invoke the right SDK + * endpoint, then delete the temp file in {@code finally}.
  8. + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FeishuMediaUploader implements MediaUploader { + + /** + * Cap on the bytes a {@link MediaSource.RemoteUrl} fetch may yield. + * Same as Feishu's hard {@link FeishuSizePolicy#FILE_MAX_BYTES file + * ceiling} — beyond this the size policy will reject anyway, so no + * point downloading more. + */ + private static final long REMOTE_FETCH_MAX_BYTES = FeishuSizePolicy.FILE_MAX_BYTES; + + /** Connection + per-request fetch timeout for {@link MediaSource.RemoteUrl}. */ + private static final Duration REMOTE_FETCH_TIMEOUT = Duration.ofSeconds(30); + + /** + * Extension → SDK {@code file_type} for the file endpoint. Anything + * not on this list maps to {@code stream} (the SDK's catch-all). + * Image extensions are intentionally absent — images go through + * the image endpoint, not file. + */ + private static final Map EXT_TO_FILE_TYPE = Map.of( + "pdf", "pdf", + "doc", "doc", + "docx", "doc", + "xls", "xls", + "xlsx", "xls", + "ppt", "ppt", + "pptx", "ppt", + "mp4", "mp4", + "opus", "opus" + ); + + private final FeishuClientFactory clientFactory; + private final FeishuSizePolicy sizePolicy; + + /** Lazily-built HTTP client for {@link MediaSource.RemoteUrl} fetches. */ + private volatile HttpClient httpClient; + + @Override + public String channelType() { + return "feishu"; + } + + @Override + public MediaUploadResult upload(MediaUploadRequest request) throws MediaUploadException { + byte[] bytes = resolveBytes(request.source(), request.fileName()); + + // ---- Apply size policy. May reject, downgrade, or pass. + MediaSizeDecision decision = sizePolicy.evaluate( + bytes.length, request.mediaType(), request.contentType()); + if (decision.rejected()) { + throw new MediaUploadException(decision.rejectReason()); + } + String effectiveType = decision.finalMediaType(); + + // ---- For images, give the compressor a chance before upload + if ("image".equals(effectiveType) && bytes.length > FeishuSizePolicy.IMAGE_MAX_BYTES / 2) { + // Pre-emptive compression when the image is in the upper + // half of the 10 MB window — keeps a margin against + // transient size growth from JPEG re-encoding. + bytes = ImageCompressor.compressIfNeeded(bytes, request.fileName(), FeishuSizePolicy.IMAGE_MAX_BYTES); + if (bytes.length > FeishuSizePolicy.IMAGE_MAX_BYTES) { + // Compression couldn't get under — fall through to the + // file endpoint instead of failing outright. + log.warn("[feishu-upload] {} image still {}KB after compression — downgrading to file", + request.fileName(), bytes.length / 1024); + effectiveType = "file"; + decision = MediaSizeDecision.downgradeTo("file", + "图片压缩后仍超过 10MB,已转为文件形式发送"); + } + } + + Path tempFile = null; + try { + tempFile = stageToTempFile(bytes, request.fileName()); + File asFile = tempFile.toFile(); + Client client = clientFactory.client(request.channelId()); + + String mediaId = switch (effectiveType) { + case "image" -> uploadImage(client, asFile); + case "audio", "video", "file" -> uploadFile( + client, asFile, effectiveType, request.fileName(), + request.contentType(), request.durationMillis()); + default -> throw new MediaUploadException( + "Unsupported mediaType: " + effectiveType); + }; + + return new MediaUploadResult(mediaId, effectiveType, decision.downgradeNote()); + + } catch (MediaUploadException e) { + throw e; + } catch (Exception e) { + throw new MediaUploadException( + "Feishu upload failed for " + request.fileName() + ": " + e.getMessage(), e); + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException ignore) { + // tmpdir cleanup is best-effort + } + } + } + } + + // ------------------------------------------------------------------ + // SDK endpoint calls + // ------------------------------------------------------------------ + + private String uploadImage(Client client, File file) throws Exception { + CreateImageReq req = CreateImageReq.newBuilder() + .createImageReqBody(CreateImageReqBody.newBuilder() + .imageType("message") + .image(file) + .build()) + .build(); + CreateImageResp resp = client.im().v1().image().create(req); + if (!resp.success()) { + throw new MediaUploadException(formatSdkError("im.image.create", resp.getCode(), resp.getMsg())); + } + return resp.getData().getImageKey(); + } + + private String uploadFile(Client client, File file, String effectiveType, String fileName, + String contentType, Integer durationMillis) throws Exception { + String fileType = resolveFileType(effectiveType, fileName, contentType); + CreateFileReqBody.Builder body = CreateFileReqBody.newBuilder() + .fileType(fileType) + .fileName(fileName) + .file(file); + if (durationMillis != null && durationMillis > 0) { + body.duration(durationMillis); + } + CreateFileReq req = CreateFileReq.newBuilder() + .createFileReqBody(body.build()) + .build(); + CreateFileResp resp = client.im().v1().file().create(req); + if (!resp.success()) { + throw new MediaUploadException(formatSdkError("im.file.create", resp.getCode(), resp.getMsg())); + } + return resp.getData().getFileKey(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * Map (effectiveType, fileName, contentType) → SDK {@code file_type} + * accepted by the {@code /im/v1/files} endpoint. The SDK enforces a + * closed set; unknown values are rejected with code 230003. + */ + private static String resolveFileType(String effectiveType, String fileName, String contentType) { + if ("audio".equals(effectiveType)) { + return "opus"; + } + if ("video".equals(effectiveType)) { + return "mp4"; + } + // effectiveType == "file" — derive from extension, fall back to stream + String ext = extensionOf(fileName); + if (ext != null) { + String mapped = EXT_TO_FILE_TYPE.get(ext); + if (mapped != null) return mapped; + } + // Some content-types carry a hint (e.g. application/pdf) + if (contentType != null) { + String ct = contentType.toLowerCase(Locale.ROOT); + if (ct.contains("pdf")) return "pdf"; + if (ct.contains("msword") || ct.contains("wordprocessing")) return "doc"; + if (ct.contains("excel") || ct.contains("spreadsheet")) return "xls"; + if (ct.contains("powerpoint") || ct.contains("presentation")) return "ppt"; + } + return "stream"; + } + + private static String extensionOf(String fileName) { + if (fileName == null) return null; + int dot = fileName.lastIndexOf('.'); + if (dot < 0 || dot == fileName.length() - 1) return null; + return fileName.substring(dot + 1).toLowerCase(Locale.ROOT); + } + + private byte[] resolveBytes(MediaSource source, String fileName) throws MediaUploadException { + try { + return switch (source) { + case MediaSource.Bytes b -> b.data(); + case MediaSource.LocalPath p -> Files.readAllBytes(p.path()); + case MediaSource.RemoteUrl u -> fetchRemote(u.url()); + }; + } catch (MediaUploadException e) { + throw e; + } catch (IOException e) { + throw new MediaUploadException( + "Failed to read media source for " + fileName + ": " + e.getMessage(), e); + } + } + + private byte[] fetchRemote(String url) throws MediaUploadException { + try { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(REMOTE_FETCH_TIMEOUT) + .GET() + .build(); + HttpResponse resp = httpClient() + .send(req, HttpResponse.BodyHandlers.ofInputStream()); + if (resp.statusCode() / 100 != 2) { + throw new MediaUploadException( + "Remote fetch " + url + " returned HTTP " + resp.statusCode()); + } + try (InputStream is = resp.body()) { + return readCapped(is, REMOTE_FETCH_MAX_BYTES, url); + } + } catch (MediaUploadException e) { + throw e; + } catch (Exception e) { + throw new MediaUploadException( + "Failed to fetch remote media " + url + ": " + e.getMessage(), e); + } + } + + /** + * Read at most {@code cap} bytes from {@code in}. Throws if the + * stream still has more — we never load oversized remote payloads + * into memory, since the size policy would reject them anyway. + */ + private static byte[] readCapped(InputStream in, long cap, String urlForError) throws IOException, MediaUploadException { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8 * 1024]; + long total = 0; + int n; + while ((n = in.read(buf)) > 0) { + total += n; + if (total > cap) { + throw new MediaUploadException( + "Remote media " + urlForError + " exceeds " + cap + " bytes — aborted partial read"); + } + out.write(buf, 0, n); + } + return out.toByteArray(); + } + + private HttpClient httpClient() { + HttpClient c = this.httpClient; + if (c != null) return c; + synchronized (this) { + c = this.httpClient; + if (c == null) { + c = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + this.httpClient = c; + } + } + return c; + } + + private static Path stageToTempFile(byte[] bytes, String fileName) throws IOException { + // Preserve extension so the SDK and Feishu server can sniff + // content type. Strip path separators from the suggested name. + String safeName = fileName == null ? "upload" : fileName.replaceAll("[/\\\\]", "_"); + Path tmp = Files.createTempFile("feishu-upload-", "-" + safeName); + Files.copy(new java.io.ByteArrayInputStream(bytes), tmp, StandardCopyOption.REPLACE_EXISTING); + return tmp; + } + + private static String formatSdkError(String op, int code, String msg) { + return op + " failed (code=" + code + ", msg=" + msg + ")"; + } + + /** Reserved for future inference if request.contentType is null and source is a path. */ + @SuppressWarnings("unused") + private static String sniffContentType(Path path) { + try { + String type = Files.probeContentType(path); + if (type != null) return type; + } catch (IOException ignore) { + // fall through + } + return URLConnection.guessContentTypeFromName(path.getFileName().toString()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuSizePolicy.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuSizePolicy.java new file mode 100644 index 00000000..fa5a5ac6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuSizePolicy.java @@ -0,0 +1,107 @@ +package vip.mate.channel.feishu; + +import org.springframework.stereotype.Component; +import vip.mate.channel.media.MediaSizeDecision; +import vip.mate.channel.media.MediaSizePolicy; + +import java.util.Locale; +import java.util.Set; + +/** + * Feishu's per-media-type ceilings, encoded as a {@link MediaSizePolicy}. + * + *

Sources (Feishu official OpenAPI docs): + *

    + *
  • {@code /open-apis/im/v1/images} — image payload max 10 MB
  • + *
  • {@code /open-apis/im/v1/files} — file payload max 30 MB; + * {@code file_type} ∈ {opus, mp4, pdf, doc, xls, ppt, stream}. + * Anything outside that set must be sent as {@code stream}.
  • + *
  • Voice msgtype only accepts {@code opus} audio. Other audio + * MIMEs (mp3, wav, …) cannot render as a native voice bubble — + * the only way to deliver them is as a downgraded file.
  • + *
  • Video msgtype only accepts {@code mp4}. Same downgrade rule.
  • + *
+ * + *

The 30 MB file ceiling is a hard limit — even after a downgrade + * to {@code file} the payload cannot exceed it. So a 50 MB video gets + * rejected outright; a 40 MB image likewise. + */ +@Component +public class FeishuSizePolicy implements MediaSizePolicy { + + /** Image ceiling — Feishu {@code /im/v1/images} accepts up to 10 MB. */ + public static final long IMAGE_MAX_BYTES = 10L * 1024 * 1024; + + /** File / audio / video ceiling — Feishu {@code /im/v1/files} accepts up to 30 MB. */ + public static final long FILE_MAX_BYTES = 30L * 1024 * 1024; + + /** Audio MIMEs that render as native voice bubbles. Anything else → file. */ + private static final Set VOICE_SUPPORTED_MIMES = Set.of( + "audio/opus", "audio/ogg", "audio/ogg;codecs=opus" + ); + + /** Video MIMEs that render as native video bubbles. Anything else → file. */ + private static final Set VIDEO_SUPPORTED_MIMES = Set.of( + "video/mp4" + ); + + @Override + public String channelType() { + return "feishu"; + } + + @Override + public MediaSizeDecision evaluate(long fileSize, String mediaType, String contentType) { + String type = mediaType == null ? "file" : mediaType.toLowerCase(Locale.ROOT); + String mime = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT).trim(); + + // ---- Hard reject: nothing on Feishu carries > 30 MB + if (fileSize > FILE_MAX_BYTES) { + double mb = fileSize / 1024.0 / 1024.0; + return MediaSizeDecision.reject(type, String.format( + Locale.ROOT, + "文件大小 %.2fMB 超过飞书 30MB 上限,无法发送。请压缩或拆分后再发。", + mb)); + } + + // ---- Image: 10 MB hard, oversized → file + if ("image".equals(type)) { + if (fileSize > IMAGE_MAX_BYTES) { + double mb = fileSize / 1024.0 / 1024.0; + return MediaSizeDecision.downgradeTo("file", String.format( + Locale.ROOT, + "图片 %.2fMB 超过飞书 10MB 限制,已转为文件形式发送", + mb)); + } + return MediaSizeDecision.pass("image"); + } + + // ---- Audio: opus-only for voice bubble, else → file + if ("audio".equals(type)) { + if (!mime.isEmpty() && !isVoiceSupported(mime)) { + return MediaSizeDecision.downgradeTo("file", + "语音格式 " + mime + " 不支持(飞书原生语音仅支持 opus),已转为文件形式发送"); + } + return MediaSizeDecision.pass("audio"); + } + + // ---- Video: mp4-only for video bubble, else → file + if ("video".equals(type)) { + if (!mime.isEmpty() && !VIDEO_SUPPORTED_MIMES.contains(mime)) { + return MediaSizeDecision.downgradeTo("file", + "视频格式 " + mime + " 不支持(飞书原生视频仅支持 mp4),已转为文件形式发送"); + } + return MediaSizeDecision.pass("video"); + } + + // ---- Plain file — already passed the 30 MB gate above + return MediaSizeDecision.pass("file"); + } + + private static boolean isVoiceSupported(String mime) { + for (String supported : VOICE_SUPPORTED_MIMES) { + if (mime.startsWith(supported)) return true; + } + return false; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java b/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java new file mode 100644 index 00000000..6caefcb8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/GeneratedFileScrubber.java @@ -0,0 +1,103 @@ +package vip.mate.channel.media; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import vip.mate.tool.document.GeneratedFileCache; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; + +/** + * Channel-agnostic scanner that finds {@code /api/v1/files/generated/{id}} + * URLs in agent output, looks each id up in {@link GeneratedFileCache}, + * and rewrites the URL so the IM bubble shows a meaningful surface + * (file name marker or "retry" hint) while collecting the bytes for the + * adapter to upload as a native attachment. + * + *

Cache miss has two causes (both surfaced with the same retry hint + * so the user just resubmits): + *

    + *
  1. The LLM hallucinated a UUID-shaped string without ever calling + * a render tool. {@link GeneratedFileCache#put} logs every real + * put, so its absence here is proof the file was never generated + * this turn.
  2. + *
  3. The 10-min cache entry expired before the IM client got around + * to clicking, or was wiped on JVM restart.
  4. + *
+ * Without this rewrite, IM clients tap a markdown link that returns + * 404, save the HTML 404 body as the requested file extension, then + * report "file is corrupted" to support. + * + *

Originally lived as a private method on {@code WeComChannelAdapter}; + * extracted here so Feishu / DingTalk / future channels share one + * implementation and one set of log conventions. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GeneratedFileScrubber { + + private final GeneratedFileCache cache; + + /** + * One attachment hit produced by {@link #scrub}. + * + * @param bytes raw file content (from cache) + * @param fileName original file name (kept for upload + display) + * @param mimeType MIME from cache entry — used to decide + * {@code image} vs {@code file} on upload + * @param mediaType coarse classification: {@code "image"} when + * {@code mimeType} starts with {@code image/}, + * otherwise {@code "file"} + */ + public record AttachmentHit(byte[] bytes, String fileName, String mimeType, String mediaType) {} + + /** + * Result of scrubbing one text block. + * + * @param rewrittenText same text with each generated-URL replaced + * by either a {@code "📎 filename"} marker + * (cache hit) or a retry warning (cache miss) + * @param attachments one entry per cache hit, in document order + */ + public record ScrubResult(String rewrittenText, List attachments) {} + + /** + * Scan {@code text} for generated-file URLs and produce a + * {@link ScrubResult}. Returns the input unchanged (and an empty + * attachment list) when {@code text} is null/empty or contains no + * matches. + */ + public ScrubResult scrub(String text) { + if (text == null || text.isEmpty()) { + return new ScrubResult(text, List.of()); + } + Matcher m = GeneratedFileCache.GENERATED_URL_PATTERN.matcher(text); + if (!m.find()) { + return new ScrubResult(text, List.of()); + } + StringBuilder out = new StringBuilder(); + List hits = new ArrayList<>(); + m.reset(); + while (m.find()) { + String id = m.group(1); + GeneratedFileCache.Entry entry = cache.get(id).orElse(null); + if (entry != null) { + String mediaType = isImageMime(entry.mimeType()) ? "image" : "file"; + hits.add(new AttachmentHit(entry.bytes(), entry.filename(), entry.mimeType(), mediaType)); + m.appendReplacement(out, Matcher.quoteReplacement("📎 " + entry.filename())); + } else { + log.warn("[generated-file-scrubber] cache miss for id={} — likely LLM skipped the render tool and wrote a fake URL", id); + m.appendReplacement(out, Matcher.quoteReplacement(GeneratedFileCache.MISSING_REFERENCE_NOTICE)); + } + } + m.appendTail(out); + return new ScrubResult(out.toString(), hits); + } + + private static boolean isImageMime(String mimeType) { + return mimeType != null && mimeType.toLowerCase().startsWith("image/"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/ImageCompressor.java b/mateclaw-server/src/main/java/vip/mate/channel/media/ImageCompressor.java new file mode 100644 index 00000000..81965e1c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/ImageCompressor.java @@ -0,0 +1,164 @@ +package vip.mate.channel.media; + +import lombok.extern.slf4j.Slf4j; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Iterator; + +/** + * Channel-agnostic image compressor — shrinks an image to fit a + * platform's per-image byte ceiling. + * + *

Strategy (in order): + *

    + *
  1. If already under {@code maxBytes}, return as-is.
  2. + *
  3. Decode → convert to RGB (drops alpha; flattens against white).
  4. + *
  5. Re-encode JPEG at progressively lower quality + * ({@code qualitySteps}); accept the first encoding that fits.
  6. + *
  7. If still too big, downscale by progressively smaller factors + * ({@code scaleSteps}) at quality 0.5; accept first that fits.
  8. + *
  9. If nothing fits, return the smallest variant we produced — + * the caller's size policy will then reject or further downgrade.
  10. + *
+ * + *

Pure static utility — no Spring, no platform knowledge. Callers + * pass in their platform's limit (Feishu image 10 MB, WeCom image + * 1.9 MB safe-margin under the 2 MB hard cap, etc.). + */ +@Slf4j +public final class ImageCompressor { + + /** Default JPEG quality steps used by {@link #compressIfNeeded(byte[], String, long)}. */ + public static final float[] DEFAULT_QUALITY_STEPS = {0.85f, 0.70f, 0.50f, 0.30f}; + + /** Default downscale factors used by {@link #compressIfNeeded(byte[], String, long)}. */ + public static final double[] DEFAULT_SCALE_STEPS = {0.75, 0.50, 0.25}; + + private ImageCompressor() {} + + /** + * Compress with the default quality and scale ladders. + * + * @param imageBytes original encoded image (PNG / JPEG / GIF / …) + * @param fileName original file name — kept for log clarity only + * @param maxBytes target ceiling; bytes returned will be at most + * this size unless every step still exceeds it + */ + public static byte[] compressIfNeeded(byte[] imageBytes, String fileName, long maxBytes) { + return compressIfNeeded(imageBytes, fileName, maxBytes, DEFAULT_QUALITY_STEPS, DEFAULT_SCALE_STEPS); + } + + /** + * Compress with caller-supplied quality and scale ladders. Useful + * when a platform's limit is tight enough that the defaults + * leave little headroom and a custom ladder converges faster. + */ + public static byte[] compressIfNeeded(byte[] imageBytes, String fileName, long maxBytes, + float[] qualitySteps, double[] scaleSteps) { + if (imageBytes == null || imageBytes.length == 0) { + return imageBytes; + } + if (imageBytes.length <= maxBytes) { + return imageBytes; + } + + log.info("[image-compress] {}: original {}KB > limit {}KB", + fileName, imageBytes.length / 1024, maxBytes / 1024); + + try { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (img == null) { + log.warn("[image-compress] {}: ImageIO could not decode; returning original", fileName); + return imageBytes; + } + + BufferedImage rgbImg = toRgb(img); + + for (float quality : qualitySteps) { + byte[] compressed = writeJpeg(rgbImg, quality); + if (compressed.length <= maxBytes) { + log.info("[image-compress] {}: compressed to {}KB (quality={})", + fileName, compressed.length / 1024, quality); + return compressed; + } + } + + int w = rgbImg.getWidth(); + int h = rgbImg.getHeight(); + byte[] smallest = null; + for (double scale : scaleSteps) { + BufferedImage resized = resize(rgbImg, (int) (w * scale), (int) (h * scale)); + byte[] compressed = writeJpeg(resized, 0.50f); + smallest = compressed; + if (compressed.length <= maxBytes) { + log.info("[image-compress] {}: resized to {}x{}, {}KB", + fileName, (int) (w * scale), (int) (h * scale), compressed.length / 1024); + return compressed; + } + } + + log.warn("[image-compress] {}: could not shrink below {}KB; returning smallest ({}KB)", + fileName, maxBytes / 1024, smallest != null ? smallest.length / 1024 : 0); + return smallest != null ? smallest : imageBytes; + + } catch (Exception e) { + log.error("[image-compress] {}: failed ({}); returning original", fileName, e.getMessage()); + return imageBytes; + } + } + + private static BufferedImage toRgb(BufferedImage img) { + if (img.getType() == BufferedImage.TYPE_INT_RGB) { + return img; + } + BufferedImage rgb = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = rgb.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, img.getWidth(), img.getHeight()); + g.drawImage(img, 0, 0, null); + g.dispose(); + return rgb; + } + + private static byte[] writeJpeg(BufferedImage img, float quality) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IllegalStateException("No JPEG ImageWriter available"); + } + ImageWriter writer = writers.next(); + try { + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality); + try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { + writer.setOutput(ios); + writer.write(null, new IIOImage(img, null, null), param); + } + } finally { + writer.dispose(); + } + return baos.toByteArray(); + } + + private static BufferedImage resize(BufferedImage img, int newWidth, int newHeight) { + BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = resized.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setColor(Color.WHITE); + g.fillRect(0, 0, newWidth, newHeight); + g.drawImage(img, 0, 0, newWidth, newHeight, null); + g.dispose(); + return resized; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizeDecision.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizeDecision.java new file mode 100644 index 00000000..5528032f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizeDecision.java @@ -0,0 +1,42 @@ +package vip.mate.channel.media; + +/** + * Decision returned by {@link MediaSizePolicy#evaluate}. + * + *

Three terminal cases, exposed as separate boolean flags so the + * caller can branch without inspecting which fields are populated: + *

    + *
  • pass — {@code rejected=false}, {@code downgraded=false}. + * Upload as-is; {@code finalMediaType} matches request.
  • + *
  • downgraded — {@code rejected=false}, {@code downgraded=true}. + * Still upload, but as {@code finalMediaType} (e.g. an oversized + * image is uploaded as {@code file}). Append {@code downgradeNote} + * to the message body so the user knows why.
  • + *
  • rejected — {@code rejected=true}. Do not upload at all; + * surface {@code rejectReason} as the message body. Even + * {@code file} cannot carry it.
  • + *
+ * + *

Generalised from WeCom's {@code WeComUploadLimitDecision} so every + * channel that needs platform-specific size rules can implement + * {@link MediaSizePolicy} without re-inventing the result shape. + */ +public record MediaSizeDecision( + String finalMediaType, + boolean rejected, + String rejectReason, + boolean downgraded, + String downgradeNote) { + + public static MediaSizeDecision pass(String mediaType) { + return new MediaSizeDecision(mediaType, false, null, false, null); + } + + public static MediaSizeDecision reject(String mediaType, String reason) { + return new MediaSizeDecision(mediaType, true, reason, false, null); + } + + public static MediaSizeDecision downgradeTo(String newMediaType, String note) { + return new MediaSizeDecision(newMediaType, false, null, true, note); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizePolicy.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizePolicy.java new file mode 100644 index 00000000..3f05d964 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSizePolicy.java @@ -0,0 +1,30 @@ +package vip.mate.channel.media; + +/** + * SPI for "what does this channel platform accept at what size". + * + *

One implementation per channel type. Encodes the platform's + * per-media-type byte ceilings and any "wrong MIME → downgrade to file" + * rules. Kept independent of {@link MediaUploader} so the policy can be + * unit-tested in pure isolation and reused by any caller that wants to + * pre-validate before commit (e.g. an admin UI showing "this file is + * too big for WeCom" up-front). + * + *

Pure function — no platform credentials, no network I/O. + */ +public interface MediaSizePolicy { + + /** Channel type this policy serves, matching {@link MediaUploader#channelType()}. */ + String channelType(); + + /** + * Decide whether to accept, downgrade, or reject the given upload. + * + * @param fileSize payload size in bytes + * @param mediaType requested type — {@code image} / {@code file} / + * {@code audio} / {@code video} + * @param contentType MIME (may be null; policies that don't care + * about MIME ignore it) + */ + MediaSizeDecision evaluate(long fileSize, String mediaType, String contentType); +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSource.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSource.java new file mode 100644 index 00000000..f4f307d8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaSource.java @@ -0,0 +1,46 @@ +package vip.mate.channel.media; + +import java.nio.file.Path; + +/** + * Sealed input source for {@link MediaUploadRequest}. + * + *

Exactly one of the three variants carries the payload. Concrete + * {@link MediaUploader} implementations decide how to normalize each + * variant to whatever shape the platform SDK requires (the Feishu SDK, + * for instance, takes {@link java.io.File}, so bytes/url variants are + * staged through a temp file). + */ +public sealed interface MediaSource permits MediaSource.Bytes, MediaSource.LocalPath, MediaSource.RemoteUrl { + + /** In-memory bytes — typical for content produced by an agent tool. */ + record Bytes(byte[] data) implements MediaSource { + public Bytes { + if (data == null || data.length == 0) { + throw new IllegalArgumentException("MediaSource.Bytes payload must be non-empty"); + } + } + } + + /** Already-on-disk file — typical for skill scripts that write to a workspace path. */ + record LocalPath(Path path) implements MediaSource { + public LocalPath { + if (path == null) { + throw new IllegalArgumentException("MediaSource.LocalPath path must not be null"); + } + } + } + + /** + * Remote HTTP(S) URL — uploader fetches it before handing the bytes + * to the platform SDK. Implementations may choose to enforce a + * size cap on the fetched body to protect memory. + */ + record RemoteUrl(String url) implements MediaSource { + public RemoteUrl { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("MediaSource.RemoteUrl url must be non-blank"); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadException.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadException.java new file mode 100644 index 00000000..6210c356 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadException.java @@ -0,0 +1,22 @@ +package vip.mate.channel.media; + +/** + * Thrown by {@link MediaUploader#upload(MediaUploadRequest)} when the + * upload cannot complete — credential issues, oversize rejection, + * platform API failure, or local I/O while staging the payload. + * + *

Distinct from {@link IllegalArgumentException} (caller's fault) + * and unchecked runtime errors (programmer bugs). Catching code is + * expected to log and fall back to a textual notice to the user + * rather than crash the adapter's send loop. + */ +public class MediaUploadException extends Exception { + + public MediaUploadException(String message) { + super(message); + } + + public MediaUploadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadRequest.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadRequest.java new file mode 100644 index 00000000..a19879a8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadRequest.java @@ -0,0 +1,48 @@ +package vip.mate.channel.media; + +/** + * Channel-agnostic request to upload one media asset. + * + *

Required: {@code channelId}, {@code source}, {@code fileName}, + * {@code mediaType}. The remaining fields are best-effort hints — + * platform SDKs that need them will use them, others ignore. + * + * @param channelId identifies which channel-row credentials to use + * when the uploader needs to authenticate + * @param source the payload (bytes / on-disk path / remote URL) + * @param fileName file name with extension (used by platform SDKs + * and shown to end users in IM file bubbles) + * @param mediaType one of {@code image} / {@code file} / + * {@code audio} / {@code video}. Decides which + * platform endpoint the uploader picks and how + * the receiving IM client renders the bubble. + * May be downgraded by a {@link MediaSizePolicy}. + * @param contentType MIME type (e.g. {@code image/png}, + * {@code audio/opus}); used by both the size + * policy and the platform SDK + * @param durationMillis playback duration in ms for audio/video; some + * SDKs surface it on the receiver UI; nullable + */ +public record MediaUploadRequest( + Long channelId, + MediaSource source, + String fileName, + String mediaType, + String contentType, + Integer durationMillis) { + + public MediaUploadRequest { + if (channelId == null) { + throw new IllegalArgumentException("MediaUploadRequest.channelId must not be null"); + } + if (source == null) { + throw new IllegalArgumentException("MediaUploadRequest.source must not be null"); + } + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("MediaUploadRequest.fileName must be non-blank"); + } + if (mediaType == null || mediaType.isBlank()) { + throw new IllegalArgumentException("MediaUploadRequest.mediaType must be non-blank"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadResult.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadResult.java new file mode 100644 index 00000000..c19e9d72 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploadResult.java @@ -0,0 +1,37 @@ +package vip.mate.channel.media; + +/** + * Result of a successful {@link MediaUploader#upload} call. + * + * @param mediaId platform-side identifier — Feishu + * {@code image_key} / {@code file_key}; + * DingTalk {@code mediaId} / {@code downloadCode}; + * WeCom {@code media_id}. Callers store this on + * the {@code MessageContentPart} and feed it to + * the platform's message-send call. + * @param finalMediaType the media type actually uploaded under — may + * differ from {@link MediaUploadRequest#mediaType()} + * if a {@link MediaSizePolicy} downgraded it + * (e.g. oversized image → file) + * @param downgradeNote optional user-visible note explaining a + * downgrade; null when no downgrade happened + */ +public record MediaUploadResult( + String mediaId, + String finalMediaType, + String downgradeNote) { + + public MediaUploadResult { + if (mediaId == null || mediaId.isBlank()) { + throw new IllegalArgumentException("MediaUploadResult.mediaId must be non-blank"); + } + if (finalMediaType == null || finalMediaType.isBlank()) { + throw new IllegalArgumentException("MediaUploadResult.finalMediaType must be non-blank"); + } + } + + /** Convenience for the common "no downgrade" path. */ + public static MediaUploadResult of(String mediaId, String mediaType) { + return new MediaUploadResult(mediaId, mediaType, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploader.java b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploader.java new file mode 100644 index 00000000..56cf43e6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/media/MediaUploader.java @@ -0,0 +1,41 @@ +package vip.mate.channel.media; + +/** + * SPI for "upload one media asset to a channel platform". + * + *

Implemented once per channel type (Feishu / WeCom / DingTalk / …). + * The adapter for that channel injects all matching beans and routes + * by {@link #channelType()}. Pure boundary contract — the SPI knows + * nothing about adapters, message routing, or downstream send logic; + * it just turns {@link MediaUploadRequest} into a platform-side + * {@link MediaUploadResult#mediaId() mediaId}. + * + *

Implementations are expected to: + *

    + *
  1. Consult their paired {@link MediaSizePolicy} first; if the + * decision rejects, throw {@link MediaUploadException} with the + * reason so the caller can surface it to the user.
  2. + *
  3. Apply any necessary downgrade ({@code image} → {@code file}) + * before calling the platform endpoint, and propagate the note + * on the returned {@link MediaUploadResult}.
  4. + *
  5. Avoid leaking temp files staged for SDK calls — always clean + * up in a {@code finally} block.
  6. + *
+ */ +public interface MediaUploader { + + /** + * Channel type this uploader serves, matching + * {@code ChannelAdapter.getChannelType()} (e.g. {@code "feishu"}). + */ + String channelType(); + + /** + * Upload the media and return the platform identifier that can be + * referenced in a subsequent send-message call. + * + * @throws MediaUploadException on size rejection, credential issues, + * platform API failure, or local I/O while staging + */ + MediaUploadResult upload(MediaUploadRequest request) throws MediaUploadException; +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java b/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java index 611edfe5..a2c887a8 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/service/ChannelService.java @@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; +import vip.mate.channel.feishu.FeishuClientFactory; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.repository.ChannelMapper; import vip.mate.exception.MateClawException; @@ -30,6 +32,14 @@ public class ChannelService { private final ChannelMapper channelMapper; private final ObjectMapper objectMapper; + /** + * Cache-eviction hook for the Feishu SDK client. {@link ObjectProvider} + * defers the lookup so this service stays usable in test contexts + * that don't load the Feishu beans, and so a future cycle (Feishu + * components transitively depending on this service) cannot crash + * Spring's eager constructor wiring. + */ + private final ObjectProvider feishuClientFactoryProvider; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); /** @@ -121,6 +131,7 @@ public class ChannelService { channel.setConfigJson(enrichWebChatConfig(channel.getConfigJson(), existing.getConfigJson())); } channelMapper.updateById(channel); + invalidateChannelCaches(channel.getId(), channel.getChannelType()); log.info("Updated channel: {}", existing.getName()); return channel; } @@ -131,6 +142,7 @@ public class ChannelService { public void deleteChannel(Long id) { ChannelEntity channel = getChannel(id); channelMapper.deleteById(id); + invalidateChannelCaches(id, channel.getChannelType()); log.info("Deleted channel: {}", channel.getName()); } @@ -141,10 +153,26 @@ public class ChannelService { ChannelEntity channel = getChannel(id); channel.setEnabled(enabled); channelMapper.updateById(channel); + invalidateChannelCaches(id, channel.getChannelType()); log.info("Channel {} {}", channel.getName(), enabled ? "enabled" : "disabled"); return channel; } + /** + * Drop any cached per-channel SDK clients / tool registrations + * after a mutation. Today this only matters for Feishu (whose + * {@link FeishuClientFactory} caches a client per channelId); + * RFC 47's channel-tool reconcile service will hook in here too. + */ + private void invalidateChannelCaches(Long channelId, String channelType) { + if ("feishu".equals(channelType)) { + FeishuClientFactory factory = feishuClientFactoryProvider.getIfAvailable(); + if (factory != null) { + factory.evict(channelId); + } + } + } + private String enrichWebChatConfig(String incomingConfigJson, String existingConfigJson) { Map incoming = parseConfig(incomingConfigJson); Map existing = parseConfig(existingConfigJson); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index 3ede4388..bc2b4e49 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -56,6 +56,8 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.notification.ApprovalNotificationService.class), mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class), mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class), + mock(vip.mate.channel.feishu.FeishuMediaUploader.class), + mock(vip.mate.channel.media.GeneratedFileScrubber.class), election); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java new file mode 100644 index 00000000..706db7d5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuMediaWiringIT.java @@ -0,0 +1,67 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import vip.mate.MateClawApplication; +import vip.mate.channel.media.GeneratedFileScrubber; +import vip.mate.channel.media.MediaSizePolicy; +import vip.mate.channel.media.MediaUploader; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test — confirms the new Layer 1 + Layer 2 media beans + * are wired by Spring with the right contracts, so a runtime + * NoSuchBeanDefinitionException can't slip through to first user + * traffic after a deploy. + * + *

Does NOT touch any real Feishu credentials or call the upstream + * SDK — purely a Spring container contract test. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +class FeishuMediaWiringIT { + + @Autowired private FeishuClientFactory clientFactory; + @Autowired private FeishuMediaUploader mediaUploader; + @Autowired private FeishuSizePolicy sizePolicy; + @Autowired private GeneratedFileScrubber scrubber; + @Autowired private List uploaderBeans; + @Autowired private List policyBeans; + + @Test + @DisplayName("all Layer 1 + Layer 2 beans resolve") + void beansAreWired() { + assertNotNull(clientFactory); + assertNotNull(mediaUploader); + assertNotNull(sizePolicy); + assertNotNull(scrubber); + } + + @Test + @DisplayName("MediaUploader SPI picks up FeishuMediaUploader by channelType") + void uploaderSpiContractsHold() { + boolean hasFeishu = uploaderBeans.stream() + .anyMatch(u -> "feishu".equals(u.channelType())); + assertTrue(hasFeishu, + "FeishuMediaUploader missing from MediaUploader SPI collection: " + + uploaderBeans.stream().map(MediaUploader::channelType).toList()); + } + + @Test + @DisplayName("MediaSizePolicy SPI picks up FeishuSizePolicy by channelType") + void policySpiContractsHold() { + boolean hasFeishu = policyBeans.stream() + .anyMatch(p -> "feishu".equals(p.channelType())); + assertTrue(hasFeishu, + "FeishuSizePolicy missing from MediaSizePolicy SPI collection: " + + policyBeans.stream().map(MediaSizePolicy::channelType).toList()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java new file mode 100644 index 00000000..964353d7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSizePolicyTest.java @@ -0,0 +1,126 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.channel.media.MediaSizeDecision; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pin the Feishu upload-decision matrix. + * + *

Feishu's server rejects oversize payloads at the end of the upload + * (after we've serialised all bytes through {@code oapi-sdk}). Without + * this client-side gate, a 40 MB video would round-trip for nothing + * and the user would see the bubble fail with a cryptic SDK error. + * These tests pin the boundary so any future tweak (Feishu raising + * limits, the SDK adding new file_types) is intentional. + */ +class FeishuSizePolicyTest { + + private final FeishuSizePolicy policy = new FeishuSizePolicy(); + + @Test + @DisplayName("normal-sized file passes through with native media type") + void normalFilePasses() { + MediaSizeDecision d = policy.evaluate(1_000_000, "file", null); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("file", d.finalMediaType()); + assertNull(d.downgradeNote()); + } + + @Test + @DisplayName("file at exactly 30MB passes; one byte over rejects with a message") + void fileBoundary() { + MediaSizeDecision pass = policy.evaluate(FeishuSizePolicy.FILE_MAX_BYTES, "file", null); + assertFalse(pass.rejected()); + + MediaSizeDecision fail = policy.evaluate(FeishuSizePolicy.FILE_MAX_BYTES + 1, "file", null); + assertTrue(fail.rejected()); + assertNotNull(fail.rejectReason()); + assertTrue(fail.rejectReason().contains("30MB"), + "reject reason should mention 30MB; got: " + fail.rejectReason()); + } + + @Test + @DisplayName("image at exactly 10MB passes as image; one byte over downgrades to file") + void imageBoundary() { + MediaSizeDecision pass = policy.evaluate(FeishuSizePolicy.IMAGE_MAX_BYTES, "image", "image/png"); + assertFalse(pass.rejected()); + assertFalse(pass.downgraded()); + assertEquals("image", pass.finalMediaType()); + + MediaSizeDecision down = policy.evaluate(FeishuSizePolicy.IMAGE_MAX_BYTES + 1, "image", "image/png"); + assertFalse(down.rejected()); + assertTrue(down.downgraded()); + assertEquals("file", down.finalMediaType()); + assertNotNull(down.downgradeNote()); + assertTrue(down.downgradeNote().contains("10MB"), + "downgrade note should mention 10MB; got: " + down.downgradeNote()); + } + + @Test + @DisplayName("image over the 30MB file ceiling rejects (not downgrades)") + void imageBeyondFileCeilingRejects() { + MediaSizeDecision d = policy.evaluate(40L * 1024 * 1024, "image", "image/png"); + assertTrue(d.rejected()); + assertNotNull(d.rejectReason()); + } + + @Test + @DisplayName("audio/opus stays as native voice; audio/mp3 downgrades to file") + void audioMimeRouting() { + MediaSizeDecision opus = policy.evaluate(500_000, "audio", "audio/opus"); + assertFalse(opus.rejected()); + assertFalse(opus.downgraded()); + assertEquals("audio", opus.finalMediaType()); + + MediaSizeDecision mp3 = policy.evaluate(500_000, "audio", "audio/mp3"); + assertFalse(mp3.rejected()); + assertTrue(mp3.downgraded()); + assertEquals("file", mp3.finalMediaType()); + assertTrue(mp3.downgradeNote().contains("opus"), + "downgrade note should explain opus-only; got: " + mp3.downgradeNote()); + } + + @Test + @DisplayName("audio without contentType defaults to native voice (caller knows it's opus)") + void audioMissingMime() { + MediaSizeDecision d = policy.evaluate(500_000, "audio", null); + assertFalse(d.rejected()); + assertFalse(d.downgraded()); + assertEquals("audio", d.finalMediaType()); + } + + @Test + @DisplayName("video/mp4 stays as native video; video/webm downgrades to file") + void videoMimeRouting() { + MediaSizeDecision mp4 = policy.evaluate(2_000_000, "video", "video/mp4"); + assertFalse(mp4.downgraded()); + assertEquals("video", mp4.finalMediaType()); + + MediaSizeDecision webm = policy.evaluate(2_000_000, "video", "video/webm"); + assertTrue(webm.downgraded()); + assertEquals("file", webm.finalMediaType()); + assertTrue(webm.downgradeNote().contains("mp4")); + } + + @Test + @DisplayName("null mediaType is treated as file (defensive default)") + void nullMediaType() { + MediaSizeDecision d = policy.evaluate(1000, null, null); + assertFalse(d.rejected()); + assertEquals("file", d.finalMediaType()); + } + + @Test + @DisplayName("channelType identifies this policy as feishu") + void channelTypeIsFeishu() { + assertEquals("feishu", policy.channelType()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java new file mode 100644 index 00000000..907745ad --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/GeneratedFileScrubberTest.java @@ -0,0 +1,96 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.tool.document.GeneratedFileCache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Pin the cache-hit vs. cache-miss rewrite contract. + * + *

The scrubber rewrites differently per outcome because the user + * experience differs: + *

    + *
  • Cache hit → upload as native attachment; bubble text shows + * "📎 filename" so the link doesn't dangle.
  • + *
  • Cache miss (LLM hallucinated the URL OR the 10 min TTL + * expired) → bubble text shows + * {@link GeneratedFileCache#MISSING_REFERENCE_NOTICE} so the + * user knows to retry the request rather than tap a 404.
  • + *
+ */ +class GeneratedFileScrubberTest { + + @Test + @DisplayName("text without any generated URL is returned unchanged") + void noMatchesReturnInput() { + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(new GeneratedFileCache()); + String text = "hello world\nnothing to see here"; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + assertSame(text, r.rewrittenText()); + assertEquals(0, r.attachments().size()); + } + + @Test + @DisplayName("cache hit replaces URL with file-name marker and queues bytes for upload") + void cacheHitProducesAttachment() { + GeneratedFileCache cache = new GeneratedFileCache(); + byte[] bytes = "fake-pdf".getBytes(); + String id = cache.put(bytes, "report.pdf", "application/pdf"); + + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(cache); + String text = "See attached: /api/v1/files/generated/" + id + " for details."; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + + assertTrue(r.rewrittenText().contains("📎 report.pdf"), + "marker should appear; got: " + r.rewrittenText()); + assertEquals(1, r.attachments().size()); + GeneratedFileScrubber.AttachmentHit hit = r.attachments().get(0); + assertEquals("report.pdf", hit.fileName()); + assertEquals("file", hit.mediaType()); + assertSame(bytes, hit.bytes()); + } + + @Test + @DisplayName("cache hit with image MIME classifies as image media type") + void cacheHitImageMime() { + GeneratedFileCache cache = new GeneratedFileCache(); + String id = cache.put(new byte[]{1, 2, 3}, "screenshot.png", "image/png"); + + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(cache); + GeneratedFileScrubber.ScrubResult r = scrubber.scrub("look: /api/v1/files/generated/" + id); + assertEquals(1, r.attachments().size()); + assertEquals("image", r.attachments().get(0).mediaType()); + } + + @Test + @DisplayName("cache miss replaces URL with retry hint and produces no attachment") + void cacheMissProducesRetryHint() { + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(new GeneratedFileCache()); + // Random UUID-shaped string the LLM might hallucinate + String text = "click /api/v1/files/generated/00000000-0000-4000-8000-000000000000 to get it"; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + assertEquals(0, r.attachments().size()); + assertTrue(r.rewrittenText().contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE), + "miss should swap in MISSING_REFERENCE_NOTICE; got: " + r.rewrittenText()); + } + + @Test + @DisplayName("multiple URLs in one text produce hits in document order") + void multipleHitsOrdered() { + GeneratedFileCache cache = new GeneratedFileCache(); + String idA = cache.put(new byte[]{1}, "a.pdf", "application/pdf"); + String idB = cache.put(new byte[]{2}, "b.png", "image/png"); + + GeneratedFileScrubber scrubber = new GeneratedFileScrubber(cache); + String text = "first: /api/v1/files/generated/" + idA + + " then: /api/v1/files/generated/" + idB + "."; + GeneratedFileScrubber.ScrubResult r = scrubber.scrub(text); + assertEquals(2, r.attachments().size()); + assertEquals("a.pdf", r.attachments().get(0).fileName()); + assertEquals("b.png", r.attachments().get(1).fileName()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java new file mode 100644 index 00000000..33a9a1cb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/ImageCompressorTest.java @@ -0,0 +1,82 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Confirm the channel-agnostic {@link ImageCompressor} behaves the + * same on Feishu's 10 MB ceiling and WeCom's 1.9 MB safe-margin. + * + *

The compressor's "give up if you can't fit; return smallest" + * fallback exists because every channel's adapter follows up with its + * own size policy that knows how to downgrade or reject — we never + * want to crash on an undecodable PNG. + */ +class ImageCompressorTest { + + @Test + @DisplayName("under-limit bytes are returned untouched (same reference)") + void underLimitPassesThrough() { + byte[] tiny = new byte[100]; + new Random(42).nextBytes(tiny); + byte[] out = ImageCompressor.compressIfNeeded(tiny, "tiny.bin", 1000); + assertSame(tiny, out, "must not copy when already under limit"); + } + + @Test + @DisplayName("null or empty input returns as-is, no NPE") + void nullAndEmpty() { + assertArrayEquals(null, ImageCompressor.compressIfNeeded(null, "n", 100)); + byte[] empty = new byte[0]; + assertSame(empty, ImageCompressor.compressIfNeeded(empty, "e", 100)); + } + + @Test + @DisplayName("undecodable garbage at over-limit size returns original (no crash)") + void undecodableReturnsOriginal() { + byte[] junk = new byte[1500]; + new Random(99).nextBytes(junk); + byte[] out = ImageCompressor.compressIfNeeded(junk, "junk.bin", 1000); + // ImageIO.read returns null on garbage; compressor logs and returns input. + assertSame(junk, out); + } + + @Test + @DisplayName("real PNG over the limit is shrunk to fit") + void realImageShrinksToFit() throws Exception { + // Generate a 256x256 PNG that's well over a tight limit when uncompressed RGBA + BufferedImage img = new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + // Fill with noisy pattern so PNG can't trivially compress to almost nothing + Random rnd = new Random(7); + for (int y = 0; y < 256; y++) { + for (int x = 0; x < 256; x++) { + img.setRGB(x, y, rnd.nextInt()); + } + } + g.setColor(Color.WHITE); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + byte[] pngBytes = baos.toByteArray(); + + // 4 KB ceiling — noisy PNG won't fit, but JPEG at low quality + resize will. + byte[] out = ImageCompressor.compressIfNeeded(pngBytes, "noise.png", 4000); + assertNotNull(out); + // Either fits, or returns the smallest variant. Either way it's smaller than the input. + assertTrue(out.length < pngBytes.length, + "compressor should at least shrink below original; in=" + pngBytes.length + " out=" + out.length); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java new file mode 100644 index 00000000..69b6b3c7 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/media/MediaSourceTest.java @@ -0,0 +1,59 @@ +package vip.mate.channel.media; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Lock in the {@link MediaSource} sealed contract — each variant + * validates its required payload at construction so downstream + * uploaders don't have to defensive-null-check every field. + */ +class MediaSourceTest { + + @Test + @DisplayName("Bytes rejects null and empty payload") + void bytesRequiresContent() { + assertThrows(IllegalArgumentException.class, () -> new MediaSource.Bytes(null)); + assertThrows(IllegalArgumentException.class, () -> new MediaSource.Bytes(new byte[0])); + } + + @Test + @DisplayName("LocalPath rejects null path") + void localPathRequiresPath() { + assertThrows(IllegalArgumentException.class, () -> new MediaSource.LocalPath(null)); + } + + @Test + @DisplayName("RemoteUrl rejects blank URL") + void remoteUrlRequiresUrl() { + assertThrows(IllegalArgumentException.class, () -> new MediaSource.RemoteUrl(null)); + assertThrows(IllegalArgumentException.class, () -> new MediaSource.RemoteUrl("")); + assertThrows(IllegalArgumentException.class, () -> new MediaSource.RemoteUrl(" ")); + } + + @Test + @DisplayName("happy paths accept the three valid forms") + void happyPaths() { + MediaSource b = new MediaSource.Bytes(new byte[]{1, 2, 3}); + MediaSource p = new MediaSource.LocalPath(Paths.get("/tmp/x")); + MediaSource u = new MediaSource.RemoteUrl("https://example.com/x.png"); + + // Exhaustive switch — verifies the sealed contract at compile time too. + assertEquals("Bytes", classifyVariant(b)); + assertEquals("LocalPath", classifyVariant(p)); + assertEquals("RemoteUrl", classifyVariant(u)); + } + + private static String classifyVariant(MediaSource s) { + return switch (s) { + case MediaSource.Bytes ignored -> "Bytes"; + case MediaSource.LocalPath ignored -> "LocalPath"; + case MediaSource.RemoteUrl ignored -> "RemoteUrl"; + }; + } +}