mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
sync: componentized media upload SPI + Feishu SDK-backed file sender
This commit is contained in:
parent
5cc567a689
commit
0e1b8ca564
@ -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,
|
||||
|
||||
@ -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<String, Object> content) {
|
||||
String apiBase = getApiBaseUrl();
|
||||
String receiveIdType = resolveReceiveIdType(targetId);
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p><b>Invalidation</b>: 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<Long, Cached> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>Flow:
|
||||
* <ol>
|
||||
* <li>Resolve the {@link MediaSource} into bytes (in-memory for
|
||||
* URL/Path sources are normalised the same way the SDK needs).</li>
|
||||
* <li>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.</li>
|
||||
* <li>If still {@code image} and oversized but under hard ceiling,
|
||||
* run {@link ImageCompressor} so the original payload fits.</li>
|
||||
* <li>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}.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@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<String, String> 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<InputStream> 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());
|
||||
}
|
||||
}
|
||||
@ -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}.
|
||||
*
|
||||
* <p>Sources (Feishu official OpenAPI docs):
|
||||
* <ul>
|
||||
* <li>{@code /open-apis/im/v1/images} — image payload max <b>10 MB</b></li>
|
||||
* <li>{@code /open-apis/im/v1/files} — file payload max <b>30 MB</b>;
|
||||
* {@code file_type} ∈ {opus, mp4, pdf, doc, xls, ppt, stream}.
|
||||
* Anything outside that set must be sent as {@code stream}.</li>
|
||||
* <li>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.</li>
|
||||
* <li>Video msgtype only accepts {@code mp4}. Same downgrade rule.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>Cache miss has two causes (both surfaced with the same retry hint
|
||||
* so the user just resubmits):
|
||||
* <ol>
|
||||
* <li>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.</li>
|
||||
* <li>The 10-min cache entry expired before the IM client got around
|
||||
* to clicking, or was wiped on JVM restart.</li>
|
||||
* </ol>
|
||||
* 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.
|
||||
*
|
||||
* <p>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<AttachmentHit> 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<AttachmentHit> 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/");
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>Strategy (in order):
|
||||
* <ol>
|
||||
* <li>If already under {@code maxBytes}, return as-is.</li>
|
||||
* <li>Decode → convert to RGB (drops alpha; flattens against white).</li>
|
||||
* <li>Re-encode JPEG at progressively lower quality
|
||||
* ({@code qualitySteps}); accept the first encoding that fits.</li>
|
||||
* <li>If still too big, downscale by progressively smaller factors
|
||||
* ({@code scaleSteps}) at quality 0.5; accept first that fits.</li>
|
||||
* <li>If nothing fits, return the smallest variant we produced —
|
||||
* the caller's size policy will then reject or further downgrade.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<ImageWriter> 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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
/**
|
||||
* Decision returned by {@link MediaSizePolicy#evaluate}.
|
||||
*
|
||||
* <p>Three terminal cases, exposed as separate boolean flags so the
|
||||
* caller can branch without inspecting which fields are populated:
|
||||
* <ul>
|
||||
* <li><b>pass</b> — {@code rejected=false}, {@code downgraded=false}.
|
||||
* Upload as-is; {@code finalMediaType} matches request.</li>
|
||||
* <li><b>downgraded</b> — {@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.</li>
|
||||
* <li><b>rejected</b> — {@code rejected=true}. Do not upload at all;
|
||||
* surface {@code rejectReason} as the message body. Even
|
||||
* {@code file} cannot carry it.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
/**
|
||||
* SPI for "what does this channel platform accept at what size".
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Sealed input source for {@link MediaUploadRequest}.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
/**
|
||||
* Channel-agnostic request to upload one media asset.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package vip.mate.channel.media;
|
||||
|
||||
/**
|
||||
* SPI for "upload one media asset to a channel platform".
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>Implementations are expected to:
|
||||
* <ol>
|
||||
* <li>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.</li>
|
||||
* <li>Apply any necessary downgrade ({@code image} → {@code file})
|
||||
* before calling the platform endpoint, and propagate the note
|
||||
* on the returned {@link MediaUploadResult}.</li>
|
||||
* <li>Avoid leaking temp files staged for SDK calls — always clean
|
||||
* up in a {@code finally} block.</li>
|
||||
* </ol>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@ -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<FeishuClientFactory> 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<String, Object> incoming = parseConfig(incomingConfigJson);
|
||||
Map<String, Object> existing = parseConfig(existingConfigJson);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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<MediaUploader> uploaderBeans;
|
||||
@Autowired private List<MediaSizePolicy> 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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>The scrubber rewrites differently per outcome because the user
|
||||
* experience differs:
|
||||
* <ul>
|
||||
* <li>Cache hit → upload as native attachment; bubble text shows
|
||||
* "📎 filename" so the link doesn't dangle.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user