feat: add file attachment support to message tool

This commit is contained in:
XZB-1248 2026-02-19 11:11:47 +08:00
parent 59fd391248
commit 45d2edef5c
7 changed files with 314 additions and 13 deletions

View file

@ -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,
Channel: channel,
ChatID: chatID,
Content: content,
Attachments: attachments,
})
return nil
})

View file

@ -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"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Attachments []Attachment `json:"attachments,omitempty"`
}
type MessageHandler func(InboundMessage) error

View file

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

View file

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

View file

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

View file

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

View file

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