From 45d2edef5cce6b5d118ed298bdf122c7f2ea6775 Mon Sep 17 00:00:00 2001 From: XZB-1248 <28593573+XZB-1248@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:11:47 +0800 Subject: [PATCH] feat: add file attachment support to message tool --- pkg/agent/loop.go | 9 ++-- pkg/bus/types.go | 12 +++-- pkg/channels/discord.go | 55 +++++++++++++++++++++++ pkg/channels/slack.go | 48 ++++++++++++++++++++ pkg/channels/telegram.go | 70 +++++++++++++++++++++++++++++ pkg/tools/message.go | 41 ++++++++++++++++- pkg/tools/message_test.go | 92 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 314 insertions(+), 13 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ed69712ff..18190f314 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -107,11 +107,12 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A // Message tool messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { + messageTool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error { msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, + Channel: channel, + ChatID: chatID, + Content: content, + Attachments: attachments, }) return nil }) diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 44f9181a5..7b14fc18e 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -10,10 +10,16 @@ type InboundMessage struct { Metadata map[string]string `json:"metadata,omitempty"` } +type Attachment struct { + Path string `json:"path"` + Filename string `json:"filename"` +} + type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Attachments []Attachment `json:"attachments,omitempty"` } type MessageHandler func(InboundMessage) error diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 472b51c53..34d33e6c2 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -100,6 +100,11 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel ID is empty") } + // If there are attachments, send them with the message + if len(msg.Attachments) > 0 { + return c.sendWithAttachments(ctx, channelID, msg.Content, msg.Attachments) + } + runes := []rune(msg.Content) if len(runes) == 0 { return nil @@ -116,6 +121,56 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } +func (c *DiscordChannel) sendWithAttachments(ctx context.Context, channelID, content string, attachments []bus.Attachment) error { + sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + files := make([]*discordgo.File, 0, len(attachments)) + openedFiles := make([]*os.File, 0, len(attachments)) + + // Clean up all opened files when done + defer func() { + for _, f := range openedFiles { + f.Close() + } + }() + + for _, attachment := range attachments { + file, err := os.Open(attachment.Path) + if err != nil { + done <- fmt.Errorf("failed to open attachment %s: %w", attachment.Path, err) + return + } + openedFiles = append(openedFiles, file) + + files = append(files, &discordgo.File{ + Name: attachment.Filename, + Reader: file, + }) + } + + messageData := &discordgo.MessageSend{ + Content: content, + Files: files, + } + + _, err := c.session.ChannelMessageSendComplex(channelID, messageData) + done <- err + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to send discord message with attachments: %w", err) + } + return nil + case <-sendCtx.Done(): + return fmt.Errorf("send message timeout: %w", sendCtx.Err()) + } +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // 使用传入的 ctx 进行超时控制 sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index 0060972ed..a27cd1b7d 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -119,6 +119,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } + // If there are attachments, send them + if len(msg.Attachments) > 0 { + return c.sendWithAttachments(ctx, channelID, threadTS, msg.Content, msg.Attachments) + } + opts := []slack.MsgOption{ slack.MsgOptionText(msg.Content, false), } @@ -148,6 +153,49 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } +func (c *SlackChannel) sendWithAttachments(ctx context.Context, channelID, threadTS, content string, attachments []bus.Attachment) error { + for _, attachment := range attachments { + file, err := os.Open(attachment.Path) + if err != nil { + return fmt.Errorf("failed to open attachment %s: %w", attachment.Path, err) + } + + params := slack.UploadFileV2Parameters{ + Channel: channelID, + Filename: attachment.Filename, + Reader: file, + InitialComment: content, + ThreadTimestamp: threadTS, + } + + _, err = c.api.UploadFileV2Context(ctx, params) + defer file.Close() + + if err != nil { + return fmt.Errorf("failed to upload file %s: %w", attachment.Filename, err) + } + + // Only use content for first attachment to avoid duplicate comments + content = "" + } + + if ref, ok := c.pendingAcks.LoadAndDelete(channelID); ok { + msgRef := ref.(slackMessageRef) + c.api.AddReaction("white_check_mark", slack.ItemRef{ + Channel: msgRef.ChannelID, + Timestamp: msgRef.Timestamp, + }) + } + + logger.DebugCF("slack", "Message with attachments sent", map[string]interface{}{ + "channel_id": channelID, + "thread_ts": threadTS, + "attachment_count": len(attachments), + }) + + return nil +} + func (c *SlackChannel) eventLoop() { for { select { diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 24b82b557..b3974c77a 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -157,6 +157,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err c.stopThinking.Delete(msg.ChatID) } + // If there are attachments, send them + if len(msg.Attachments) > 0 { + return c.sendWithAttachments(ctx, chatID, msg.Content, msg.Attachments) + } + htmlContent := markdownToTelegramHTML(msg.Content) // Try to edit placeholder @@ -186,6 +191,71 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } +func (c *TelegramChannel) sendWithAttachments(ctx context.Context, chatID int64, content string, attachments []bus.Attachment) error { + chatIDStr := fmt.Sprintf("%d", chatID) + htmlContent := markdownToTelegramHTML(content) + + // Try to edit placeholder with the message content + // This shows the LLM's response as a text message + if pID, ok := c.placeholders.Load(chatIDStr); ok { + c.placeholders.Delete(chatIDStr) + editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) + editMsg.ParseMode = telego.ModeHTML + + if _, err := c.bot.EditMessageText(ctx, editMsg); err != nil { + logger.DebugCF("telegram", "Failed to edit placeholder, will send new message", map[string]interface{}{ + "error": err.Error(), + }) + // Fallback to sending new message if edit fails + tgMsg := tu.Message(tu.ID(chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ + "error": err.Error(), + }) + tgMsg.ParseMode = "" + tgMsg.Text = content + c.bot.SendMessage(ctx, tgMsg) + } + } + } else { + // No placeholder exists, send as new message + tgMsg := tu.Message(tu.ID(chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ + "error": err.Error(), + }) + tgMsg.ParseMode = "" + tgMsg.Text = content + c.bot.SendMessage(ctx, tgMsg) + } + } + + // Now send files as separate messages + for _, attachment := range attachments { + file, err := os.Open(attachment.Path) + if err != nil { + return fmt.Errorf("failed to open attachment %s: %w", attachment.Path, err) + } + + document := tu.Document( + tu.ID(chatID), + tu.File(file), + ) + document.Caption = attachment.Filename + + if _, err := c.bot.SendDocument(ctx, document); err != nil { + file.Close() + return fmt.Errorf("failed to send document %s: %w", attachment.Filename, err) + } + + file.Close() + } + + return nil +} + func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { if message == nil { return fmt.Errorf("message is nil") diff --git a/pkg/tools/message.go b/pkg/tools/message.go index abedb1316..c2aedb308 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -3,9 +3,11 @@ package tools import ( "context" "fmt" + + "github.com/sipeed/picoclaw/pkg/bus" ) -type SendCallback func(channel, chatID, content string) error +type SendCallback func(channel, chatID, content string, attachments []bus.Attachment) error type MessageTool struct { sendCallback SendCallback @@ -42,6 +44,24 @@ func (t *MessageTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional: target chat/user ID", }, + "attachments": map[string]interface{}{ + "type": "array", + "description": "Optional: files to attach to the message", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Absolute file path to attach", + }, + "filename": map[string]interface{}{ + "type": "string", + "description": "Filename to use for the attachment", + }, + }, + "required": []string{"path", "filename"}, + }, + }, }, "required": []string{"content"}, } @@ -86,7 +106,24 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) return &ToolResult{ForLLM: "Message sending not configured", IsError: true} } - if err := t.sendCallback(channel, chatID, content); err != nil { + // Parse attachments if provided + var attachments []bus.Attachment + if attachmentsRaw, ok := args["attachments"].([]interface{}); ok { + for _, attachRaw := range attachmentsRaw { + if attachMap, ok := attachRaw.(map[string]interface{}); ok { + path, pathOk := attachMap["path"].(string) + filename, filenameOk := attachMap["filename"].(string) + if pathOk && filenameOk { + attachments = append(attachments, bus.Attachment{ + Path: path, + Filename: filename, + }) + } + } + } + } + + if err := t.sendCallback(channel, chatID, content, attachments); err != nil { return &ToolResult{ ForLLM: fmt.Sprintf("sending message: %v", err), IsError: true, diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index 4bedbe79b..ceb4fff30 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "testing" + + "github.com/sipeed/picoclaw/pkg/bus" ) func TestMessageTool_Execute_Success(t *testing.T) { @@ -11,10 +13,12 @@ func TestMessageTool_Execute_Success(t *testing.T) { tool.SetContext("test-channel", "test-chat-id") var sentChannel, sentChatID, sentContent string - tool.SetSendCallback(func(channel, chatID, content string) error { + var sentAttachments []bus.Attachment + tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error { sentChannel = channel sentChatID = chatID sentContent = content + sentAttachments = attachments return nil }) @@ -35,6 +39,9 @@ func TestMessageTool_Execute_Success(t *testing.T) { if sentContent != "Hello, world!" { t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent) } + if len(sentAttachments) != 0 { + t.Errorf("Expected no attachments, got %d", len(sentAttachments)) + } // Verify ToolResult meets US-011 criteria: // - Send success returns SilentResult (Silent=true) @@ -63,7 +70,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { tool.SetContext("default-channel", "default-chat-id") var sentChannel, sentChatID string - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error { sentChannel = channel sentChatID = chatID return nil @@ -99,7 +106,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { tool.SetContext("test-channel", "test-chat-id") sendErr := errors.New("network error") - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error { return sendErr }) @@ -153,7 +160,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { tool := NewMessageTool() // No SetContext called, so defaultChannel and defaultChatID are empty - tool.SetSendCallback(func(channel, chatID, content string) error { + tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error { return nil }) @@ -256,4 +263,81 @@ func TestMessageTool_Parameters(t *testing.T) { if chatIDProp["type"] != "string" { t.Error("Expected chat_id type to be 'string'") } + + // Check attachments property (optional) + attachmentsProp, ok := props["attachments"].(map[string]interface{}) + if !ok { + t.Error("Expected 'attachments' property") + } + if attachmentsProp["type"] != "array" { + t.Error("Expected attachments type to be 'array'") + } +} + +func TestMessageTool_Execute_WithAttachments(t *testing.T) { + tool := NewMessageTool() + tool.SetContext("test-channel", "test-chat-id") + + var sentChannel, sentChatID, sentContent string + var sentAttachments []bus.Attachment + tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error { + sentChannel = channel + sentChatID = chatID + sentContent = content + sentAttachments = attachments + return nil + }) + + ctx := context.Background() + args := map[string]interface{}{ + "content": "Here's your document", + "attachments": []interface{}{ + map[string]interface{}{ + "path": "/tmp/test.docx", + "filename": "test.docx", + }, + map[string]interface{}{ + "path": "/tmp/test.pdf", + "filename": "test.pdf", + }, + }, + } + + result := tool.Execute(ctx, args) + + // Verify message was sent with correct parameters + if sentChannel != "test-channel" { + t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel) + } + if sentChatID != "test-chat-id" { + t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID) + } + if sentContent != "Here's your document" { + t.Errorf("Expected content 'Here's your document', got '%s'", sentContent) + } + + // Verify attachments were passed correctly + if len(sentAttachments) != 2 { + t.Fatalf("Expected 2 attachments, got %d", len(sentAttachments)) + } + if sentAttachments[0].Path != "/tmp/test.docx" { + t.Errorf("Expected first attachment path '/tmp/test.docx', got '%s'", sentAttachments[0].Path) + } + if sentAttachments[0].Filename != "test.docx" { + t.Errorf("Expected first attachment filename 'test.docx', got '%s'", sentAttachments[0].Filename) + } + if sentAttachments[1].Path != "/tmp/test.pdf" { + t.Errorf("Expected second attachment path '/tmp/test.pdf', got '%s'", sentAttachments[1].Path) + } + if sentAttachments[1].Filename != "test.pdf" { + t.Errorf("Expected second attachment filename 'test.pdf', got '%s'", sentAttachments[1].Filename) + } + + // Verify successful result + if !result.Silent { + t.Error("Expected Silent=true for successful send") + } + if result.IsError { + t.Error("Expected IsError=false for successful send") + } }