feat(feishu): enhance channel with rich text, media sending, and interactive capabilities

- Add Markdown→Feishu post format converter (richtext.go) so messages
  render with bold, code blocks, links, and headings instead of plain text
- Implement MessageEditor, PlaceholderCapable, ReactionCapable, and
  MediaSender interfaces for the Feishu channel
- Add send_file tool allowing the agent to send local images/files as
  native Feishu media messages via upload API
- Add @mention detection and bot mention stripping for group chats
- Add Feishu rate limit (5 msg/s) in channel manager
- Add placeholder config support for Feishu
- Fix media dispatch being gated by SendResponse flag, which prevented
  tool-originated media from reaching channels since normal message
  processing uses SendResponse=false
This commit is contained in:
wseelly 2026-02-28 18:10:11 +08:00
parent c119e0d5e8
commit b54bc1cefd
7 changed files with 767 additions and 7 deletions

View file

@ -54,6 +54,7 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewSendFileTool(workspace, restrict))
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)

View file

@ -251,6 +251,17 @@ 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
// 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") // 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 tool returned media refs, publish them as outbound media.
if len(toolResult.Media) > 0 && opts.SendResponse { // 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)) parts := make([]bus.MediaPart, 0, len(toolResult.Media))
for _, ref := range toolResult.Media { for _, ref := range toolResult.Media {
part := bus.MediaPart{Ref: ref} part := bus.MediaPart{Ref: ref}
@ -852,6 +865,13 @@ func (al *AgentLoop) runLLMIteration(
ChatID: opts.ChatID, ChatID: opts.ChatID,
Parts: parts, 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 // 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) 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. // maybeSummarize triggers summarization if the session history exceeds thresholds.

View file

@ -6,6 +6,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
@ -30,6 +33,9 @@ type FeishuChannel struct {
mu sync.Mutex mu sync.Mutex
cancel context.CancelFunc 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) { 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") 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 { 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(). req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId). ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder(). Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(msg.ChatID). ReceiveId(msg.ChatID).
MsgType(larkim.MsgTypeText). MsgType(msgType).
Content(string(payload)). Content(payload).
Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())). Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
Build()). Build()).
Build() Build()
@ -133,6 +139,281 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil 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 { func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
if event == nil || event.Event == nil || event.Event.Message == nil { if event == nil || event.Event == nil || event.Event.Message == nil {
return nil return nil
@ -177,8 +458,13 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
peer = bus.Peer{Kind: "direct", ID: senderID} peer = bus.Peer{Kind: "direct", ID: senderID}
} else { } else {
peer = bus.Peer{Kind: "group", ID: chatID} 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 // In group chats, apply unified group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(false, content) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond { if !respond {
return nil return nil
} }
@ -205,6 +491,80 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.
return nil 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 { func extractFeishuSenderID(sender *larkim.EventSender) string {
if sender == nil || sender.SenderId == nil { if sender == nil || sender.SenderId == nil {
return "" return ""

View file

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

View file

@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{
"discord": 1, "discord": 1,
"slack": 1, "slack": 1,
"line": 10, "line": 10,
"feishu": 5,
} }
type channelWorker struct { type channelWorker struct {

View file

@ -252,6 +252,7 @@ type FeishuConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
} }
type DiscordConfig struct { type DiscordConfig struct {

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

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