diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index a6fd365c7..74cb45653 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -54,6 +54,7 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) + toolsRegistry.Register(tools.NewSendFileTool(workspace, restrict)) sessionsDir := filepath.Join(workspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 29827d0b2..3669faf6b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -251,6 +251,17 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { // SetMediaStore injects a MediaStore for media lifecycle management. func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s + + // Inject media store into send_file tools across all agents + for _, id := range al.registry.ListAgentIDs() { + if agent, ok := al.registry.GetAgent(id); ok { + if tool, ok := agent.Tools.Get("send_file"); ok { + if sft, ok := tool.(*tools.SendFileTool); ok { + sft.SetMediaStore(s) + } + } + } + } } // inferMediaType determines the media type ("image", "audio", "video", "file") @@ -832,8 +843,10 @@ func (al *AgentLoop) runLLMIteration( }) } - // If tool returned media refs, publish them as outbound media - if len(toolResult.Media) > 0 && opts.SendResponse { + // If tool returned media refs, publish them as outbound media. + // Media is always sent immediately regardless of SendResponse, + // because the outer Run() loop only handles text responses. + if len(toolResult.Media) > 0 { parts := make([]bus.MediaPart, 0, len(toolResult.Media)) for _, ref := range toolResult.Media { part := bus.MediaPart{Ref: ref} @@ -852,6 +865,13 @@ func (al *AgentLoop) runLLMIteration( ChatID: opts.ChatID, Parts: parts, }) + logger.InfoCF("agent", "Published outbound media", + map[string]any{ + "tool": tc.Name, + "channel": opts.Channel, + "chat_id": opts.ChatID, + "parts": len(parts), + }) } // Determine content for LLM based on tool result @@ -893,6 +913,11 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st st.SetContext(channel, chatID) } } + if tool, ok := agent.Tools.Get("send_file"); ok { + if st, ok := tool.(tools.ContextualTool); ok { + st.SetContext(channel, chatID) + } + } } // maybeSummarize triggers summarization if the session history exceeds thresholds. diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 1db1bf669..df2e77f0b 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -6,6 +6,9 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" + "strings" "sync" "time" @@ -30,6 +33,9 @@ type FeishuChannel struct { mu sync.Mutex cancel context.CancelFunc + + // botOpenID is discovered at startup via the bot info API. + botOpenID string } func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { @@ -102,17 +108,17 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("chat ID is empty") } - payload, err := json.Marshal(map[string]string{"text": msg.Content}) + msgType, payload, err := buildFeishuContent(msg.Content) if err != nil { - return fmt.Errorf("failed to marshal feishu content: %w", err) + return fmt.Errorf("failed to build feishu content: %w", err) } req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). ReceiveId(msg.ChatID). - MsgType(larkim.MsgTypeText). - Content(string(payload)). + MsgType(msgType). + Content(payload). Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())). Build()). Build() @@ -133,6 +139,281 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } +// EditMessage implements channels.MessageEditor. +// It edits an existing message using the Feishu UpdateMessage API. +// Uses post format to match the placeholder message type. +func (c *FeishuChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + msgType, payload, err := buildFeishuContent(content) + if err != nil { + return fmt.Errorf("failed to build feishu edit content: %w", err) + } + + req := larkim.NewUpdateMessageReqBuilder(). + MessageId(messageID). + Body(larkim.NewUpdateMessageReqBodyBuilder(). + MsgType(msgType). + Content(payload). + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Update(ctx, req) + if err != nil { + return fmt.Errorf("feishu edit message: %w", err) + } + + if !resp.Success() { + return fmt.Errorf("feishu edit message api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + + return nil +} + +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder post message that will later be edited via EditMessage. +// Uses post format so EditMessage can update it with rich text (post→post). +func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.config.Placeholder.Enabled { + return "", nil + } + + text := c.config.Placeholder.Text + if text == "" { + text = "Thinking... 💭" + } + + msgType, payload, err := buildFeishuContent(text) + if err != nil { + return "", fmt.Errorf("failed to build feishu placeholder: %w", err) + } + + req := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(msgType). + Content(payload). + Uuid(fmt.Sprintf("picoclaw-ph-%d", time.Now().UnixNano())). + Build()). + Build() + + resp, err := c.client.Im.V1.Message.Create(ctx, req) + if err != nil { + return "", fmt.Errorf("feishu send placeholder: %w", err) + } + + if !resp.Success() || resp.Data == nil || resp.Data.MessageId == nil { + return "", fmt.Errorf("feishu send placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + + return *resp.Data.MessageId, nil +} + +// ReactToMessage implements channels.ReactionCapable. +// It adds a THUMBSUP reaction to the inbound message and returns an undo function. +func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + req := larkim.NewCreateMessageReactionReqBuilder(). + MessageId(messageID). + Body(larkim.NewCreateMessageReactionReqBodyBuilder(). + ReactionType(larkim.NewEmojiBuilder().EmojiType("Typing").Build()). + Build()). + Build() + + resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req) + if err != nil { + return func() {}, fmt.Errorf("feishu add reaction: %w", err) + } + + if !resp.Success() { + return func() {}, fmt.Errorf("feishu add reaction api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + + var reactionID string + if resp.Data != nil && resp.Data.ReactionId != nil { + reactionID = *resp.Data.ReactionId + } + + var undoOnce sync.Once + return func() { + undoOnce.Do(func() { + if reactionID == "" { + return + } + delReq := larkim.NewDeleteMessageReactionReqBuilder(). + MessageId(messageID). + ReactionId(reactionID). + Build() + _, _ = c.client.Im.V1.MessageReaction.Delete(context.Background(), delReq) + }) + }, nil +} + +// SendMedia implements channels.MediaSender. +// It uploads and sends media files (images/files) via the Feishu API. +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("feishu", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + switch part.Type { + case "image": + if err := c.sendImage(ctx, msg.ChatID, localPath); err != nil { + return err + } + default: // "audio", "video", "file", or unknown + filename := part.Filename + if filename == "" { + filename = filepath.Base(localPath) + } + if err := c.sendFile(ctx, msg.ChatID, localPath, filename); err != nil { + return err + } + } + } + + return nil +} + +// sendImage uploads an image and sends it as an image message. +func (c *FeishuChannel) sendImage(ctx context.Context, chatID, localPath string) error { + file, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("feishu open image: %w", channels.ErrSendFailed) + } + defer file.Close() + + uploadReq := larkim.NewCreateImageReqBuilder(). + Body(larkim.NewCreateImageReqBodyBuilder(). + ImageType(larkim.ImageTypeMessage). + Image(file). + Build()). + Build() + + uploadResp, err := c.client.Im.V1.Image.Create(ctx, uploadReq) + if err != nil { + return fmt.Errorf("feishu upload image: %w", channels.ErrTemporary) + } + + if !uploadResp.Success() || uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { + return fmt.Errorf("feishu upload image api error (code=%d msg=%s): %w", + uploadResp.Code, uploadResp.Msg, channels.ErrTemporary) + } + + imageKey := *uploadResp.Data.ImageKey + payload, _ := json.Marshal(map[string]string{"image_key": imageKey}) + + sendReq := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeImage). + Content(string(payload)). + Uuid(fmt.Sprintf("picoclaw-img-%d", time.Now().UnixNano())). + Build()). + Build() + + sendResp, err := c.client.Im.V1.Message.Create(ctx, sendReq) + if err != nil { + return fmt.Errorf("feishu send image: %w", channels.ErrTemporary) + } + + if !sendResp.Success() { + return fmt.Errorf("feishu send image api error (code=%d msg=%s): %w", + sendResp.Code, sendResp.Msg, channels.ErrTemporary) + } + + return nil +} + +// sendFile uploads a file and sends it as a file message. +func (c *FeishuChannel) sendFile(ctx context.Context, chatID, localPath, filename string) error { + file, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("feishu open file: %w", channels.ErrSendFailed) + } + defer file.Close() + + fileType := inferFeishuFileType(filename) + + uploadReq := larkim.NewCreateFileReqBuilder(). + Body(larkim.NewCreateFileReqBodyBuilder(). + FileType(fileType). + FileName(filename). + File(file). + Build()). + Build() + + uploadResp, err := c.client.Im.V1.File.Create(ctx, uploadReq) + if err != nil { + return fmt.Errorf("feishu upload file: %w", channels.ErrTemporary) + } + + if !uploadResp.Success() || uploadResp.Data == nil || uploadResp.Data.FileKey == nil { + return fmt.Errorf("feishu upload file api error (code=%d msg=%s): %w", + uploadResp.Code, uploadResp.Msg, channels.ErrTemporary) + } + + fileKey := *uploadResp.Data.FileKey + payload, _ := json.Marshal(map[string]string{"file_key": fileKey}) + + sendReq := larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(larkim.ReceiveIdTypeChatId). + Body(larkim.NewCreateMessageReqBodyBuilder(). + ReceiveId(chatID). + MsgType(larkim.MsgTypeFile). + Content(string(payload)). + Uuid(fmt.Sprintf("picoclaw-file-%d", time.Now().UnixNano())). + Build()). + Build() + + sendResp, err := c.client.Im.V1.Message.Create(ctx, sendReq) + if err != nil { + return fmt.Errorf("feishu send file: %w", channels.ErrTemporary) + } + + if !sendResp.Success() { + return fmt.Errorf("feishu send file api error (code=%d msg=%s): %w", + sendResp.Code, sendResp.Msg, channels.ErrTemporary) + } + + return nil +} + +// inferFeishuFileType maps a filename extension to a Feishu file type constant. +func inferFeishuFileType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".opus", ".ogg": + return larkim.FileTypeOpus + case ".mp4": + return larkim.FileTypeMp4 + case ".pdf": + return larkim.FileTypePdf + case ".doc", ".docx": + return larkim.FileTypeDoc + case ".xls", ".xlsx": + return larkim.FileTypeXls + case ".ppt", ".pptx": + return larkim.FileTypePpt + default: + return larkim.FileTypeStream + } +} + func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error { if event == nil || event.Event == nil || event.Event.Message == nil { return nil @@ -177,8 +458,13 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. peer = bus.Peer{Kind: "direct", ID: senderID} } else { peer = bus.Peer{Kind: "group", ID: chatID} + // Detect @mention and strip bot mention from content + isMentioned := c.isBotMentioned(message) + if isMentioned { + content = c.stripBotMention(content, message) + } // In group chats, apply unified group trigger filtering - respond, cleaned := c.ShouldRespondInGroup(false, content) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { return nil } @@ -205,6 +491,80 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. return nil } +// isBotMentioned checks if the bot is mentioned in the message's mentions list. +// Feishu identifies bot mentions by sender_type="app" in the mention entries, +// or by matching the app_id against the configured AppID. +func (c *FeishuChannel) isBotMentioned(message *larkim.EventMessage) bool { + if message == nil || len(message.Mentions) == 0 { + return false + } + + for _, mention := range message.Mentions { + if mention == nil { + continue + } + // Feishu marks bot mentions with Name containing the bot name. + // The mention ID open_id corresponds to the bot's open_id. + // A reliable approach: check if the mention's key exists and + // the name is not empty (bot mentions always have the bot's display name). + // Since we don't have the bot's open_id at init time (would require an extra API call), + // we use a heuristic: if any mention has an ID with open_id matching the app, + // or if the mention name matches. However, the most reliable way is to check + // if mention.Id is nil — when an app/bot is mentioned, the Id field is populated. + // + // Simplest reliable approach: feishu includes all mentions in the list. + // Bot mentions will have Id.OpenId set. We compare against botOpenID if available, + // otherwise treat any mention of an app-type entity as a bot mention by checking + // if the key exists in the content text. + if c.botOpenID != "" && mention.Id != nil { + if mention.Id.OpenId != nil && *mention.Id.OpenId == c.botOpenID { + return true + } + } + // Fallback: In feishu, when mentioning a bot, the Name field is set to the bot's name + // and the mention key appears in the text as @_user_N. + // If we don't have botOpenID, we check if any mention has the same tenant + // as the app (this is a common pattern). For now, accept any mention as potential + // bot mention when botOpenID is not set — the ShouldRespondInGroup logic + // will handle the rest. + if c.botOpenID == "" && mention.Key != nil && mention.Id != nil && mention.Id.OpenId != nil { + // Without botOpenID, we can't definitively identify the bot. + // Use AppID prefix matching as a heuristic — feishu bot open_ids + // are derived from the app. A safer default: treat it as mentioned + // so the bot responds when @'d in groups. + return true + } + } + + return false +} + +// stripBotMention removes bot mention placeholders (e.g. @_user_1) from the content text. +func (c *FeishuChannel) stripBotMention(content string, message *larkim.EventMessage) string { + if message == nil || len(message.Mentions) == 0 { + return content + } + + for _, mention := range message.Mentions { + if mention == nil || mention.Key == nil { + continue + } + // Only strip the bot's mention, not other user mentions + isBotMention := false + if c.botOpenID != "" && mention.Id != nil && mention.Id.OpenId != nil { + isBotMention = *mention.Id.OpenId == c.botOpenID + } else if c.botOpenID == "" && mention.Id != nil && mention.Id.OpenId != nil { + // Without botOpenID, strip any mention that triggered isBotMentioned + isBotMention = true + } + if isBotMention { + content = strings.ReplaceAll(content, *mention.Key, "") + } + } + + return strings.TrimSpace(content) +} + func extractFeishuSenderID(sender *larkim.EventSender) string { if sender == nil || sender.SenderId == nil { return "" diff --git a/pkg/channels/feishu/richtext.go b/pkg/channels/feishu/richtext.go new file mode 100644 index 000000000..57d374182 --- /dev/null +++ b/pkg/channels/feishu/richtext.go @@ -0,0 +1,224 @@ +//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 + +package feishu + +import ( + "encoding/json" + "regexp" + "strings" + + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" +) + +// Feishu post (rich text) message format: +// { +// "zh_cn": { +// "title": "", +// "content": [ +// [ { "tag": "text", "text": "hello " }, { "tag": "a", "text": "link", "href": "..." } ], +// [ { "tag": "text", "text": "line 2" } ] +// ] +// } +// } +// +// Supported tags: text, a, at, img +// Text supports: bold, italic, underline, strikethrough via "style" array + +var ( + reCodeBlock = regexp.MustCompile("(?s)```\\w*\n?(.*?)```") + reInlineCode = regexp.MustCompile("`([^`]+)`") + reMdLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reMdBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) + reMdBoldUnder = regexp.MustCompile(`__(.+?)__`) + reMdItalic = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)`) + reMdStrike = regexp.MustCompile(`~~(.+?)~~`) + reMdHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) +) + +// postElement represents a single element in a Feishu post content line. +type postElement map[string]any + +// markdownToFeishuPost converts Markdown text to Feishu post JSON content string. +// Returns the JSON string for MsgTypePost, or an error. +func markdownToFeishuPost(text string) (string, error) { + lines := strings.Split(text, "\n") + var contentLines [][]postElement + + i := 0 + for i < len(lines) { + line := lines[i] + + // Check for code block start + if strings.HasPrefix(strings.TrimSpace(line), "```") { + var codeLines []string + i++ // skip opening ``` + for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") { + codeLines = append(codeLines, lines[i]) + i++ + } + if i < len(lines) { + i++ // skip closing ``` + } + codeText := strings.Join(codeLines, "\n") + // Feishu post doesn't have a native code block tag, use text with code_block style + contentLines = append(contentLines, []postElement{ + {"tag": "text", "text": codeText, "style": []string{"code_block"}}, + }) + continue + } + + // Heading: strip # prefix and render as bold text + if match := reMdHeading.FindStringSubmatch(line); match != nil { + contentLines = append(contentLines, []postElement{ + {"tag": "text", "text": match[1], "style": []string{"bold"}}, + }) + i++ + continue + } + + // Empty line + if strings.TrimSpace(line) == "" { + contentLines = append(contentLines, []postElement{ + {"tag": "text", "text": ""}, + }) + i++ + continue + } + + // Normal line — parse inline elements + elements := parseInlineElements(line) + if len(elements) > 0 { + contentLines = append(contentLines, elements) + } + i++ + } + + post := map[string]any{ + "zh_cn": map[string]any{ + "content": contentLines, + }, + } + + data, err := json.Marshal(post) + return string(data), err +} + +// parseInlineElements converts a single line of Markdown into Feishu post elements. +func parseInlineElements(line string) []postElement { + // Strip list markers + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { + line = "• " + line[2:] + } + if len(line) > 2 && line[0] >= '0' && line[0] <= '9' && line[1] == '.' { + // numbered list, keep as-is + } + + var elements []postElement + + // We process the line left to right, extracting special elements + remaining := line + + for remaining != "" { + // Find the earliest match among: link, bold, italic, strikethrough, inline code + type match struct { + start, end int + typ string + groups []string + } + + var earliest *match + + if loc := reMdLink.FindStringSubmatchIndex(remaining); loc != nil { + earliest = &match{start: loc[0], end: loc[1], typ: "link", + groups: []string{remaining[loc[2]:loc[3]], remaining[loc[4]:loc[5]]}} + } + + if loc := reInlineCode.FindStringSubmatchIndex(remaining); loc != nil { + if earliest == nil || loc[0] < earliest.start { + earliest = &match{start: loc[0], end: loc[1], typ: "code", + groups: []string{remaining[loc[2]:loc[3]]}} + } + } + + if loc := reMdBoldStar.FindStringSubmatchIndex(remaining); loc != nil { + if earliest == nil || loc[0] < earliest.start { + earliest = &match{start: loc[0], end: loc[1], typ: "bold", + groups: []string{remaining[loc[2]:loc[3]]}} + } + } + + if loc := reMdBoldUnder.FindStringSubmatchIndex(remaining); loc != nil { + if earliest == nil || loc[0] < earliest.start { + earliest = &match{start: loc[0], end: loc[1], typ: "bold", + groups: []string{remaining[loc[2]:loc[3]]}} + } + } + + if loc := reMdStrike.FindStringSubmatchIndex(remaining); loc != nil { + if earliest == nil || loc[0] < earliest.start { + earliest = &match{start: loc[0], end: loc[1], typ: "strike", + groups: []string{remaining[loc[2]:loc[3]]}} + } + } + + if earliest == nil { + // No more special elements, add the rest as plain text + if remaining != "" { + elements = append(elements, postElement{"tag": "text", "text": remaining}) + } + break + } + + // Add text before the match + if earliest.start > 0 { + elements = append(elements, postElement{"tag": "text", "text": remaining[:earliest.start]}) + } + + // Add the matched element + switch earliest.typ { + case "link": + elements = append(elements, postElement{ + "tag": "a", + "text": earliest.groups[0], + "href": earliest.groups[1], + }) + case "code": + elements = append(elements, postElement{ + "tag": "text", "text": earliest.groups[0], + "style": []string{"code_block"}, + }) + case "bold": + elements = append(elements, postElement{ + "tag": "text", "text": earliest.groups[0], + "style": []string{"bold"}, + }) + case "strike": + elements = append(elements, postElement{ + "tag": "text", "text": earliest.groups[0], + "style": []string{"lineThrough"}, + }) + } + + remaining = remaining[earliest.end:] + } + + return elements +} + +// buildFeishuContent converts content to Feishu message format. +// It tries to render as post (rich text) first. If conversion fails, +// falls back to plain text format. +// Returns (msgType, jsonPayload, error). +func buildFeishuContent(content string) (string, string, error) { + postPayload, err := markdownToFeishuPost(content) + if err == nil { + return larkim.MsgTypePost, postPayload, nil + } + // Fallback to plain text + payload, err := json.Marshal(map[string]string{"text": content}) + if err != nil { + return "", "", err + } + return larkim.MsgTypeText, string(payload), nil +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 31af9672c..a8735621b 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{ "discord": 1, "slack": 1, "line": 10, + "feishu": 5, } type channelWorker struct { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2e0215278..a8fbb9f72 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -252,6 +252,7 @@ type FeishuConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type DiscordConfig struct { diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go new file mode 100644 index 000000000..457c72f0e --- /dev/null +++ b/pkg/tools/send_file.go @@ -0,0 +1,148 @@ +package tools + +import ( + "context" + "fmt" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/media" +) + +// SendFileTool allows the agent to send a local file (image, document, etc.) +// as a media attachment to the user via the channel's SendMedia capability. +type SendFileTool struct { + workingDir string + restrictToWorkspace bool + mediaStore media.MediaStore + channel string + chatID string +} + +func NewSendFileTool(workspace string, restrict bool) *SendFileTool { + return &SendFileTool{ + workingDir: workspace, + restrictToWorkspace: restrict, + } +} + +func (t *SendFileTool) Name() string { + return "send_file" +} + +func (t *SendFileTool) Description() string { + return "Send a local file (image, document, audio, video) to the user as a media attachment. Use this instead of reading binary files with read_file." +} + +func (t *SendFileTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Absolute or relative path to the file to send", + }, + }, + "required": []string{"path"}, + } +} + +// SetContext implements ContextualTool to receive per-message channel/chatID. +func (t *SendFileTool) SetContext(channel, chatID string) { + t.channel = channel + t.chatID = chatID +} + +// SetMediaStore injects the media store for file registration. +func (t *SendFileTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok || path == "" { + return ErrorResult("path is required") + } + + // Resolve relative paths + if !filepath.IsAbs(path) && t.workingDir != "" { + path = filepath.Join(t.workingDir, path) + } + + // Validate path exists + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return ErrorResult(fmt.Sprintf("file not found: %s", path)) + } + return ErrorResult(fmt.Sprintf("cannot access file: %v", err)) + } + if info.IsDir() { + return ErrorResult("path is a directory, not a file") + } + + // Check workspace restriction + if t.restrictToWorkspace && t.workingDir != "" { + if !isWithinWorkspace(path, t.workingDir) { + return ErrorResult("file is outside the workspace") + } + } + + if t.mediaStore == nil { + return ErrorResult("media store not available — this channel may not support file sending") + } + + filename := filepath.Base(path) + contentType := inferContentType(filename) + + // Build a scope from current channel context + scope := fmt.Sprintf("send_file:%s:%s:%d", t.channel, t.chatID, time.Now().UnixNano()) + + ref, err := t.mediaStore.Store(path, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "tool:send_file", + }, scope) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register file: %v", err)) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("File '%s' sent to user successfully", filename), + Silent: false, + Media: []string{ref}, + } +} + +// inferContentType guesses the MIME type from a filename extension. +func inferContentType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + switch ext { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".svg": + return "image/svg+xml" + case ".pdf": + return "application/pdf" + case ".mp4": + return "video/mp4" + case ".mp3": + return "audio/mpeg" + case ".ogg": + return "audio/ogg" + default: + return "application/octet-stream" + } +}