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:
parent
13e4028d42
commit
e2e24a0366
8 changed files with 148 additions and 11 deletions
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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:"-"`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue