feat(feishu): implement SendMedia and add send_file tool

Add outbound media support for the Feishu channel so the agent can send
images and files to users via the MediaStore pipeline.

Feishu channel:
- SendMedia dispatches media parts as image or file uploads
- sendImage uploads via Image.Create then sends image message
- sendFile uploads via File.Create then sends file message
- feishuFileType maps extensions to Feishu file_type values

send_file tool:
- New tool lets the LLM send a local file to the current chat
- Validates path, registers file in MediaStore, returns media ref
- Agent loop wires tool registration, MediaStore propagation, and
  context updates

Tested on Radxa Cubie A7A (arm64) with Feishu websocket channel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shikihane 2026-03-02 11:19:00 +08:00
parent 23da4503c1
commit 07ff0d57f6
3 changed files with 294 additions and 0 deletions

View file

@ -170,6 +170,14 @@ func registerSharedTools(
agent.Tools.Register(messageTool) agent.Tools.Register(messageTool)
} }
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
sendFileTool := tools.NewSendFileTool(
agent.Workspace,
cfg.Agents.Defaults.RestrictToWorkspace,
nil,
)
agent.Tools.Register(sendFileTool)
// Skill discovery and installation tools // Skill discovery and installation tools
skills_enabled := cfg.Tools.IsToolEnabled("skills") skills_enabled := cfg.Tools.IsToolEnabled("skills")
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
@ -371,6 +379,19 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
// SetMediaStore injects a MediaStore for media lifecycle management. // SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) { func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s al.mediaStore = s
// Propagate store to send_file tools in all agents.
for _, id := range al.registry.ListAgentIDs() {
agent, ok := al.registry.GetAgent(id)
if !ok {
continue
}
if tool, ok := agent.Tools.Get("send_file"); ok {
if sf, ok := tool.(*tools.SendFileTool); ok {
sf.SetMediaStore(s)
}
}
}
} }
// SetTranscriber injects a voice transcriber for agent-level audio transcription. // SetTranscriber injects a voice transcriber for agent-level audio transcription.
@ -1169,6 +1190,31 @@ func (al *AgentLoop) runLLMIteration(
return finalContent, iteration, nil return finalContent, iteration, nil
} }
// updateToolContexts updates the context for tools that need channel/chatID info.
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
// Use ContextualTool interface instead of type assertions
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
if tool, ok := agent.Tools.Get("send_file"); ok {
if sf, ok := tool.(tools.ContextualTool); ok {
sf.SetContext(channel, chatID)
}
}
}
// maybeSummarize triggers summarization if the session history exceeds thresholds. // maybeSummarize triggers summarization if the session history exceeds thresholds.
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey) newHistory := agent.Sessions.GetHistory(sessionKey)

125
pkg/tools/send_file.go Normal file
View file

@ -0,0 +1,125 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/media"
)
// SendFileTool allows the LLM to send a local file (image, document, etc.)
// to the user on the current chat channel via the MediaStore pipeline.
type SendFileTool struct {
workspace string
restrict bool
mediaStore media.MediaStore
defaultChannel string
defaultChatID string
}
func NewSendFileTool(workspace string, restrict bool, store media.MediaStore) *SendFileTool {
return &SendFileTool{
workspace: workspace,
restrict: restrict,
mediaStore: store,
}
}
func (t *SendFileTool) Name() string { return "send_file" }
func (t *SendFileTool) Description() string {
return "Send a local file (image, document, etc.) to the user on the current chat channel."
}
func (t *SendFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the local file. Relative paths are resolved from workspace.",
},
"filename": map[string]any{
"type": "string",
"description": "Optional display filename. Defaults to the basename of path.",
},
},
"required": []string{"path"},
}
}
func (t *SendFileTool) SetContext(channel, chatID string) {
t.defaultChannel = channel
t.defaultChatID = chatID
}
func (t *SendFileTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
func (t *SendFileTool) Execute(_ context.Context, args map[string]any) *ToolResult {
path, _ := args["path"].(string)
if strings.TrimSpace(path) == "" {
return ErrorResult("path is required")
}
if t.defaultChannel == "" || t.defaultChatID == "" {
return ErrorResult("no target channel/chat available")
}
if t.mediaStore == nil {
return ErrorResult("media store not configured")
}
resolved, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid path: %v", err))
}
info, err := os.Stat(resolved)
if err != nil {
return ErrorResult(fmt.Sprintf("file not found: %v", err))
}
if info.IsDir() {
return ErrorResult("path is a directory, expected a file")
}
filename, _ := args["filename"].(string)
if filename == "" {
filename = filepath.Base(resolved)
}
mediaType := detectMediaType(resolved)
scope := fmt.Sprintf("tool:send_file:%s:%s", t.defaultChannel, t.defaultChatID)
ref, err := t.mediaStore.Store(resolved, media.MediaMeta{
Filename: filename,
ContentType: mediaType,
Source: "tool:send_file",
}, scope)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to register media: %v", err))
}
return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref})
}
func detectMediaType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".pdf":
return "application/pdf"
default:
return "application/octet-stream"
}
}

