From 55982c490af40b163d9b99b652bcc7694ff086ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Fri, 29 May 2026 10:04:19 +0800 Subject: [PATCH] feat(feishu): auto-associate recent files with follow-up text messages (#201) Per-chat recent-file cache so files sent without @mention get injected into the follow-up text message's content parts. --- .../channel/feishu/FeishuChannelAdapter.java | 181 +++++++++++++++++- 1 file changed, 180 insertions(+), 1 deletion(-) 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 417a25be..20a9b7d9 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 @@ -20,6 +20,9 @@ import vip.mate.channel.media.MediaUploadResult; import vip.mate.channel.model.ChannelEntity; import vip.mate.workspace.conversation.model.MessageContentPart; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; @@ -29,6 +32,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -170,6 +174,25 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre */ private final vip.mate.stt.SttService sttService; + // ==================== Per-chat recent file cache ==================== + + /** + * Per-chat cache of recently downloaded file messages. When a file is + * sent in a Feishu chat (even without @mention), it is downloaded and + * cached here. When a follow-up text message arrives in the same chat, + * the cached files are injected as content parts so the agent can see + * and process them. + */ + private static final long RECENT_FILE_TTL_MINUTES = 60; + private static final int RECENT_FILE_MAX_PER_CHAT = 5; + + record RecentFileEntry(String fileName, String path, String fileUrl, String contentType) {} + + private final Cache> recentFileCache = Caffeine.newBuilder() + .expireAfterWrite(RECENT_FILE_TTL_MINUTES, TimeUnit.MINUTES) + .maximumSize(200) + .build(); + public FeishuChannelAdapter(ChannelEntity channelEntity, ChannelMessageRouter messageRouter, ObjectMapper objectMapper) { @@ -903,10 +926,23 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre private void handleFeishuMessage(String messageId, String messageType, String contentStr, String chatId, String chatType, String senderOpenId, String parentId, boolean isBotMentioned, Object rawPayload) { + // Per-chat recent file cache: always download file messages (even + // without @mention) so they can be auto-associated with follow-up + // text messages in the same chat. + boolean isGroup = "group".equals(chatType); + boolean isFileMessage = "file".equals(messageType) || "image".equals(messageType) + || "audio".equals(messageType) || "media".equals(messageType); + // Compute conversationId once — used as cache key for both write (cacheRecentFile) + // and read (injectRecentFiles), and as the directory name under data/chat-uploads/. + String conversationId = buildConversationId(chatId, senderOpenId, isGroup); + + if (isFileMessage) { + cacheRecentFile(messageId, messageType, contentStr, conversationId); + } + // require_mention 群聊过滤:群聊中必须 @机器人才响应。 // 当 botOpenId 为 null 时(API 抖动 / 尚未拉取成功),失败回退到放行 — // 避免飞书 /open-apis/bot/v3/info 短暂不可用时整个群机器人变哑巴。 - boolean isGroup = "group".equals(chatType); boolean requireMention = getConfigBoolean("require_mention", false); if (isGroupNonMentionDrop(isGroup, requireMention, isBotMentioned, botOpenId)) { log.debug("[feishu] require_mention=true but bot not mentioned, dropping messageId={}", messageId); @@ -956,6 +992,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre } } + // Auto-associate recent files: inject file/image parts from the + // per-chat cache so the agent can see files sent earlier in the + // same conversation (user sends file → asks about it in text). + if (!isFileMessage && conversationId != null) { + textContent = injectRecentFiles(conversationId, contentParts, textContent); + } + // 生成短会话后缀 String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup); @@ -1355,6 +1398,142 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return null; } + /** + * Compute the conversationId that {@link ChannelMessageRouter} would + * derive from the same chat/sender fields, so we can save inbound + * files to the matching {@code data/chat-uploads/} directory. + */ + private String buildConversationId(String chatId, String senderOpenId, boolean isGroup) { + // Mirror ChannelMessageRouter#buildConversationId: + // groups → feishu:{chatId}, DMs → feishu:{full senderOpenId} + String identifier = chatId != null ? chatId : senderOpenId; + return identifier != null ? CHANNEL_TYPE + ":" + identifier : null; + } + + // ==================== Per-chat recent file cache ==================== + + /** + * Download an inbound file message and cache its metadata in the + * per-chat recent-file cache. The file is saved to + * {@code data/chat-uploads/{conversationId}/} so existing tools + * ({@code ReadFileTool}, {@code DocumentExtractTool}) can find it + * via {@code ChatUploadResolver}, and it gets cleaned up when the + * conversation is deleted. + */ + private void cacheRecentFile(String messageId, String messageType, String contentStr, + String conversationId) { + try { + Map contentObj = objectMapper.readValue(contentStr, Map.class); + + String fileKey = null; + String fileName = null; + String type; // SDK type: "image" or "file" + + switch (messageType) { + case "image" -> { + fileKey = (String) contentObj.get("image_key"); + type = "image"; + } + case "file" -> { + fileKey = (String) contentObj.get("file_key"); + fileName = (String) contentObj.get("file_name"); + type = "file"; + } + case "audio" -> { + fileKey = (String) contentObj.get("file_key"); + type = "file"; + } + case "media" -> { + fileKey = (String) contentObj.get("file_key"); + fileName = (String) contentObj.get("file_name"); + type = "file"; + } + default -> { + return; + } + } + + if (fileKey == null) return; + + // Download file bytes + DownloadedResource dl = "image".equals(messageType) + ? maybeDownloadImage(messageId, fileKey) + : maybeDownloadResource(messageId, fileKey, type, fileName); + if (dl == null) return; + + // Save to data/chat-uploads/{conversationId}/ + Path uploadDir = Path.of("data", "chat-uploads", conversationId); + Files.createDirectories(uploadDir); + String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) + ? dl.fileName() : fileKey; + String safeName = Path.of(rawName).getFileName().toString() + .replaceAll("[^a-zA-Z0-9._-]", "_"); + if (safeName.isBlank()) safeName = "file"; + String storedName = System.currentTimeMillis() + "_" + safeName; + Path dest = uploadDir.resolve(storedName); + Files.copy(Path.of(dl.path()), dest, StandardCopyOption.REPLACE_EXISTING); + + String contentType = dl.contentType() != null ? dl.contentType() : "application/octet-stream"; + RecentFileEntry entry = new RecentFileEntry(safeName, dest.toAbsolutePath().toString(), + dl.fileUrl(), contentType); + + // Append to per-conversation cache (cap at RECENT_FILE_MAX_PER_CHAT) + recentFileCache.asMap().compute(conversationId, (k, existing) -> { + List list = existing != null ? new ArrayList<>(existing) : new ArrayList<>(); + list.add(entry); + if (list.size() > RECENT_FILE_MAX_PER_CHAT) { + list = list.subList(list.size() - RECENT_FILE_MAX_PER_CHAT, list.size()); + } + return list; + }); + + log.info("[feishu] Cached recent file for conversation={}: {} ({} bytes, {})", + conversationId, entry.fileName(), Files.size(dest), contentType); + + } catch (Exception e) { + log.debug("[feishu] Failed to cache recent file: {}", e.getMessage()); + } + } + + /** + * Inject recent files from the per-chat cache into the current + * message's content parts, so the agent can see files that were + * sent earlier in the same conversation. + * + * @return updated textContent with file descriptions appended + */ + private String injectRecentFiles(String conversationId, List parts, String textContent) { + List recent = recentFileCache.getIfPresent(conversationId); + if (recent == null || recent.isEmpty()) return textContent; + + // Collect paths already in parts to avoid duplicates + Set existingPaths = new java.util.HashSet<>(); + for (MessageContentPart p : parts) { + if (p != null && p.getPath() != null) existingPaths.add(p.getPath()); + } + + StringBuilder text = new StringBuilder(textContent != null ? textContent : ""); + for (RecentFileEntry entry : recent) { + if (existingPaths.contains(entry.path())) continue; + + MessageContentPart part = new MessageContentPart(); + if (entry.contentType() != null && entry.contentType().startsWith("image/")) { + part.setType("image"); + } else { + part.setType("file"); + } + part.setFileName(entry.fileName()); + part.setPath(entry.path()); + if (entry.fileUrl() != null) part.setFileUrl(entry.fileUrl()); + part.setContentType(entry.contentType()); + parts.add(part); + + if (!text.isEmpty()) text.append('\n'); + text.append("[用户发送了文件: ").append(entry.fileName()).append("]"); + } + return text.toString(); + } + // ==================== 消息内容解析 ==================== /**