sync: componentized media upload SPI + Feishu SDK-backed file sender

This commit is contained in:
matevip 2026-05-20 11:23:33 +08:00
parent 5cc567a689
commit 0e1b8ca564
21 changed files with 1772 additions and 11 deletions

View File

@ -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,

View File

@ -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 重连2s4s8s16s30s无限重试
this.mediaUploader = mediaUploader;
this.generatedFileScrubber = generatedFileScrubber;
// Feishu WebSocket reconnect: 2s4s8s16s30s, 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);

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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/");
}
}

View File

@ -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;
}
}

View File

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

View File

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

View File

@ -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");
}
}
}
}

View File

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

View File

@ -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");
}
}
}

View File

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

View File

@ -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;
}

View File

@ -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);

View File

@ -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();
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

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

View File

@ -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";
};
}
}