From 6b397a10ed90e3c27baef481268e36fc10fd11f2 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 20 May 2026 15:53:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(feishu):=20inbound=20file/audio/video=20do?= =?UTF-8?q?wnload=20=E2=80=94=20SDK=20path=20+=20cache=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/vip/mate/channel/ChannelManager.java | 12 +- .../channel/feishu/FeishuChannelAdapter.java | 322 ++++++++++++++---- .../channel/ChannelManagerReconcileTest.java | 1 + .../feishu/FeishuInboundResourceTest.java | 170 +++++++++ 4 files changed, 447 insertions(+), 58 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java index 8eeb5f4c..68cbd465 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java @@ -108,6 +108,16 @@ public class ChannelManager { */ private final vip.mate.channel.feishu.cards.FeishuCardDispatcher feishuCardDispatcher; + /** + * Per-channelId SDK {@link com.lark.oapi.Client} cache. Injected into + * {@link vip.mate.channel.feishu.FeishuChannelAdapter} so inbound + * file/image/audio/video downloads go through + * {@code client.im().v1().messageResource().get(...)} instead of the + * legacy hand-rolled HTTP path — token refresh, retries, and domain + * switching are then handled by the SDK. + */ + private final vip.mate.channel.feishu.FeishuClientFactory feishuClientFactory; + /** * Distributed leader election. Channels whose adapter reports * {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so @@ -1175,7 +1185,7 @@ public class ChannelManager { case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager, - feishuCardDispatcher); + feishuCardDispatcher, feishuClientFactory, generatedFileCache); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 36ed7972..735f9311 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -137,10 +137,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre /** Interactive-card dispatcher (approval cards etc.). Nullable for legacy callers / tests. */ private final vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher; + /** + * Per-channel SDK {@code Client} factory — drives inbound resource + * downloads via {@code client.im().v1().messageResource().get(...)} + * instead of the legacy hand-rolled {@code HttpClient} path. Nullable + * for legacy callers / tests, in which case the adapter falls back + * to the raw HTTP path (still works, just bypasses retries / token + * refresh / domain switching that the SDK handles). + */ + private final FeishuClientFactory clientFactory; + + /** + * Process-local cache that exposes downloaded inbound media via + * {@code /api/v1/files/generated/{id}} so the conversation memory + * and admin UI can render the file without holding a tenant token. + * Nullable for legacy callers / tests. + */ + private final vip.mate.tool.document.GeneratedFileCache generatedFileCache; + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { - this(channelEntity, messageRouter, objectMapper, null, null, null, null); + this(channelEntity, messageRouter, objectMapper, null, null, null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -148,7 +166,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre ObjectMapper objectMapper, FeishuMediaUploader mediaUploader, GeneratedFileScrubber generatedFileScrubber) { - this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, null, null); + this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, + null, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -158,7 +177,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre GeneratedFileScrubber generatedFileScrubber, FeishuStreamingCardManager streamingCardManager) { this(channelEntity, messageRouter, objectMapper, mediaUploader, - generatedFileScrubber, streamingCardManager, null); + generatedFileScrubber, streamingCardManager, null, null, null); } public FeishuChannelAdapter(ChannelEntity channelEntity, @@ -168,11 +187,26 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre GeneratedFileScrubber generatedFileScrubber, FeishuStreamingCardManager streamingCardManager, vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher) { + this(channelEntity, messageRouter, objectMapper, mediaUploader, + generatedFileScrubber, streamingCardManager, cardDispatcher, null, null); + } + + public FeishuChannelAdapter(ChannelEntity channelEntity, + ChannelMessageRouter messageRouter, + ObjectMapper objectMapper, + FeishuMediaUploader mediaUploader, + GeneratedFileScrubber generatedFileScrubber, + FeishuStreamingCardManager streamingCardManager, + vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher, + FeishuClientFactory clientFactory, + vip.mate.tool.document.GeneratedFileCache generatedFileCache) { super(channelEntity, messageRouter, objectMapper); this.mediaUploader = mediaUploader; this.generatedFileScrubber = generatedFileScrubber; this.streamingCardManager = streamingCardManager; this.cardDispatcher = cardDispatcher; + this.clientFactory = clientFactory; + this.generatedFileCache = generatedFileCache; // Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); } @@ -1276,11 +1310,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // image. Default-on for images (separate from the // file/audio/video gate) so vision works out of the // box; admins can opt out with feishu_image_download_enabled=false. - String localPath = maybeDownloadImage(messageId, imageKey); + DownloadedResource dl = maybeDownloadImage(messageId, imageKey); MessageContentPart part = MessageContentPart.image(imageKey, null); - if (localPath != null) { - part.setPath(localPath); - } + applyDownload(part, dl); parts.add(part); } yield "[图片]"; @@ -1289,9 +1321,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre String fileKey = (String) contentObj.get("file_key"); String fileName = (String) contentObj.get("file_name"); if (fileKey != null) { - String localPath = maybeDownloadResource(messageId, fileKey, "file", fileName); + DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName); MessageContentPart part = MessageContentPart.file(fileKey, fileName, null); - if (localPath != null) part.setPath(localPath); + applyDownload(part, dl); parts.add(part); } yield "[文件: " + (fileName != null ? fileName : "") + "]"; @@ -1299,9 +1331,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre case "audio" -> { String fileKey = (String) contentObj.get("file_key"); if (fileKey != null) { - String localPath = maybeDownloadResource(messageId, fileKey, "file", null); + DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", null); MessageContentPart part = MessageContentPart.audio(fileKey, null); - if (localPath != null) part.setPath(localPath); + applyDownload(part, dl); parts.add(part); } yield "[音频]"; @@ -1310,9 +1342,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre String fileKey = (String) contentObj.get("file_key"); String fileName = (String) contentObj.get("file_name"); if (fileKey != null) { - String localPath = maybeDownloadResource(messageId, fileKey, "file", fileName); + DownloadedResource dl = maybeDownloadResource(messageId, fileKey, "file", fileName); MessageContentPart part = MessageContentPart.video(fileKey, fileName); - if (localPath != null) part.setPath(localPath); + applyDownload(part, dl); parts.add(part); } yield "[视频]"; @@ -1388,7 +1420,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre (List>>) localeBranch.get("content"); if (paragraphs == null) return text.toString().trim(); - boolean mediaDownload = getConfigBoolean("media_download_enabled", false); + // Same default-on behaviour as the standalone file/audio/video branch — see maybeDownloadResource. + boolean mediaDownload = getConfigBoolean("media_download_enabled", true); for (int i = 0; i < paragraphs.size(); i++) { List> paragraph = paragraphs.get(i); @@ -1432,9 +1465,9 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre if (imageKey != null) { // Same reasoning as the standalone image case: vision // pipelines need bytes; image_key alone is opaque. - String localPath = maybeDownloadImage(messageId, imageKey); + DownloadedResource dl = maybeDownloadImage(messageId, imageKey); MessageContentPart imgPart = MessageContentPart.image(imageKey, null); - if (localPath != null) imgPart.setPath(localPath); + applyDownload(imgPart, dl); parts.add(imgPart); text.append("[图片]"); } @@ -1442,9 +1475,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre case "media" -> { String fileKey = (String) element.get("file_key"); if (fileKey != null) { - String localPath = mediaDownload ? maybeDownloadResource(messageId, fileKey, "file", null) : null; + DownloadedResource dl = mediaDownload + ? maybeDownloadResource(messageId, fileKey, "file", null) + : null; MessageContentPart mediaPart = MessageContentPart.file(fileKey, null, null); - if (localPath != null) mediaPart.setPath(localPath); + applyDownload(mediaPart, dl); parts.add(mediaPart); text.append("[媒体]"); } @@ -1469,13 +1504,44 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return result.isEmpty() ? null : result; } - // ==================== 媒体文件下载 ==================== + // ==================== Inbound media download ==================== /** - * 如果 media_download_enabled 则下载资源,否则返回 null + * Result of a successful inbound-resource download. + * + * @param path absolute path on disk under + * {@code ~/.mateclaw/media/feishu/} — fed to + * {@link MessageContentPart#setPath(String)} so + * vision / file-reading tools can consume bytes + * @param fileUrl {@code /api/v1/files/generated/{id}} URL backed + * by {@link vip.mate.tool.document.GeneratedFileCache} + * (10-min TTL) so the admin UI and conversation + * memory can render the file without a tenant + * token; {@code null} when the cache wasn't wired + * in (e.g. legacy tests) + * @param fileName server-side filename (extension inferred from + * content-type, falls back to {@code fileNameHint} + * or the SDK-returned name) + * @param contentType MIME type from the download response headers */ - private String maybeDownloadResource(String messageId, String fileKey, String type, String fileNameHint) { - if (!getConfigBoolean("media_download_enabled", false)) { + // Package-private for unit tests — pin the field copy contract. + record DownloadedResource(String path, String fileUrl, + String fileName, String contentType) {} + + /** + * Download a {@code file} / {@code audio} / {@code video} inbound + * resource, but only when the per-channel + * {@code media_download_enabled} flag is on. Default is now true + * so a user uploading a file to the bot lands as a real file the + * agent can read — set it to {@code false} on the channel config to + * opt out (e.g. tighter privacy, no disk usage). + * + *

Mirrors the WeCom / DingTalk pattern: text-only adapters dropping + * file bytes was the "did you receive my doc?" gap reported in + * production. + */ + private DownloadedResource maybeDownloadResource(String messageId, String fileKey, String type, String fileNameHint) { + if (!getConfigBoolean("media_download_enabled", true)) { return null; } return downloadResource(messageId, fileKey, type, fileNameHint); @@ -1490,7 +1556,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre * downloads (e.g. tighter privacy, no disk usage) can set * {@code feishu_image_download_enabled=false} on the channel config. */ - private String maybeDownloadImage(String messageId, String imageKey) { + private DownloadedResource maybeDownloadImage(String messageId, String imageKey) { if (!getConfigBoolean("feishu_image_download_enabled", true)) { return null; } @@ -1498,15 +1564,87 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre } /** - * 下载飞书消息资源(图片/文件)到本地 - * - * @param messageId 消息 ID - * @param fileKey 资源 key(image_key 或 file_key) - * @param type 资源类型:"image" 或 "file" - * @param fileNameHint 文件名提示(可选) - * @return 本地文件路径,失败返回 null + * Copy a {@link DownloadedResource}'s fields onto a + * {@link MessageContentPart}. {@code null} input is a no-op so the + * caller can keep the part as a bare {@code file_key} placeholder + * when download was disabled / failed. */ - private String downloadResource(String messageId, String fileKey, String type, String fileNameHint) { + // Package-private for unit tests. + static void applyDownload(MessageContentPart part, DownloadedResource dl) { + if (dl == null || part == null) return; + if (dl.path() != null) part.setPath(dl.path()); + if (dl.fileUrl() != null) part.setFileUrl(dl.fileUrl()); + if (dl.fileName() != null && (part.getFileName() == null || part.getFileName().isBlank())) { + part.setFileName(dl.fileName()); + } + if (dl.contentType() != null) part.setContentType(dl.contentType()); + } + + /** + * Pull an inbound resource (image / file / audio / video) from + * Feishu and persist it both to disk and (best-effort) to the + * {@link vip.mate.tool.document.GeneratedFileCache}. + * + *

Implementation prefers the official {@code oapi-sdk} + * ({@code client.im().v1().messageResource().get(...)}) when a + * {@link FeishuClientFactory} was wired in — gets token refresh, + * domain switching (feishu / lark), retries, and the same + * {@code Authorization} contract the rest of the SDK uses for free. + * Falls back to the legacy hand-rolled {@code HttpClient} path + * when no factory is available (3-arg legacy ctor / unit tests). + * + * @param messageId Feishu message id ({@code om_…}) — required + * by the resource endpoint to scope the file + * @param fileKey resource key ({@code img_…} for images, + * {@code file_…} for everything else) + * @param type SDK type discriminator: {@code "image"} for + * images, {@code "file"} for file/audio/video + * @param fileNameHint optional original filename (used for + * extension inference when the response has no + * useful Content-Type) + * @return a {@link DownloadedResource} on success, {@code null} on + * any failure (logged at debug) — caller treats {@code null} + * as "skip, just pass the opaque key through" + */ + private DownloadedResource downloadResource(String messageId, String fileKey, String type, String fileNameHint) { + // ---- Prefer SDK path: token refresh, retries, domain handled by the SDK + if (clientFactory != null && channelEntity != null && channelEntity.getId() != null) { + try { + com.lark.oapi.Client client = clientFactory.client(channelEntity.getId()); + com.lark.oapi.service.im.v1.model.GetMessageResourceReq req = + com.lark.oapi.service.im.v1.model.GetMessageResourceReq.newBuilder() + .messageId(messageId) + .fileKey(fileKey) + .type(type) + .build(); + com.lark.oapi.service.im.v1.model.GetMessageResourceResp resp = + client.im().v1().messageResource().get(req); + if (!resp.success() || resp.getData() == null) { + log.debug("[feishu] SDK resource.get failed: code={}, msg={}", + resp.getCode(), resp.getMsg()); + return null; + } + byte[] bytes = resp.getData().toByteArray(); + // SDK exposes Content-Disposition's filename via getFileName(); + // prefer it over the hint when present. + String sdkFileName = resp.getFileName(); + String preferredHint = (sdkFileName != null && !sdkFileName.isBlank()) + ? sdkFileName : fileNameHint; + // No Content-Type accessor on GetMessageResourceResp — infer + // from filename extension (good enough for cache MIME and + // for vision pipelines that key on extension). + String contentType = inferMimeFromName(preferredHint); + return persistAndCache(messageId, fileKey, bytes, preferredHint, contentType); + } catch (Exception e) { + log.debug("[feishu] SDK resource.get threw, falling back to raw HTTP: {}", e.getMessage()); + // Fall through to raw HTTP — keeps the inbound path resilient + // when the SDK is temporarily unhappy (e.g. invalid req shape + // on a new event type the SDK doesn't yet model). + } + } + + // ---- Legacy fallback: hand-rolled HTTP. Kept so the 3-arg ctor + // and unit tests that don't wire a factory still get bytes. try { ensureTokenValid(); String apiBase = getApiBaseUrl(); @@ -1532,32 +1670,10 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre log.debug("[feishu] Download resource failed: status={}", response.statusCode()); return null; } - - // 构建目标目录 - Path mediaDir = Path.of(System.getProperty("user.home"), ".mateclaw", "media", "feishu"); - Files.createDirectories(mediaDir); - - // 安全文件名 - String safeKey = fileKey.replaceAll("[^a-zA-Z0-9_]", ""); - if (safeKey.isEmpty()) safeKey = "file"; - - // 推断扩展名 - String ext = "bin"; - String contentType = response.headers().firstValue("Content-Type").orElse(""); - if (contentType.contains("jpeg") || contentType.contains("jpg")) ext = "jpg"; - else if (contentType.contains("png")) ext = "png"; - else if (contentType.contains("gif")) ext = "gif"; - else if (contentType.contains("webp")) ext = "webp"; - else if (contentType.contains("pdf")) ext = "pdf"; - else if (fileNameHint != null && fileNameHint.contains(".")) { - ext = fileNameHint.substring(fileNameHint.lastIndexOf('.') + 1); - } - - Path filePath = mediaDir.resolve(messageId + "_" + safeKey + "." + ext); - Files.copy(is, filePath, StandardCopyOption.REPLACE_EXISTING); - - log.debug("[feishu] Downloaded resource to: {}", filePath); - return filePath.toAbsolutePath().toString(); + String contentType = response.headers().firstValue("Content-Type") + .orElse(inferMimeFromName(fileNameHint)); + byte[] bytes = is.readAllBytes(); + return persistAndCache(messageId, fileKey, bytes, fileNameHint, contentType); } } catch (Exception e) { @@ -1566,6 +1682,98 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre } } + /** + * Write {@code bytes} to {@code ~/.mateclaw/media/feishu/} and, + * if {@link #generatedFileCache} is wired, also push them into the + * cache so the UI gets a {@code /api/v1/files/generated/{id}} URL. + * Disk persist is always attempted; cache push is best-effort. + */ + private DownloadedResource persistAndCache(String messageId, String fileKey, + byte[] bytes, String fileNameHint, + String contentType) throws java.io.IOException { + Path mediaDir = Path.of(System.getProperty("user.home"), ".mateclaw", "media", "feishu"); + Files.createDirectories(mediaDir); + + String safeKey = fileKey.replaceAll("[^a-zA-Z0-9_]", ""); + if (safeKey.isEmpty()) safeKey = "file"; + + String ext = extensionFor(contentType, fileNameHint); + String resolvedFileName = (fileNameHint != null && !fileNameHint.isBlank()) + ? fileNameHint + : (safeKey + "." + ext); + + Path filePath = mediaDir.resolve(messageId + "_" + safeKey + "." + ext); + Files.write(filePath, bytes); + log.debug("[feishu] Downloaded resource to: {} ({} bytes, {})", + filePath, bytes.length, contentType); + + // Best-effort cache push so the UI / agent can reference the file + // via a tokenless URL. Failure here doesn't kill the inbound path — + // the local path on its own is still useful for vision tools. + String fileUrl = null; + if (generatedFileCache != null) { + try { + String id = generatedFileCache.put(bytes, resolvedFileName, contentType); + fileUrl = "/api/v1/files/generated/" + id; + } catch (Exception cacheEx) { + log.debug("[feishu] cache put failed: {}", cacheEx.getMessage()); + } + } + return new DownloadedResource(filePath.toAbsolutePath().toString(), + fileUrl, resolvedFileName, contentType); + } + + /** Map content-type / file-name hint to a short extension for the on-disk filename. */ + // Package-private for unit tests. + static String extensionFor(String contentType, String fileNameHint) { + if (contentType != null) { + String ct = contentType.toLowerCase(java.util.Locale.ROOT); + if (ct.contains("jpeg") || ct.contains("jpg")) return "jpg"; + if (ct.contains("png")) return "png"; + if (ct.contains("gif")) return "gif"; + if (ct.contains("webp")) return "webp"; + if (ct.contains("pdf")) return "pdf"; + if (ct.contains("opus")) return "opus"; + if (ct.contains("mpeg") || ct.contains("mp3")) return "mp3"; + if (ct.contains("mp4")) return "mp4"; + if (ct.contains("wordprocessing") || ct.contains("msword")) return "docx"; + if (ct.contains("spreadsheet") || ct.contains("excel")) return "xlsx"; + if (ct.contains("presentation") || ct.contains("powerpoint")) return "pptx"; + } + if (fileNameHint != null && fileNameHint.contains(".")) { + String hint = fileNameHint.substring(fileNameHint.lastIndexOf('.') + 1) + .toLowerCase(java.util.Locale.ROOT); + if (!hint.isBlank()) return hint; + } + return "bin"; + } + + /** + * Best-effort MIME guess from a filename — the SDK download response + * doesn't expose Content-Type, so we fall back to the extension. Good + * enough for the cache (used for the {@code Content-Type} header the + * UI sets when re-serving) and for vision providers (most key on + * extension anyway). + */ + // Package-private for unit tests. + static String inferMimeFromName(String fileName) { + if (fileName == null) return "application/octet-stream"; + String lower = fileName.toLowerCase(java.util.Locale.ROOT); + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".pdf")) return "application/pdf"; + if (lower.endsWith(".opus")) return "audio/opus"; + if (lower.endsWith(".mp3")) return "audio/mpeg"; + if (lower.endsWith(".mp4")) return "video/mp4"; + if (lower.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + if (lower.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + if (lower.endsWith(".pptx")) return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + if (lower.endsWith(".txt") || lower.endsWith(".md")) return "text/plain"; + return "application/octet-stream"; + } + // ==================== 消息发送 ==================== /** diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java index 4db97a05..040af111 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java @@ -60,6 +60,7 @@ class ChannelManagerReconcileTest { mock(vip.mate.channel.media.GeneratedFileScrubber.class), mock(vip.mate.channel.feishu.FeishuStreamingCardManager.class), mock(vip.mate.channel.feishu.cards.FeishuCardDispatcher.class), + mock(vip.mate.channel.feishu.FeishuClientFactory.class), election); adapter = new TrackingAdapter(); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java new file mode 100644 index 00000000..d916ed39 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuInboundResourceTest.java @@ -0,0 +1,170 @@ +package vip.mate.channel.feishu; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.workspace.conversation.model.MessageContentPart; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Pin the inbound-resource download contract for Feishu: + * + *

    + *
  1. {@link FeishuChannelAdapter#extensionFor} maps every supported + * inbound content-type to a sensible on-disk extension — important + * because vision providers and file-reading tools often key on the + * extension, not the MIME header.
  2. + *
  3. {@link FeishuChannelAdapter#inferMimeFromName} round-trips the + * same set so the {@link vip.mate.tool.document.GeneratedFileCache} + * entry (which only stores MIME, no headers) renders correctly in + * the admin UI.
  4. + *
  5. {@link FeishuChannelAdapter#applyDownload} copies every populated + * field from a {@link FeishuChannelAdapter.DownloadedResource} onto + * the outbound {@link MessageContentPart} — and tolerates {@code + * null} input so the legacy "download disabled, keep the bare + * file_key" path still produces a valid part.
  6. + *
+ * + *

These are the contracts that broke production before this change: + * when a user uploaded a PDF to the bot, the adapter only emitted a + * {@code file_key} placeholder and the agent never saw the bytes. + */ +class FeishuInboundResourceTest { + + // ------------------------------------------------------------------ + // extensionFor — Content-Type → on-disk extension + // ------------------------------------------------------------------ + + @Test + @DisplayName("extensionFor: image content-types map to standard image extensions") + void extensionForImages() { + assertEquals("jpg", FeishuChannelAdapter.extensionFor("image/jpeg", null)); + assertEquals("jpg", FeishuChannelAdapter.extensionFor("image/jpg", null)); + assertEquals("png", FeishuChannelAdapter.extensionFor("image/png", null)); + assertEquals("gif", FeishuChannelAdapter.extensionFor("image/gif", null)); + assertEquals("webp", FeishuChannelAdapter.extensionFor("image/webp", null)); + } + + @Test + @DisplayName("extensionFor: office and document MIMEs map correctly") + void extensionForOfficeDocs() { + assertEquals("pdf", FeishuChannelAdapter.extensionFor("application/pdf", null)); + assertEquals("docx", FeishuChannelAdapter.extensionFor( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", null)); + assertEquals("xlsx", FeishuChannelAdapter.extensionFor( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", null)); + assertEquals("pptx", FeishuChannelAdapter.extensionFor( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", null)); + } + + @Test + @DisplayName("extensionFor: audio + video MIMEs map correctly") + void extensionForMedia() { + assertEquals("opus", FeishuChannelAdapter.extensionFor("audio/opus", null)); + assertEquals("mp3", FeishuChannelAdapter.extensionFor("audio/mpeg", null)); + assertEquals("mp4", FeishuChannelAdapter.extensionFor("video/mp4", null)); + } + + @Test + @DisplayName("extensionFor: unknown content-type falls back to filename hint, then 'bin'") + void extensionForFallback() { + // Filename hint when content-type is unhelpful + assertEquals("zip", FeishuChannelAdapter.extensionFor("application/octet-stream", "report.zip")); + assertEquals("csv", FeishuChannelAdapter.extensionFor(null, "data.csv")); + // No hint and no recognisable content-type → bin sentinel + assertEquals("bin", FeishuChannelAdapter.extensionFor(null, null)); + assertEquals("bin", FeishuChannelAdapter.extensionFor("application/x-weird", null)); + // Filename hint with no dot is not a usable extension + assertEquals("bin", FeishuChannelAdapter.extensionFor(null, "noextension")); + } + + // ------------------------------------------------------------------ + // inferMimeFromName — round-trip from filename to MIME + // ------------------------------------------------------------------ + + @Test + @DisplayName("inferMimeFromName: common extensions resolve to the right MIME") + void inferMimeImages() { + assertEquals("image/jpeg", FeishuChannelAdapter.inferMimeFromName("photo.jpg")); + assertEquals("image/jpeg", FeishuChannelAdapter.inferMimeFromName("PHOTO.JPEG")); + assertEquals("image/png", FeishuChannelAdapter.inferMimeFromName("screenshot.png")); + assertEquals("image/gif", FeishuChannelAdapter.inferMimeFromName("anim.gif")); + assertEquals("image/webp", FeishuChannelAdapter.inferMimeFromName("avatar.webp")); + } + + @Test + @DisplayName("inferMimeFromName: office docs + media") + void inferMimeDocsAndMedia() { + assertEquals("application/pdf", FeishuChannelAdapter.inferMimeFromName("contract.pdf")); + assertEquals("audio/opus", FeishuChannelAdapter.inferMimeFromName("voice.opus")); + assertEquals("audio/mpeg", FeishuChannelAdapter.inferMimeFromName("track.mp3")); + assertEquals("video/mp4", FeishuChannelAdapter.inferMimeFromName("clip.mp4")); + assertEquals("text/plain", FeishuChannelAdapter.inferMimeFromName("README.md")); + } + + @Test + @DisplayName("inferMimeFromName: null or unknown returns generic octet-stream") + void inferMimeUnknown() { + assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName(null)); + assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName("mystery.xyz")); + assertEquals("application/octet-stream", FeishuChannelAdapter.inferMimeFromName("noext")); + } + + // ------------------------------------------------------------------ + // applyDownload — copy DownloadedResource onto MessageContentPart + // ------------------------------------------------------------------ + + @Test + @DisplayName("applyDownload: copies path / fileUrl / fileName / contentType") + void applyDownloadFullCopy() { + MessageContentPart part = MessageContentPart.file("file_abc", null, null); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + "/tmp/mateclaw/feishu/om_x_file_abc.pdf", + "/api/v1/files/generated/uuid-1", + "contract.pdf", + "application/pdf"); + FeishuChannelAdapter.applyDownload(part, dl); + assertEquals("/tmp/mateclaw/feishu/om_x_file_abc.pdf", part.getPath()); + assertEquals("/api/v1/files/generated/uuid-1", part.getFileUrl()); + assertEquals("contract.pdf", part.getFileName()); + assertEquals("application/pdf", part.getContentType()); + } + + @Test + @DisplayName("applyDownload: null download leaves the bare key part untouched (legacy fallback)") + void applyDownloadNullIsNoop() { + MessageContentPart part = MessageContentPart.file("file_abc", "original.pdf", "application/pdf"); + FeishuChannelAdapter.applyDownload(part, null); + assertNull(part.getPath()); + assertNull(part.getFileUrl()); + // Pre-existing fields preserved — the "download disabled / failed" path + // still produces a valid part the outbound side can handle as an opaque key. + assertEquals("file_abc", part.getMediaId()); + assertEquals("original.pdf", part.getFileName()); + assertEquals("application/pdf", part.getContentType()); + } + + @Test + @DisplayName("applyDownload: preserves pre-existing fileName when caller already had one") + void applyDownloadPreservesExistingName() { + MessageContentPart part = MessageContentPart.file("file_abc", "user-named.pdf", null); + FeishuChannelAdapter.DownloadedResource dl = new FeishuChannelAdapter.DownloadedResource( + "/tmp/path.pdf", "/url", "server-derived.pdf", "application/pdf"); + FeishuChannelAdapter.applyDownload(part, dl); + // User-provided file_name wins over server-derived name so the + // bubble matches what the sender typed. + assertEquals("user-named.pdf", part.getFileName()); + assertEquals("/tmp/path.pdf", part.getPath()); + assertEquals("/url", part.getFileUrl()); + } + + @Test + @DisplayName("applyDownload: tolerates null part defensively (router never sees NPE)") + void applyDownloadNullPart() { + // Just assert no exception. This is the contract that lets the + // inbound parser keep going when an upstream event is malformed. + FeishuChannelAdapter.applyDownload(null, new FeishuChannelAdapter.DownloadedResource( + "/x", "/y", "z", "t")); + } +}