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.
This commit is contained in:
倪程伟 2026-05-29 10:04:19 +08:00 committed by GitHub
parent 237f649e7e
commit 55982c490a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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<String, List<RecentFileEntry>> 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<String, Object> 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<RecentFileEntry> 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<MessageContentPart> parts, String textContent) {
List<RecentFileEntry> recent = recentFileCache.getIfPresent(conversationId);
if (recent == null || recent.isEmpty()) return textContent;
// Collect paths already in parts to avoid duplicates
Set<String> 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();
}
// ==================== 消息内容解析 ====================
/**