From ac551a0caa253e601e3787667d20518fda48bd34 Mon Sep 17 00:00:00 2001 From: pkonowrocki Date: Wed, 18 Feb 2026 09:21:43 +0100 Subject: [PATCH] feat: add support for attachments in message tool --- pkg/agent/loop.go | 3 ++- pkg/bus/types.go | 7 ++++--- pkg/channels/discord.go | 28 ++++++++++++++++++++++++++-- pkg/tools/message.go | 16 ++++++++++++++-- pkg/tools/message_test.go | 13 +++++++++---- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 54ea3d1eb..4f4da28a9 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -95,11 +95,12 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg // Message tool - available to both agent and subagent // Subagent uses it to communicate directly with user messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { + messageTool.SetSendCallback(func(channel, chatID, content string, attachments []string) error { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, + Media: attachments, }) return nil }) diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 44f9181a5..82c6b4754 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -11,9 +11,10 @@ type InboundMessage struct { } 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"` + Media []string `json:"media,omitempty"` } type MessageHandler func(InboundMessage) error diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index be9368149..bccbfe569 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -101,12 +101,24 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel ID is empty") } + for _, mediaPath := range msg.Media { + file, err := os.Open(mediaPath) + if err != nil { + return fmt.Errorf("failed to open media file %s: %w", mediaPath, err) + } + _, err = c.session.ChannelFileSend(channelID, mediaPath, file) + file.Close() + if err != nil { + return fmt.Errorf("failed to send media file %s: %w", mediaPath, err) + } + } + runes := []rune(msg.Content) if len(runes) == 0 { return nil } - chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks + chunks := splitMessage(msg.Content, 1500) for _, chunk := range chunks { if err := c.sendChunk(ctx, channelID, chunk); err != nil { @@ -253,7 +265,19 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin done := make(chan error, 1) go func() { - _, err := c.session.ChannelMessageSend(channelID, content) + var err error + if strings.HasPrefix(content, "file://") { + filePath := strings.TrimPrefix(content, "file://") + file, fErr := os.Open(filePath) + if fErr != nil { + done <- fErr + return + } + defer file.Close() + _, err = c.session.ChannelFileSend(channelID, filePath, file) + } else { + _, err = c.session.ChannelMessageSend(channelID, content) + } done <- err }() diff --git a/pkg/tools/message.go b/pkg/tools/message.go index abedb1316..bd9afad48 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -5,7 +5,7 @@ import ( "fmt" ) -type SendCallback func(channel, chatID, content string) error +type SendCallback func(channel, chatID, content string, attachments []string) error type MessageTool struct { sendCallback SendCallback @@ -42,6 +42,11 @@ func (t *MessageTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional: target chat/user ID", }, + "attachments": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"type": "string"}, + "description": "Optional: list of local file paths or URLs to attach", + }, }, "required": []string{"content"}, } @@ -70,6 +75,13 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) channel, _ := args["channel"].(string) chatID, _ := args["chat_id"].(string) + attachmentsRaw, _ := args["attachments"].([]interface{}) + var attachments []string + for _, a := range attachmentsRaw { + if s, ok := a.(string); ok { + attachments = append(attachments, s) + } + } if channel == "" { channel = t.defaultChannel @@ -86,7 +98,7 @@ 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 { + 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..dca393502 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -11,10 +11,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 []string + tool.SetSendCallback(func(channel, chatID, content string, attachments []string) error { sentChannel = channel sentChatID = chatID sentContent = content + sentAttachments = attachments return nil }) @@ -35,6 +37,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 0 attachments, got %d", len(sentAttachments)) + } // Verify ToolResult meets US-011 criteria: // - Send success returns SilentResult (Silent=true) @@ -63,7 +68,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 []string) error { sentChannel = channel sentChatID = chatID return nil @@ -99,7 +104,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 []string) error { return sendErr }) @@ -153,7 +158,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 []string) error { return nil })