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:
parent
c119e0d5e8
commit
b54bc1cefd
7 changed files with 767 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
224
pkg/channels/feishu/richtext.go
Normal file
224
pkg/channels/feishu/richtext.go
Normal 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
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ var channelRateConfig = map[string]float64{
|
|||
"discord": 1,
|
||||
"slack": 1,
|
||||
"line": 10,
|
||||
"feishu": 5,
|
||||
}
|
||||
|
||||
type channelWorker struct {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
148
pkg/tools/send_file.go
Normal file
148
pkg/tools/send_file.go
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue