feat: add media support to outbound messages

Enable the bot to send files (photos, videos, audio, documents) back
through Telegram, Discord, and Slack channels via the message tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
repfigit 2026-02-15 22:25:09 -05:00
parent 13e4028d42
commit e2e24a0366
8 changed files with 148 additions and 11 deletions

View file

@ -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
})

View file

@ -14,6 +14,7 @@ type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
}
type MessageHandler func(InboundMessage) error

View file

@ -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
}

View file

@ -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{

View file

@ -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)
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")

View file

@ -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,

View file

@ -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
})

View file

@ -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:"-"`