123
pkg/tools/send_file_test.go Normal file
View file

@ -0,0 +1,123 @@
package tools
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestSendFileTool_MissingPath(t *testing.T) {
store := media.NewFileMediaStore()
tool := NewSendFileTool("/tmp", false, store)
tool.SetContext("feishu", "chat123")
result := tool.Execute(context.Background(), map[string]any{})
if !result.IsError {
t.Fatal("expected error for missing path")
}
}
func TestSendFileTool_NoContext(t *testing.T) {
store := media.NewFileMediaStore()
tool := NewSendFileTool("/tmp", false, store)
// no SetContext call
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
if !result.IsError {
t.Fatal("expected error when no channel context")
}
}
func TestSendFileTool_NoMediaStore(t *testing.T) {
tool := NewSendFileTool("/tmp", false, nil)
tool.SetContext("feishu", "chat123")
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp/test.txt"})
if !result.IsError {
t.Fatal("expected error when no media store")
}
}
func TestSendFileTool_Directory(t *testing.T) {
store := media.NewFileMediaStore()
tool := NewSendFileTool("/tmp", false, store)
tool.SetContext("feishu", "chat123")
result := tool.Execute(context.Background(), map[string]any{"path": "/tmp"})
if !result.IsError {
t.Fatal("expected error for directory path")
}
}
func TestSendFileTool_Success(t *testing.T) {
dir := t.TempDir()
testFile := filepath.Join(dir, "photo.png")
if err := os.WriteFile(testFile, []byte("fake png"), 0o644); err != nil {
t.Fatal(err)
}
store := media.NewFileMediaStore()
tool := NewSendFileTool(dir, false, store)
tool.SetContext("feishu", "chat123")
result := tool.Execute(context.Background(), map[string]any{"path": testFile})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
}
if result.Media[0][:8] != "media://" {
t.Errorf("expected media:// ref, got %q", result.Media[0])
}
}
func TestSendFileTool_CustomFilename(t *testing.T) {
dir := t.TempDir()
testFile := filepath.Join(dir, "img.jpg")
if err := os.WriteFile(testFile, []byte("fake jpg"), 0o644); err != nil {
t.Fatal(err)
}
store := media.NewFileMediaStore()
tool := NewSendFileTool(dir, false, store)
tool.SetContext("telegram", "chat456")
result := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"filename": "my-photo.jpg",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if len(result.Media) != 1 {
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
}
}
func TestDetectMediaType(t *testing.T) {
tests := []struct {
path string
want string
}{
{"photo.jpg", "image/jpeg"},
{"photo.jpeg", "image/jpeg"},
{"photo.png", "image/png"},
{"anim.gif", "image/gif"},
{"photo.webp", "image/webp"},
{"doc.pdf", "application/pdf"},
{"data.bin", "application/octet-stream"},
{"noext", "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := detectMediaType(tt.path)
if got != tt.want {
t.Errorf("detectMediaType(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}