feat: add file attachment support to message tool
This commit is contained in:
parent
59fd391248
commit
45d2edef5c
7 changed files with 314 additions and 13 deletions
|
|
@ -107,11 +107,12 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
|
|||
|
||||
// Message tool
|
||||
messageTool := tools.NewMessageTool()
|
||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
messageTool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error {
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Attachments: attachments,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ type InboundMessage struct {
|
|||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
Path string `json:"path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
type OutboundMessage struct {
|
||||
Channel string `json:"channel"`
|
||||
ChatID string `json:"chat_id"`
|
||||
Content string `json:"content"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type MessageHandler func(InboundMessage) error
|
||||
|
|
|
|||
|
|
@ -100,6 +100,11 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return fmt.Errorf("channel ID is empty")
|
||||
}
|
||||
|
||||
// If there are attachments, send them with the message
|
||||
if len(msg.Attachments) > 0 {
|
||||
return c.sendWithAttachments(ctx, channelID, msg.Content, msg.Attachments)
|
||||
}
|
||||
|
||||
runes := []rune(msg.Content)
|
||||
if len(runes) == 0 {
|
||||
return nil
|
||||
|
|
@ -116,6 +121,56 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) sendWithAttachments(ctx context.Context, channelID, content string, attachments []bus.Attachment) error {
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
files := make([]*discordgo.File, 0, len(attachments))
|
||||
openedFiles := make([]*os.File, 0, len(attachments))
|
||||
|
||||
// Clean up all opened files when done
|
||||
defer func() {
|
||||
for _, f := range openedFiles {
|
||||
f.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
for _, attachment := range attachments {
|
||||
file, err := os.Open(attachment.Path)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("failed to open attachment %s: %w", attachment.Path, err)
|
||||
return
|
||||
}
|
||||
openedFiles = append(openedFiles, file)
|
||||
|
||||
files = append(files, &discordgo.File{
|
||||
Name: attachment.Filename,
|
||||
Reader: file,
|
||||
})
|
||||
}
|
||||
|
||||
messageData := &discordgo.MessageSend{
|
||||
Content: content,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
_, err := c.session.ChannelMessageSendComplex(channelID, messageData)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send discord message with attachments: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-sendCtx.Done():
|
||||
return fmt.Errorf("send message timeout: %w", sendCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
||||
// 使用传入的 ctx 进行超时控制
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
||||
}
|
||||
|
||||
// If there are attachments, send them
|
||||
if len(msg.Attachments) > 0 {
|
||||
return c.sendWithAttachments(ctx, channelID, threadTS, msg.Content, msg.Attachments)
|
||||
}
|
||||
|
||||
opts := []slack.MsgOption{
|
||||
slack.MsgOptionText(msg.Content, false),
|
||||
}
|
||||
|
|
@ -148,6 +153,49 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) sendWithAttachments(ctx context.Context, channelID, threadTS, content string, attachments []bus.Attachment) error {
|
||||
for _, attachment := range attachments {
|
||||
file, err := os.Open(attachment.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open attachment %s: %w", attachment.Path, err)
|
||||
}
|
||||
|
||||
params := slack.UploadFileV2Parameters{
|
||||
Channel: channelID,
|
||||
Filename: attachment.Filename,
|
||||
Reader: file,
|
||||
InitialComment: content,
|
||||
ThreadTimestamp: threadTS,
|
||||
}
|
||||
|
||||
_, err = c.api.UploadFileV2Context(ctx, params)
|
||||
defer file.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file %s: %w", attachment.Filename, err)
|
||||
}
|
||||
|
||||
// Only use content for first attachment to avoid duplicate comments
|
||||
content = ""
|
||||
}
|
||||
|
||||
if ref, ok := c.pendingAcks.LoadAndDelete(channelID); ok {
|
||||
msgRef := ref.(slackMessageRef)
|
||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||
Channel: msgRef.ChannelID,
|
||||
Timestamp: msgRef.Timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Message with attachments sent", map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
"attachment_count": len(attachments),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) eventLoop() {
|
||||
for {
|
||||
select {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
c.stopThinking.Delete(msg.ChatID)
|
||||
}
|
||||
|
||||
// If there are attachments, send them
|
||||
if len(msg.Attachments) > 0 {
|
||||
return c.sendWithAttachments(ctx, chatID, msg.Content, msg.Attachments)
|
||||
}
|
||||
|
||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
||||
|
||||
// Try to edit placeholder
|
||||
|
|
@ -186,6 +191,71 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) sendWithAttachments(ctx context.Context, chatID int64, content string, attachments []bus.Attachment) error {
|
||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||
htmlContent := markdownToTelegramHTML(content)
|
||||
|
||||
// Try to edit placeholder with the message content
|
||||
// This shows the LLM's response as a text message
|
||||
if pID, ok := c.placeholders.Load(chatIDStr); ok {
|
||||
c.placeholders.Delete(chatIDStr)
|
||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
||||
editMsg.ParseMode = telego.ModeHTML
|
||||
|
||||
if _, err := c.bot.EditMessageText(ctx, editMsg); err != nil {
|
||||
logger.DebugCF("telegram", "Failed to edit placeholder, will send new message", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Fallback to sending new message if edit fails
|
||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||
tgMsg.ParseMode = telego.ModeHTML
|
||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
tgMsg.ParseMode = ""
|
||||
tgMsg.Text = content
|
||||
c.bot.SendMessage(ctx, tgMsg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No placeholder exists, send as new message
|
||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||
tgMsg.ParseMode = telego.ModeHTML
|
||||
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
tgMsg.ParseMode = ""
|
||||
tgMsg.Text = content
|
||||
c.bot.SendMessage(ctx, tgMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Now send files as separate messages
|
||||
for _, attachment := range attachments {
|
||||
file, err := os.Open(attachment.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open attachment %s: %w", attachment.Path, err)
|
||||
}
|
||||
|
||||
document := tu.Document(
|
||||
tu.ID(chatID),
|
||||
tu.File(file),
|
||||
)
|
||||
document.Caption = attachment.Filename
|
||||
|
||||
if _, err := c.bot.SendDocument(ctx, document); err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("failed to send document %s: %w", attachment.Filename, err)
|
||||
}
|
||||
|
||||
file.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||
if message == nil {
|
||||
return fmt.Errorf("message is nil")
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ package tools
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
|
||||
type SendCallback func(channel, chatID, content string) error
|
||||
type SendCallback func(channel, chatID, content string, attachments []bus.Attachment) error
|
||||
|
||||
type MessageTool struct {
|
||||
sendCallback SendCallback
|
||||
|
|
@ -42,6 +44,24 @@ func (t *MessageTool) Parameters() map[string]interface{} {
|
|||
"type": "string",
|
||||
"description": "Optional: target chat/user ID",
|
||||
},
|
||||
"attachments": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "Optional: files to attach to the message",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Absolute file path to attach",
|
||||
},
|
||||
"filename": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Filename to use for the attachment",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "filename"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
}
|
||||
|
|
@ -86,7 +106,24 @@ 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 {
|
||||
// Parse attachments if provided
|
||||
var attachments []bus.Attachment
|
||||
if attachmentsRaw, ok := args["attachments"].([]interface{}); ok {
|
||||
for _, attachRaw := range attachmentsRaw {
|
||||
if attachMap, ok := attachRaw.(map[string]interface{}); ok {
|
||||
path, pathOk := attachMap["path"].(string)
|
||||
filename, filenameOk := attachMap["filename"].(string)
|
||||
if pathOk && filenameOk {
|
||||
attachments = append(attachments, bus.Attachment{
|
||||
Path: path,
|
||||
Filename: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := t.sendCallback(channel, chatID, content, attachments); err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: fmt.Sprintf("sending message: %v", err),
|
||||
IsError: true,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
|
||||
func TestMessageTool_Execute_Success(t *testing.T) {
|
||||
|
|
@ -11,10 +13,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 []bus.Attachment
|
||||
tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
sentContent = content
|
||||
sentAttachments = attachments
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -35,6 +39,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 no attachments, got %d", len(sentAttachments))
|
||||
}
|
||||
|
||||
// Verify ToolResult meets US-011 criteria:
|
||||
// - Send success returns SilentResult (Silent=true)
|
||||
|
|
@ -63,7 +70,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 []bus.Attachment) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
return nil
|
||||
|
|
@ -99,7 +106,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 []bus.Attachment) error {
|
||||
return sendErr
|
||||
})
|
||||
|
||||
|
|
@ -153,7 +160,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 []bus.Attachment) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -256,4 +263,81 @@ func TestMessageTool_Parameters(t *testing.T) {
|
|||
if chatIDProp["type"] != "string" {
|
||||
t.Error("Expected chat_id type to be 'string'")
|
||||
}
|
||||
|
||||
// Check attachments property (optional)
|
||||
attachmentsProp, ok := props["attachments"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Expected 'attachments' property")
|
||||
}
|
||||
if attachmentsProp["type"] != "array" {
|
||||
t.Error("Expected attachments type to be 'array'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTool_Execute_WithAttachments(t *testing.T) {
|
||||
tool := NewMessageTool()
|
||||
tool.SetContext("test-channel", "test-chat-id")
|
||||
|
||||
var sentChannel, sentChatID, sentContent string
|
||||
var sentAttachments []bus.Attachment
|
||||
tool.SetSendCallback(func(channel, chatID, content string, attachments []bus.Attachment) error {
|
||||
sentChannel = channel
|
||||
sentChatID = chatID
|
||||
sentContent = content
|
||||
sentAttachments = attachments
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"content": "Here's your document",
|
||||
"attachments": []interface{}{
|
||||
map[string]interface{}{
|
||||
"path": "/tmp/test.docx",
|
||||
"filename": "test.docx",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"path": "/tmp/test.pdf",
|
||||
"filename": "test.pdf",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Verify message was sent with correct parameters
|
||||
if sentChannel != "test-channel" {
|
||||
t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel)
|
||||
}
|
||||
if sentChatID != "test-chat-id" {
|
||||
t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID)
|
||||
}
|
||||
if sentContent != "Here's your document" {
|
||||
t.Errorf("Expected content 'Here's your document', got '%s'", sentContent)
|
||||
}
|
||||
|
||||
// Verify attachments were passed correctly
|
||||
if len(sentAttachments) != 2 {
|
||||
t.Fatalf("Expected 2 attachments, got %d", len(sentAttachments))
|
||||
}
|
||||
if sentAttachments[0].Path != "/tmp/test.docx" {
|
||||
t.Errorf("Expected first attachment path '/tmp/test.docx', got '%s'", sentAttachments[0].Path)
|
||||
}
|
||||
if sentAttachments[0].Filename != "test.docx" {
|
||||
t.Errorf("Expected first attachment filename 'test.docx', got '%s'", sentAttachments[0].Filename)
|
||||
}
|
||||
if sentAttachments[1].Path != "/tmp/test.pdf" {
|
||||
t.Errorf("Expected second attachment path '/tmp/test.pdf', got '%s'", sentAttachments[1].Path)
|
||||
}
|
||||
if sentAttachments[1].Filename != "test.pdf" {
|
||||
t.Errorf("Expected second attachment filename 'test.pdf', got '%s'", sentAttachments[1].Filename)
|
||||
}
|
||||
|
||||
// Verify successful result
|
||||
if !result.Silent {
|
||||
t.Error("Expected Silent=true for successful send")
|
||||
}
|
||||
if result.IsError {
|
||||
t.Error("Expected IsError=false for successful send")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue