From f16021690f10f264c76eb84a51940c8f7022b84c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Fri, 22 May 2026 16:27:53 +0800 Subject: [PATCH] feat(tool): add send_file tool for sending existing server files as IM attachments (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tool): add send_file tool for sending existing server files as IM attachments Adds a new built-in tool that reads a file from the server and stashes it in GeneratedFileCache so the channel adapter (Feishu, DingTalk, etc.) automatically sends it as a native attachment. This fills the gap where agents had no way to send existing server files to users — ReadFileTool only reads text, and render tools only generate new files. - New SendFileTool with path validation, MIME detection, 20MB limit - Added "send_file" to tool allowlist in AgentBindingService - Added i18n error messages (zh-CN + en-US) * fix(tool): send_file returns URL in scrubber-detectable format The previous JSON return format caused the LLM to reply with just "status: sent" without echoing the /api/v1/files/generated/{id} URL. GeneratedFileScrubber only scans the LLM's final text output, so the file was never delivered as a native attachment. Changed to match GeneratedFileLink's format: returns a markdown link with explicit instructions for the LLM to echo the URL verbatim. --- .../binding/service/AgentBindingService.java | 1 + .../vip/mate/tool/builtin/SendFileTool.java | 163 ++++++++++++++++++ .../src/main/resources/messages.properties | 4 + .../src/main/resources/messages_en.properties | 4 + 4 files changed, 172 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 511e48f9..383b8462 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -606,6 +606,7 @@ public class AgentBindingService implements AgentBindingResolver { "search", "browser_use", "read_file", + "send_file", "write_file", "edit_file", "execute_shell_command", diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java new file mode 100644 index 00000000..b59ada24 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java @@ -0,0 +1,163 @@ +package vip.mate.tool.builtin; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; +import vip.mate.tool.document.GeneratedFileCache; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** + * 内置工具:将服务器上的现有文件发送给用户 + *

+ * 读取指定路径的文件,放入 {@link GeneratedFileCache} 生成临时下载链接, + * 由渠道适配器(飞书/钉钉/Telegram 等)自动检测并作为原生附件发送。 + *

+ * 与 {@link ReadFileTool} 不同,此工具处理任意文件类型(包括二进制文件), + * 目标是将文件作为附件发送而非读取其文本内容。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@lombok.RequiredArgsConstructor +public class SendFileTool { + + private final GeneratedFileCache cache; + private final vip.mate.i18n.I18nService i18n; + + private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB + + private static final Map EXTENSION_MIME = Map.ofEntries( + Map.entry(".pdf", "application/pdf"), + Map.entry(".doc", "application/msword"), + Map.entry(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + Map.entry(".xls", "application/vnd.ms-excel"), + Map.entry(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + Map.entry(".ppt", "application/vnd.ms-powerpoint"), + Map.entry(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + Map.entry(".txt", "text/plain"), + Map.entry(".csv", "text/csv"), + Map.entry(".json", "application/json"), + Map.entry(".xml", "application/xml"), + Map.entry(".html", "text/html"), + Map.entry(".htm", "text/html"), + Map.entry(".md", "text/markdown"), + Map.entry(".png", "image/png"), + Map.entry(".jpg", "image/jpeg"), + Map.entry(".jpeg", "image/jpeg"), + Map.entry(".gif", "image/gif"), + Map.entry(".svg", "image/svg+xml"), + Map.entry(".webp", "image/webp"), + Map.entry(".mp3", "audio/mpeg"), + Map.entry(".wav", "audio/wav"), + Map.entry(".ogg", "audio/ogg"), + Map.entry(".mp4", "video/mp4"), + Map.entry(".avi", "video/x-msvideo"), + Map.entry(".mov", "video/quicktime"), + Map.entry(".zip", "application/zip"), + Map.entry(".tar", "application/x-tar"), + Map.entry(".gz", "application/gzip"), + Map.entry(".yaml", "text/yaml"), + Map.entry(".yml", "text/yaml"), + Map.entry(".log", "text/plain") + ); + + @Tool(description = """ + Send an existing file from the server to the user as an attachment. \ + The file is uploaded to the IM channel as a native attachment (not a text link). \ + Works for any file type: documents (PDF, DOCX, XLSX, PPTX), images, \ + audio, video, archives, etc. Use this instead of read_file when you \ + need to send a binary file to the user.""") + public String send_file( + @ToolParam(description = "Absolute or relative file path on the server") String filePath, + @ToolParam(description = "Display name for the file (e.g. 'report.pdf'). Omit to use the original filename", required = false) String fileName, + @Nullable ToolContext ctx) { + + try { + Path path; + try { + path = WorkspacePathGuard.validatePath(filePath, ctx); + } catch (IllegalArgumentException e) { + // Sandbox rejected the literal path. Try chat-upload fallback. + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, e.getMessage()); + } + path = attachment; + } + + if (!Files.exists(path)) { + Path attachment = ChatUploadResolver.resolve(filePath); + if (attachment == null) { + return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path)); + } + path = attachment; + } + if (Files.isDirectory(path)) { + return errorResult(filePath, i18n.msg("tool.read_file.error.is_directory", path)); + } + if (!Files.isReadable(path)) { + return errorResult(filePath, i18n.msg("tool.read_file.error.not_readable", path)); + } + + long fileSize = Files.size(path); + if (fileSize > MAX_FILE_SIZE) { + return errorResult(filePath, i18n.msg("tool.send_file.error.too_large", + fileSize / 1024 / 1024, MAX_FILE_SIZE / 1024 / 1024)); + } + + byte[] bytes = Files.readAllBytes(path); + String displayName = (fileName != null && !fileName.isBlank()) ? fileName : path.getFileName().toString(); + String mimeType = resolveMimeType(displayName); + + String url = stash(bytes, displayName, mimeType); + + log.info("[SendFile] Sending {} ({}, {} bytes) via generated file cache", + displayName, mimeType, fileSize); + + // Return in the same format as GeneratedFileLink so the channel + // adapter's GeneratedFileScrubber detects the URL and sends the + // file as a native attachment. The LLM MUST echo the URL in its + // reply for the scrubber to pick it up. + return displayName + " 已发送:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n" + + "重要:回答用户时**必须**使用上述相对路径 `" + url + "`," + + "**不要**添加任何 https://、http:// 域名前缀,前端会自动拼接当前主机。"; + + } catch (Exception e) { + log.error("[SendFile] Failed to send file: {}", e.getMessage(), e); + return errorResult(filePath, i18n.msg("tool.send_file.error.failed", e.getMessage())); + } + } + + private String stash(byte[] bytes, String displayName, String mimeType) { + String id = cache.put(bytes, displayName, mimeType); + return "/api/v1/files/generated/" + id; + } + + private String resolveMimeType(String fileName) { + String lower = fileName.toLowerCase(); + for (Map.Entry entry : EXTENSION_MIME.entrySet()) { + if (lower.endsWith(entry.getKey())) { + return entry.getValue(); + } + } + return "application/octet-stream"; + } + + private String errorResult(String filePath, String message) { + JSONObject result = new JSONObject(); + result.set("filePath", filePath); + result.set("error", true); + result.set("message", message); + return JSONUtil.toJsonPrettyStr(result); + } +} diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 3b491805..297cbd6d 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -320,3 +320,7 @@ err.wiki.vision.all_failed=\u6240\u6709\u56fe\u7247\u8bc6\u522b provider \u8c03\ # --- Chat: assistant stop / interrupt placeholders --- chat.stopMarker.userAborted=[\u5df2\u88ab\u7528\u6237\u4e2d\u6b62] + +# --- Tool: send_file --- +tool.send_file.error.too_large=\u6587\u4ef6\u8fc7\u5927\uff1a{0}MB \u8d85\u51fa\u9650\u5236 {1}MB +tool.send_file.error.failed=\u53d1\u9001\u6587\u4ef6\u5931\u8d25\uff1a{0} diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index 859c5a4c..06d5012f 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -327,3 +327,7 @@ err.wiki.vision.all_failed=All image vision providers failed # --- Chat: assistant stop / interrupt placeholders --- chat.stopMarker.userAborted=[Stopped by user] + +# --- Tool: send_file --- +tool.send_file.error.too_large=File too large: {0}MB exceeds limit of {1}MB +tool.send_file.error.failed=Failed to send file: {0}