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
|
// Message tool - available to both agent and subagent
|
||||||
// Subagent uses it to communicate directly with user
|
// Subagent uses it to communicate directly with user
|
||||||
messageTool := tools.NewMessageTool()
|
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{
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
Media: media,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type OutboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Media []string `json:"media,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler func(InboundMessage) error
|
type MessageHandler func(InboundMessage) error
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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)
|
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 {
|
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
||||||
msgRef := ref.(slackMessageRef)
|
msgRef := ref.(slackMessageRef)
|
||||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -180,12 +181,65 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
})
|
})
|
||||||
tgMsg.ParseMode = ""
|
tgMsg.ParseMode = ""
|
||||||
_, err = c.bot.SendMessage(ctx, tgMsg)
|
_, err = c.bot.SendMessage(ctx, tgMsg)
|
||||||
|
if err != nil {
|
||||||
return err
|
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
|
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 {
|
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||||
if message == nil {
|
if message == nil {
|
||||||
return fmt.Errorf("message is nil")
|
return fmt.Errorf("message is nil")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendCallback func(channel, chatID, content string) error
|
type SendCallback func(channel, chatID, content string, media []string) error
|
||||||
|
|
||||||
type MessageTool struct {
|
type MessageTool struct {
|
||||||
sendCallback SendCallback
|
sendCallback SendCallback
|
||||||
|
|
@ -42,6 +42,13 @@ func (t *MessageTool) Parameters() map[string]interface{} {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional: target chat/user ID",
|
"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"},
|
"required": []string{"content"},
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +78,15 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
|
||||||
channel, _ := args["channel"].(string)
|
channel, _ := args["channel"].(string)
|
||||||
chatID, _ := args["chat_id"].(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 == "" {
|
if channel == "" {
|
||||||
channel = t.defaultChannel
|
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}
|
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{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||||
IsError: true,
|
IsError: true,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
|
||||||
tool.SetContext("test-channel", "test-chat-id")
|
tool.SetContext("test-channel", "test-chat-id")
|
||||||
|
|
||||||
var sentChannel, sentChatID, sentContent string
|
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
|
sentChannel = channel
|
||||||
sentChatID = chatID
|
sentChatID = chatID
|
||||||
sentContent = content
|
sentContent = content
|
||||||
|
|
@ -63,7 +63,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
|
||||||
tool.SetContext("default-channel", "default-chat-id")
|
tool.SetContext("default-channel", "default-chat-id")
|
||||||
|
|
||||||
var sentChannel, sentChatID string
|
var sentChannel, sentChatID string
|
||||||
tool.SetSendCallback(func(channel, chatID, content string) error {
|
tool.SetSendCallback(func(channel, chatID, content string, media []string) error {
|
||||||
sentChannel = channel
|
sentChannel = channel
|
||||||
sentChatID = chatID
|
sentChatID = chatID
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -99,7 +99,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
|
||||||
tool.SetContext("test-channel", "test-chat-id")
|
tool.SetContext("test-channel", "test-chat-id")
|
||||||
|
|
||||||
sendErr := errors.New("network error")
|
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
|
return sendErr
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
|
||||||
tool := NewMessageTool()
|
tool := NewMessageTool()
|
||||||
// No SetContext called, so defaultChannel and defaultChatID are empty
|
// 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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ type ToolResult struct {
|
||||||
// When true, the tool will complete later and notify via callback.
|
// When true, the tool will complete later and notify via callback.
|
||||||
Async bool `json:"async"`
|
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).
|
// Err is the underlying error (not JSON serialized).
|
||||||
// Used for internal error handling and logging.
|
// Used for internal error handling and logging.
|
||||||
Err error `json:"-"`
|
Err error `json:"-"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue