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.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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
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,
|
"discord": 1,
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
"feishu": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelWorker struct {
|
type channelWorker struct {
|
||||||
|
|
|
||||||
|
|
@ -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
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