diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cd4276155..c669d6d13 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -91,11 +91,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, media []string) error { msgBus.PublishOutbound(bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, + Media: media, }) 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 00aa8ab4d..f5f430684 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "time" @@ -114,6 +115,32 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro } } + // Send media files + for _, mediaPath := range msg.Media { + f, err := os.Open(mediaPath) + if err != nil { + logger.ErrorCF("discord", "Failed to open media file", map[string]any{ + "path": mediaPath, + "error": err.Error(), + }) + continue + } + msgSend := &discordgo.MessageSend{ + Files: []*discordgo.File{{ + Name: filepath.Base(mediaPath), + Reader: f, + }}, + } + _, sendErr := c.session.ChannelMessageSendComplex(channelID, msgSend) + f.Close() + if sendErr != nil { + logger.ErrorCF("discord", "Failed to send media file", map[string]any{ + "path": mediaPath, + "error": sendErr.Error(), + }) + } + } + return nil } diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index 5387e9213..a896bf82e 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "sync" "time" @@ -130,6 +131,40 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("failed to send slack message: %w", err) } + // Upload media files + for _, mediaPath := range msg.Media { + fi, err := os.Stat(mediaPath) + if err != nil { + logger.ErrorCF("slack", "Failed to stat media file", map[string]interface{}{ + "path": mediaPath, + "error": err.Error(), + }) + continue + } + f, err := os.Open(mediaPath) + if err != nil { + logger.ErrorCF("slack", "Failed to open media file", map[string]interface{}{ + "path": mediaPath, + "error": err.Error(), + }) + continue + } + _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ + Reader: f, + FileSize: int(fi.Size()), + Filename: filepath.Base(mediaPath), + Channel: channelID, + ThreadTimestamp: threadTS, + }) + f.Close() + if err != nil { + logger.ErrorCF("slack", "Failed to upload media file", map[string]interface{}{ + "path": mediaPath, + "error": err.Error(), + }) + } + } + if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { msgRef := ref.(slackMessageRef) c.api.AddReaction("white_check_mark", slack.ItemRef{ diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 5601d508c..af06e0dad 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "os" + "path/filepath" "regexp" "strings" "sync" @@ -180,12 +181,65 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err }) tgMsg.ParseMode = "" _, err = c.bot.SendMessage(ctx, tgMsg) - return err + if err != nil { + return err + } + } + + // Send media files + for _, mediaPath := range msg.Media { + if err := c.sendMediaFile(ctx, chatID, mediaPath); err != nil { + logger.ErrorCF("telegram", "Failed to send media file", map[string]interface{}{ + "path": mediaPath, + "error": err.Error(), + }) + } } return nil } +func (c *TelegramChannel) sendMediaFile(ctx context.Context, chatID int64, filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open media file: %w", err) + } + defer f.Close() + + fileName := filepath.Base(filePath) + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + photo := &telego.SendPhotoParams{ + ChatID: tu.ID(chatID), + Photo: telego.InputFile{File: f}, + } + _, err = c.bot.SendPhoto(ctx, photo) + case ".mp4", ".avi", ".mov", ".mkv": + video := &telego.SendVideoParams{ + ChatID: tu.ID(chatID), + Video: telego.InputFile{File: f}, + } + _, err = c.bot.SendVideo(ctx, video) + case ".mp3", ".ogg", ".wav", ".flac", ".aac", ".m4a": + audio := &telego.SendAudioParams{ + ChatID: tu.ID(chatID), + Audio: telego.InputFile{File: f}, + } + _, err = c.bot.SendAudio(ctx, audio) + default: + doc := &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), + Document: telego.InputFile{File: f}, + Caption: fileName, + } + _, err = c.bot.SendDocument(ctx, doc) + } + + return err +} + 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..8f963d4ff 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, media []string) error type MessageTool struct { sendCallback SendCallback @@ -42,6 +42,13 @@ func (t *MessageTool) Parameters() map[string]interface{} { "type": "string", "description": "Optional: target chat/user ID", }, + "media": map[string]interface{}{ + "type": "array", + "description": "Optional: list of local file paths to send as media attachments", + "items": map[string]interface{}{ + "type": "string", + }, + }, }, "required": []string{"content"}, } @@ -71,6 +78,15 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) channel, _ := args["channel"].(string) chatID, _ := args["chat_id"].(string) + var media []string + if rawMedia, ok := args["media"].([]interface{}); ok { + for _, item := range rawMedia { + if path, ok := item.(string); ok { + media = append(media, path) + } + } + } + if channel == "" { channel = t.defaultChannel } @@ -86,7 +102,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, media); 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..505e4f1d6 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -11,7 +11,7 @@ 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 { + tool.SetSendCallback(func(channel, chatID, content string, media []string) error { sentChannel = channel sentChatID = chatID sentContent = content @@ -63,7 +63,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, media []string) error { sentChannel = channel sentChatID = chatID return nil @@ -99,7 +99,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, media []string) error { return sendErr }) @@ -153,7 +153,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, media []string) error { return nil }) diff --git a/pkg/tools/result.go b/pkg/tools/result.go index b13055b1c..af1c774bb 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -27,6 +27,9 @@ type ToolResult struct { // When true, the tool will complete later and notify via callback. Async bool `json:"async"` + // Media contains local file paths to send alongside the message. + Media []string `json:"media,omitempty"` + // Err is the underlying error (not JSON serialized). // Used for internal error handling and logging. Err error `json:"-"`