From 4b355c4ec6055a392477d167a231116a1aa71c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BF=97=E5=8D=8E?= <> Date: Fri, 13 Mar 2026 17:06:24 +0800 Subject: [PATCH 1/4] add aibot send files att to user ,telegram had test,the other channel not test --- bus/bus.go | 11 +-- channels/discord/discord.go | 49 ++++++++++---- channels/slack/slack.go | 47 ++++++++++++- channels/telegram/telegram.go | 115 ++++++++++++++++++++++++++++++-- channels/whatsapp/whatsapp.go | 97 ++++++++++++++++++++++++++- tools/defs.go | 21 ++++++ tools/registry.go | 28 +++++++- tools/tool_message.go | 55 +++++++++++++++ tools/tools_definitions_test.go | 25 +++++++ 9 files changed, 420 insertions(+), 28 deletions(-) diff --git a/bus/bus.go b/bus/bus.go index c7c983a..c32ccb3 100644 --- a/bus/bus.go +++ b/bus/bus.go @@ -49,11 +49,12 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string - ChatID string - Content string - ReplyTo string - Delivery Delivery + Channel string + ChatID string + Content string + ReplyTo string + Delivery Delivery + Attachments []Attachment } type Bus struct { diff --git a/channels/discord/discord.go b/channels/discord/discord.go index 061882b..b479d75 100644 --- a/channels/discord/discord.go +++ b/channels/discord/discord.go @@ -1,12 +1,14 @@ package discord import ( + "bytes" "context" "errors" "fmt" "log" "net" "net/http" + "os" "strings" "sync" "sync/atomic" @@ -103,7 +105,8 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("chat_id is empty") } content := strings.TrimSpace(msg.Content) - if content == "" { + attachments := msg.Attachments + if content == "" && len(attachments) == 0 { return nil } @@ -125,7 +128,7 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { replyToID := resolveDiscordReplyTarget(msg) const maxAttempts = 3 for attempt := 1; attempt <= maxAttempts; attempt++ { - err := sendDiscordMessage(dg, chID, content, replyToID) + err := sendDiscordMessage(dg, chID, content, replyToID, attachments) if err == nil { return nil } @@ -233,21 +236,43 @@ func buildDiscordDelivery(m *discordgo.MessageCreate) bus.Delivery { return d } -func sendDiscordMessage(dg *discordgo.Session, chID, content, replyToID string) error { - if replyToID == "" { - _, err := dg.ChannelMessageSend(chID, content) - return err - } - _, err := dg.ChannelMessageSendComplex(chID, &discordgo.MessageSend{ +func sendDiscordMessage(dg *discordgo.Session, chID, content, replyToID string, attachments []bus.Attachment) error { + msgSend := &discordgo.MessageSend{ Content: content, - Reference: &discordgo.MessageReference{ + } + + // Add reply reference if needed + if replyToID != "" { + msgSend.Reference = &discordgo.MessageReference{ MessageID: replyToID, ChannelID: chID, - }, - AllowedMentions: &discordgo.MessageAllowedMentions{ + } + msgSend.AllowedMentions = &discordgo.MessageAllowedMentions{ RepliedUser: false, - }, + } + } + + // Add attachments + for _, att := range attachments { + if att.Data != nil { + msgSend.Files = append(msgSend.Files, &discordgo.File{ + Name: att.Name, + Reader: bytes.NewReader(att.Data), }) + } else if att.LocalPath != "" { + file, err := os.Open(att.LocalPath) + if err != nil { + return err +} + defer file.Close() + msgSend.Files = append(msgSend.Files, &discordgo.File{ + Name: att.Name, + Reader: file, + }) + } + } + + _, err := dg.ChannelMessageSendComplex(chID, msgSend) return err } diff --git a/channels/slack/slack.go b/channels/slack/slack.go index 2dc3632..2364de7 100644 --- a/channels/slack/slack.go +++ b/channels/slack/slack.go @@ -1,9 +1,12 @@ package slack import ( + "bytes" "context" "fmt" + "io" "net/http" + "os" "strings" "sync" "sync/atomic" @@ -151,7 +154,8 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("chat_id is empty") } text := strings.TrimSpace(msg.Content) - if text == "" { + attachments := msg.Attachments + if text == "" && len(attachments) == 0 { return nil } c.mu.Lock() @@ -175,6 +179,8 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { c.mu.Unlock() } + // Send text message first if present + if text != "" { threadTS, direct := slackThreadMeta(msg) opts := []slack.MsgOption{ slack.MsgOptionText(text, false), @@ -184,6 +190,45 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { opts = append(opts, slack.MsgOptionTS(threadTS)) } _, _, err := api.PostMessageContext(ctx, ch, opts...) + if err != nil { + return err +} + } + + // Send attachments + for _, att := range attachments { + if err := c.sendSlackAttachment(ctx, api, ch, att, msg); err != nil { + return err + } + } + + return nil +} + +func (c *Channel) sendSlackAttachment(ctx context.Context, api *slack.Client, ch string, att bus.Attachment, msg bus.OutboundMessage) error { + var reader io.Reader + var filename string + + if att.Data != nil { + reader = bytes.NewReader(att.Data) + filename = att.Name + } else if att.LocalPath != "" { + file, err := os.Open(att.LocalPath) + if err != nil { + return err + } + defer file.Close() + reader = file + filename = att.Name + } else { + return fmt.Errorf("attachment has no data or local path") + } + + _, err := api.UploadFileContext(ctx, slack.FileUploadParameters{ + Reader: reader, + Filename: filename, + Channels: []string{ch}, + }) return err } diff --git a/channels/telegram/telegram.go b/channels/telegram/telegram.go index fffe233..4041c61 100644 --- a/channels/telegram/telegram.go +++ b/channels/telegram/telegram.go @@ -1,11 +1,13 @@ package telegram import ( + "bytes" "context" "errors" "fmt" "net" "net/http" + "os" "strconv" "strings" "sync" @@ -110,7 +112,8 @@ func (c *Channel) Stop() error { func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { text := strings.TrimSpace(msg.Content) - if text == "" { + attachments := msg.Attachments + if text == "" && len(attachments) == 0 { return nil } @@ -126,6 +129,8 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("telegram not connected") } + // Send text message if present + if text != "" { params := &tgbot.SendMessageParams{ ChatID: chatIDAny, Text: markdownToTelegramHTML(text), @@ -138,14 +143,114 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { } } if err := c.sendMessageWithRetry(ctx, b, params); err == nil { - return nil + // Continue to send attachments } else if !isTelegramParseError(err) { return err - } - + } else { params.Text = text params.ParseMode = "" - return c.sendMessageWithRetry(ctx, b, params) + if err := c.sendMessageWithRetry(ctx, b, params); err != nil { + return err +} + } + } + + // Send attachments + for _, att := range attachments { + if err := c.sendTelegramAttachment(ctx, b, chatIDAny, att, msg); err != nil { + return err + } + } + + return nil +} + +func (c *Channel) sendTelegramAttachment(ctx context.Context, b *tgbot.Bot, chatID any, att bus.Attachment, msg bus.OutboundMessage) error { + var inputFile models.InputFile + + if att.Data != nil { + // Use in-memory data + inputFile = &models.InputFileUpload{ + Filename: att.Name, + Data: bytes.NewReader(att.Data), + } + } else if att.LocalPath != "" { + // Use local file path + file, err := os.Open(att.LocalPath) + if err != nil { + return err + } + defer file.Close() + inputFile = &models.InputFileUpload{ + Filename: att.Name, + Data: file, + } + } else { + return fmt.Errorf("attachment has no data or local path") + } + + replyTo := resolveTelegramReplyTarget(msg) + + // Determine send method based on attachment kind or MIME type + switch strings.ToLower(att.Kind) { + case "image": + params := &tgbot.SendPhotoParams{ + ChatID: chatID, + Photo: inputFile, + Caption: att.Name, + } + if replyTo > 0 { + params.ReplyParameters = &models.ReplyParameters{ + MessageID: int(replyTo), + AllowSendingWithoutReply: true, + } + } + _, err := b.SendPhoto(ctx, params) + return err + case "audio": + params := &tgbot.SendAudioParams{ + ChatID: chatID, + Audio: inputFile, + Caption: att.Name, + } + if replyTo > 0 { + params.ReplyParameters = &models.ReplyParameters{ + MessageID: int(replyTo), + AllowSendingWithoutReply: true, + } + } + _, err := b.SendAudio(ctx, params) + return err + case "video": + params := &tgbot.SendVideoParams{ + ChatID: chatID, + Video: inputFile, + Caption: att.Name, + } + if replyTo > 0 { + params.ReplyParameters = &models.ReplyParameters{ + MessageID: int(replyTo), + AllowSendingWithoutReply: true, + } + } + _, err := b.SendVideo(ctx, params) + return err + default: + // Send as document for all other types + params := &tgbot.SendDocumentParams{ + ChatID: chatID, + Document: inputFile, + Caption: att.Name, + } + if replyTo > 0 { + params.ReplyParameters = &models.ReplyParameters{ + MessageID: int(replyTo), + AllowSendingWithoutReply: true, + } + } + _, err := b.SendDocument(ctx, params) + return err + } } func (c *Channel) onUpdate(ctx context.Context, b *tgbot.Bot, up *models.Update) { diff --git a/channels/whatsapp/whatsapp.go b/channels/whatsapp/whatsapp.go index e8f4bf2..732c23a 100644 --- a/channels/whatsapp/whatsapp.go +++ b/channels/whatsapp/whatsapp.go @@ -147,7 +147,8 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return err } text := strings.TrimSpace(msg.Content) - if text == "" { + attachments := msg.Attachments + if text == "" && len(attachments) == 0 { return nil } @@ -158,11 +159,29 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("whatsapp not connected") } - payload := buildOutboundMessage(text, resolveWhatsAppReplyTarget(msg)) + // Send text message if present + if text != "" { + if err := c.sendWhatsAppMessage(ctx, wa, to, text); err != nil { + return err + } + } + + // Send attachments + for _, att := range attachments { + if err := c.sendWhatsAppAttachment(ctx, wa, to, att); err != nil { + return err + } + } + + return nil +} + +func (c *Channel) sendWhatsAppMessage(ctx context.Context, wa *whatsmeow.Client, to types.JID, text string) error { + payload := buildOutboundMessage(text, "") const maxAttempts = 3 for attempt := 1; attempt <= maxAttempts; attempt++ { - _, err = wa.SendMessage(ctx, to, payload) + _, err := wa.SendMessage(ctx, to, payload) if err == nil { return nil } @@ -181,6 +200,78 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } +func (c *Channel) sendWhatsAppAttachment(ctx context.Context, wa *whatsmeow.Client, to types.JID, att bus.Attachment) error { + var data []byte + var err error + + if att.Data != nil { + data = att.Data + } else if att.LocalPath != "" { + data, err = os.ReadFile(att.LocalPath) + if err != nil { + return err + } + } else { + return fmt.Errorf("attachment has no data or local path") + } + + uploaded, err := wa.Upload(ctx, data, whatsmeow.MediaImage) // Default to image, will be overridden + if err != nil { + return err + } + + msg := &waE2E.Message{} + fileLength := uint64(len(data)) + switch strings.ToLower(att.Kind) { + case "image": + msg.ImageMessage = &waE2E.ImageMessage{ + Mimetype: &att.MIMEType, + Caption: &att.Name, + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: &fileLength, + } + case "audio": + msg.AudioMessage = &waE2E.AudioMessage{ + Mimetype: &att.MIMEType, + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: &fileLength, + } + case "video": + msg.VideoMessage = &waE2E.VideoMessage{ + Mimetype: &att.MIMEType, + Caption: &att.Name, + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: &fileLength, + } + default: + msg.DocumentMessage = &waE2E.DocumentMessage{ + Mimetype: &att.MIMEType, + FileName: &att.Name, + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: &fileLength, + } + } + + _, err = wa.SendMessage(ctx, to, msg) + return err +} + func (c *Channel) handleEvent(raw any) { switch evt := raw.(type) { case *events.Message: diff --git a/tools/defs.go b/tools/defs.go index 16c6e48..0f216d2 100644 --- a/tools/defs.go +++ b/tools/defs.go @@ -213,6 +213,27 @@ func defMessage() llm.ToolDefinition { } } +func defSendFile() llm.ToolDefinition { + return llm.ToolDefinition{ + Type: "function", + Function: llm.FunctionDefinition{ + Name: "send_file", + Description: "Send a file to a specific channel/chat_id. Supports local files and data.", + Parameters: llm.JSONSchema{ + Type: "object", + Properties: map[string]llm.JSONSchema{ + "file_path": {Type: "string", Description: "Path to local file to send (relative to workspace)."}, + "file_name": {Type: "string", Description: "Display name for the file."}, + "channel": {Type: "string", Description: "Target channel (e.g., 'telegram')."}, + "chat_id": {Type: "string", Description: "Target chat ID."}, + "caption": {Type: "string", Description: "Optional caption/text to send with the file."}, + }, + Required: []string{"file_path", "channel", "chat_id"}, + }, + }, + } +} + func defSpawn() llm.ToolDefinition { return llm.ToolDefinition{ Type: "function", diff --git a/tools/registry.go b/tools/registry.go index 547369c..62aa330 100644 --- a/tools/registry.go +++ b/tools/registry.go @@ -65,7 +65,7 @@ func (r *Registry) Definitions() []llm.ToolDefinition { defs = append(defs, defWebSearch()) } if r.Outbound != nil { - defs = append(defs, defMessage()) + defs = append(defs, defMessage(), defSendFile()) } if r.Spawn != nil { defs = append(defs, defSpawn()) @@ -224,10 +224,34 @@ func (r *Registry) Execute(ctx context.Context, tctx Context, name string, args // Avoid duplicate sends to the active conversation; reply with normal assistant text instead. if strings.TrimSpace(tctx.Channel) != "" && strings.TrimSpace(tctx.ChatID) != "" { if ch == strings.TrimSpace(tctx.Channel) && cid == strings.TrimSpace(tctx.ChatID) { - return "", errors.New("message to current session is not allowed; respond with assistant text instead") + return "", errors.New("message to current session is not allowed; use a different chat_id to send to another conversation/channel") + } + } + // Avoid duplicate sends to the active conversation; reply with normal assistant text instead. + if strings.TrimSpace(tctx.Channel) != "" && strings.TrimSpace(tctx.ChatID) != "" { + if ch == strings.TrimSpace(tctx.Channel) && cid == strings.TrimSpace(tctx.ChatID) { + return "", errors.New("message to current session is not allowed; use a different chat_id to send to another conversation/channel") } } return r.message(ctx, ch, cid, a.Content) + case "send_file": + var a struct { + FilePath string `json:"file_path"` + FileName string `json:"file_name"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Caption string `json:"caption"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", err + } + ch := strings.TrimSpace(a.Channel) + cid := strings.TrimSpace(a.ChatID) + if ch == "" || cid == "" || a.FilePath == "" { + return "", errors.New("send_file requires channel, chat_id, and file_path") + } + // For send_file, we allow sending to current session since files have different semantics than text messages + return r.sendFile(ctx, a.FilePath, a.FileName, ch, cid, a.Caption) case "spawn": var a struct { Task string `json:"task"` diff --git a/tools/tool_message.go b/tools/tool_message.go index cf03d66..127e331 100644 --- a/tools/tool_message.go +++ b/tools/tool_message.go @@ -26,3 +26,58 @@ func (r *Registry) message(ctx context.Context, channel, chatID, content string) } return fmt.Sprintf("Message sent to %s:%s", channel, chatID), nil } + +func (r *Registry) sendFile(ctx context.Context, filePath, fileName, channel, chatID, caption string) (string, error) { + filePath = strings.TrimSpace(filePath) + if filePath == "" { + return "", errors.New("file_path is empty") + } + if strings.TrimSpace(channel) == "" || strings.TrimSpace(chatID) == "" { + return "", errors.New("no target channel/chat_id") + } + if r.Outbound == nil { + return "", errors.New("file sending not configured") + } + + // Resolve file path relative to workspace + if !strings.HasPrefix(filePath, "/") { + filePath = strings.TrimPrefix(filePath, "./") + if r.WorkspaceDir != "" { + filePath = fmt.Sprintf("%s/%s", r.WorkspaceDir, filePath) + } + } + + // If fileName is not provided, use the base name of the file + if fileName == "" { + parts := strings.Split(filePath, "/") + fileName = parts[len(parts)-1] + } + + // Determine file kind based on extension or MIME type + kind := "file" + if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") || strings.HasSuffix(strings.ToLower(fileName), ".png") || strings.HasSuffix(strings.ToLower(fileName), ".gif") { + kind = "image" + } else if strings.HasSuffix(strings.ToLower(fileName), ".mp4") || strings.HasSuffix(strings.ToLower(fileName), ".avi") || strings.HasSuffix(strings.ToLower(fileName), ".mov") { + kind = "video" + } else if strings.HasSuffix(strings.ToLower(fileName), ".mp3") || strings.HasSuffix(strings.ToLower(fileName), ".wav") || strings.HasSuffix(strings.ToLower(fileName), ".ogg") { + kind = "audio" + } + + attachment := bus.Attachment{ + Name: fileName, + Kind: kind, + LocalPath: filePath, + } + + msg := bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: strings.TrimSpace(caption), + Attachments: []bus.Attachment{attachment}, + } + + if err := r.Outbound(ctx, msg); err != nil { + return "", err + } + return fmt.Sprintf("File '%s' sent to %s:%s", fileName, channel, chatID), nil +} diff --git a/tools/tools_definitions_test.go b/tools/tools_definitions_test.go index 09c5445..f6f99a2 100644 --- a/tools/tools_definitions_test.go +++ b/tools/tools_definitions_test.go @@ -6,11 +6,17 @@ import ( "testing" "time" + "github.com/mosaxiv/clawlet/bus" "github.com/mosaxiv/clawlet/memory" ) type stubMemoryManager struct{} type stubSkillRegistry struct{} +type stubOutbound func(ctx context.Context, msg bus.OutboundMessage) error + +func stubOutboundFunc(ctx context.Context, msg bus.OutboundMessage) error { + return nil +} func (stubMemoryManager) Search(ctx context.Context, query string, opts memory.SearchOptions) ([]memory.SearchResult, error) { return []memory.SearchResult{}, nil @@ -119,3 +125,22 @@ func TestRegistryDefinitions_IncludesSkillRegistryToolsWhenEnabled(t *testing.T) } } } + +func TestRegistryDefinitions_IncludesMessageToolsWhenEnabled(t *testing.T) { + r := &Registry{ + WorkspaceDir: "/tmp", + RestrictToWorkspace: false, + ExecTimeout: 1 * time.Second, + Outbound: stubOutboundFunc, + } + defs := r.Definitions() + has := map[string]bool{} + for _, d := range defs { + has[d.Function.Name] = true + } + for _, n := range []string{"message", "send_file"} { + if !has[n] { + t.Fatalf("expected message tool definition: %s", n) + } + } +} From 2813853e7f52004c0a428e2f2f52409aa789ec5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BF=97=E5=8D=8E?= <> Date: Sat, 14 Mar 2026 20:56:37 +0800 Subject: [PATCH 2/4] add log files;add token stats;add feishu channel (only text support(file and pic not done)),add set heartbeat and corn rsp to channel .... --- agent/agent.go | 1 + agent/loop.go | 1 + cmd/clawlet/cmd_gateway.go | 61 +++++++++- cmd/clawlet/cmd_sessions.go | 38 +++++++ cmd/clawlet/cmd_stats.go | 56 ++++++++++ cmd/clawlet/cmd_status.go | 1 + cmd/clawlet/main.go | 20 ++++ config/config.go | 33 +++++- heartbeat/service.go | 21 +++- heartbeat/service_test.go | 4 +- llm/anthropic.go | 9 ++ llm/client.go | 21 +++- llm/gemini.go | 9 ++ llm/openai.go | 9 ++ llm/token_usage.go | 215 ++++++++++++++++++++++++++++++++++++ llm/token_usage_test.go | 46 ++++++++ paths/paths.go | 8 ++ session/session.go | 59 ++++++++++ session/session_test.go | 20 ++++ 19 files changed, 619 insertions(+), 13 deletions(-) create mode 100644 cmd/clawlet/cmd_sessions.go create mode 100644 cmd/clawlet/cmd_stats.go create mode 100644 llm/token_usage.go create mode 100644 llm/token_usage_test.go diff --git a/agent/agent.go b/agent/agent.go index e476460..39f9d0e 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -80,6 +80,7 @@ func New(opts Options) (*Agent, error) { MaxTokens: opts.Config.Agents.Defaults.MaxTokensValue(), Temperature: opts.Config.Agents.Defaults.Temperature, Headers: opts.Config.LLM.Headers, + LogLevel: opts.Config.Log.LevelValue(), } treg := &tools.Registry{ diff --git a/agent/loop.go b/agent/loop.go index b673006..6ae383f 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -94,6 +94,7 @@ func NewLoop(opts LoopOptions) (*Loop, error) { MaxTokens: opts.Config.Agents.Defaults.MaxTokensValue(), Temperature: opts.Config.Agents.Defaults.Temperature, Headers: opts.Config.LLM.Headers, + LogLevel: opts.Config.Log.LevelValue(), } treg := &tools.Registry{ diff --git a/cmd/clawlet/cmd_gateway.go b/cmd/clawlet/cmd_gateway.go index 2147758..7e2570d 100644 --- a/cmd/clawlet/cmd_gateway.go +++ b/cmd/clawlet/cmd_gateway.go @@ -7,11 +7,13 @@ import ( "os" "os/signal" "strings" + "unicode/utf8" "github.com/mosaxiv/clawlet/agent" "github.com/mosaxiv/clawlet/bus" "github.com/mosaxiv/clawlet/channels" "github.com/mosaxiv/clawlet/channels/discord" + "github.com/mosaxiv/clawlet/channels/feishu" "github.com/mosaxiv/clawlet/channels/slack" "github.com/mosaxiv/clawlet/channels/telegram" "github.com/mosaxiv/clawlet/channels/whatsapp" @@ -60,7 +62,12 @@ func cmdGateway() *cli.Command { } ch := job.Payload.Channel to := job.Payload.To - if !job.Payload.Deliver || strings.TrimSpace(ch) == "" || strings.TrimSpace(to) == "" { + if !job.Payload.Deliver { + fmt.Printf("cron job %s executed (no delivery)\n", job.ID) + return "", nil + } + if strings.TrimSpace(ch) == "" || strings.TrimSpace(to) == "" { + fmt.Printf("cron job %s executed but no channel/to specified\n", job.ID) return "", nil } _ = b.PublishInbound(ctx, bus.InboundMessage{ @@ -98,12 +105,29 @@ func cmdGateway() *cli.Command { } } + onHeartbeat := func(ctx context.Context, prompt string) (string, error) { + resp, err := loop.ProcessDirect(ctx, prompt, "heartbeat", "cli", "heartbeat") + if err != nil { + return "", err + } + if strings.TrimSpace(cfg.Heartbeat.TargetChannel) != "" && strings.TrimSpace(cfg.Heartbeat.TargetChatID) != "" { + msg := "heartbeat OK" + if strings.TrimSpace(resp) != "" && !heartbeat.IsHeartbeatOK(resp) { + msg = "heartbeat FAILED: " + truncateForLog(resp, 400) + } + _ = b.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: cfg.Heartbeat.TargetChannel, + ChatID: cfg.Heartbeat.TargetChatID, + Content: msg, + }) + } + return resp, nil + } + hb := heartbeat.New(wsAbs, heartbeat.Options{ Enabled: cfg.Heartbeat.EnabledValue(), IntervalSec: cfg.Heartbeat.IntervalSec, - OnHeartbeat: func(ctx context.Context, prompt string) (string, error) { - return loop.ProcessDirect(ctx, prompt, "heartbeat", "cli", "heartbeat") - }, + OnHeartbeat: onHeartbeat, }) hb.Start(ctx) @@ -138,6 +162,15 @@ func cmdGateway() *cli.Command { } cm.Add(whatsapp.New(cfg.Channels.WhatsApp, b)) } + if cfg.Channels.Feishu.Enabled { + if strings.TrimSpace(cfg.Channels.Feishu.AppID) == "" { + return fmt.Errorf("feishu enabled but appId is empty") + } + if strings.TrimSpace(cfg.Channels.Feishu.AppSecret) == "" { + return fmt.Errorf("feishu enabled but appSecret is empty") + } + cm.Add(feishu.New(cfg.Channels.Feishu, b)) + } if err := cm.StartAll(ctx); err != nil { return err @@ -197,3 +230,23 @@ func isLocalGatewayHost(host string) bool { } return false } + +func truncateForLog(s string, max int) string { + s = strings.TrimSpace(s) + if max <= 0 { + max = 200 + } + if len(s) <= max { + return s + } + // Truncate at UTF-8 boundary to avoid invalid encoding + truncated := s + for len(truncated) > max-3 { // Reserve space for "..." + _, size := utf8.DecodeLastRuneInString(truncated) + if size == 0 { + break + } + truncated = truncated[:len(truncated)-size] + } + return truncated + "...(truncated)" +} diff --git a/cmd/clawlet/cmd_sessions.go b/cmd/clawlet/cmd_sessions.go new file mode 100644 index 0000000..678fc46 --- /dev/null +++ b/cmd/clawlet/cmd_sessions.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "fmt" + + "github.com/mosaxiv/clawlet/paths" + "github.com/mosaxiv/clawlet/session" + "github.com/urfave/cli/v3" +) + +func cmdSessions() *cli.Command { + return &cli.Command{ + Name: "sessions", + Usage: "inspect local session state", + Commands: []*cli.Command{ + { + Name: "list", + Usage: "list stored session keys", + Action: func(ctx context.Context, cmd *cli.Command) error { + dir := paths.SessionsDir() + keys, err := session.ListKeys(dir) + if err != nil { + return err + } + if len(keys) == 0 { + fmt.Printf("no sessions found in %s\n", dir) + return nil + } + for _, k := range keys { + fmt.Println(k) + } + return nil + }, + }, + }, + } +} diff --git a/cmd/clawlet/cmd_stats.go b/cmd/clawlet/cmd_stats.go new file mode 100644 index 0000000..914888b --- /dev/null +++ b/cmd/clawlet/cmd_stats.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/mosaxiv/clawlet/llm" + "github.com/urfave/cli/v3" +) + +func cmdStats() *cli.Command { + return &cli.Command{ + Name: "stats", + Usage: "print persisted statistics", + Commands: []*cli.Command{ + { + Name: "tokens", + Usage: "print token usage (per day)", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "date", Usage: "YYYY-MM-DD (default: all dates)"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + date := strings.TrimSpace(cmd.String("date")) + store := llm.DefaultTokenUsageStore() + all := store.All() + if len(all) == 0 { + fmt.Println("no token usage recorded") + return nil + } + + keys := make([]string, 0, len(all)) + for k := range all { + if date != "" && k != date { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) == 0 { + fmt.Printf("no token usage recorded for %s\n", date) + return nil + } + + fmt.Printf("date calls prompt completion total\n") + for _, d := range keys { + u := all[d] + fmt.Printf("%s %5d %6d %10d %5d\n", d, u.Calls, u.PromptTokens, u.CompletionTokens, u.TotalTokens) + } + return nil + }, + }, + }, + } +} diff --git a/cmd/clawlet/cmd_status.go b/cmd/clawlet/cmd_status.go index 08a7cc9..19d8d59 100644 --- a/cmd/clawlet/cmd_status.go +++ b/cmd/clawlet/cmd_status.go @@ -28,6 +28,7 @@ func cmdStatus() *cli.Command { } fmt.Printf("agents.defaults.maxTokens: %d\n", cfg.Agents.Defaults.MaxTokensValue()) fmt.Printf("agents.defaults.temperature: %.2f\n", cfg.Agents.Defaults.TemperatureValue()) + fmt.Printf("log.level: %s\n", cfg.Log.LevelValue()) fmt.Printf("tools.restrictToWorkspace: %v\n", cfg.Tools.RestrictToWorkspaceValue()) fmt.Printf("tools.exec.timeoutSec: %d\n", cfg.Tools.Exec.TimeoutSec) fmt.Printf("tools.web.braveApiKey: %v\n", cfg.Tools.Web.BraveAPIKey != "") diff --git a/cmd/clawlet/main.go b/cmd/clawlet/main.go index e590495..9bf5311 100644 --- a/cmd/clawlet/main.go +++ b/cmd/clawlet/main.go @@ -3,12 +3,30 @@ package main import ( "context" "fmt" + "log" "os" + "path/filepath" + "github.com/mosaxiv/clawlet/paths" "github.com/urfave/cli/v3" ) func main() { + // Get config directory for log file + configDir, err := paths.ConfigDir() + if err != nil { + log.Fatal("Failed to get config directory:", err) + } + logPath := filepath.Join(configDir, "clawlet.log") + + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + log.Fatal("Failed to open log file:", err) + } + defer file.Close() + log.SetOutput(file) + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + root := &cli.Command{ Name: "clawlet", Usage: "minimal Go agent", @@ -22,6 +40,8 @@ func main() { cmdProvider(), cmdChannels(), cmdCron(), + cmdStats(), + cmdSessions(), }, } diff --git a/config/config.go b/config/config.go index 33640a3..ea9aa24 100644 --- a/config/config.go +++ b/config/config.go @@ -18,6 +18,7 @@ type Config struct { Cron CronConfig `json:"cron"` Heartbeat HeartbeatConfig `json:"heartbeat"` Gateway GatewayConfig `json:"gateway"` + Log LogConfig `json:"log,omitempty"` // Channels are optional; enable what you need. Channels ChannelsConfig `json:"channels"` } @@ -238,6 +239,17 @@ func (c MediaToolsConfig) AttachmentEnabledValue() bool { return *c.AttachmentEnabled } +type LogConfig struct { + Level string `json:"level,omitempty"` +} + +func (c LogConfig) LevelValue() string { + if strings.TrimSpace(c.Level) == "" { + return "warning" + } + return strings.ToLower(strings.TrimSpace(c.Level)) +} + type CronConfig struct { Enabled *bool `json:"enabled"` } @@ -250,8 +262,10 @@ func (c CronConfig) EnabledValue() bool { } type HeartbeatConfig struct { - Enabled *bool `json:"enabled"` - IntervalSec int `json:"intervalSec"` + Enabled *bool `json:"enabled"` + IntervalSec int `json:"intervalSec"` + TargetChannel string `json:"targetChannel,omitempty"` // e.g. "telegram" + TargetChatID string `json:"targetChatId,omitempty"` } func (c HeartbeatConfig) EnabledValue() bool { @@ -275,6 +289,7 @@ type ChannelsConfig struct { Slack SlackConfig `json:"slack"` Telegram TelegramConfig `json:"telegram"` WhatsApp WhatsAppConfig `json:"whatsapp"` + Feishu FeishuConfig `json:"feishu"` } type DiscordConfig struct { @@ -320,6 +335,14 @@ type WhatsAppConfig struct { SessionStorePath string `json:"sessionStorePath,omitempty"` // optional: sqlite store path for persistent login } +// Feishu/Lark (WebSocket long connection, no public IP needed). +type FeishuConfig struct { + Enabled bool `json:"enabled"` + AllowFrom []string `json:"allowFrom"` + AppID string `json:"appId"` // App ID from Feishu developer console + AppSecret string `json:"appSecret"` // App Secret from Feishu developer console +} + const ( DefaultAgentMaxTokens = 8192 DefaultAgentTemperature = 0.7 @@ -496,6 +519,12 @@ func Default() *Config { Enabled: false, AllowFrom: nil, }, + Feishu: FeishuConfig{ + Enabled: false, + AllowFrom: nil, + AppID: "", + AppSecret: "", + }, }, } } diff --git a/heartbeat/service.go b/heartbeat/service.go index ab6fef1..ac5a6f3 100644 --- a/heartbeat/service.go +++ b/heartbeat/service.go @@ -8,6 +8,7 @@ import ( "strings" "sync/atomic" "time" + "unicode/utf8" ) const ( @@ -43,6 +44,8 @@ func New(workspace string, opts Options) *Service { if sec <= 0 { sec = DefaultIntervalSec } + log.Printf("heartbeat: initialized with interval %v", sec) + return &Service{ workspace: workspace, onBeat: opts.OnHeartbeat, @@ -110,7 +113,7 @@ func (s *Service) tick(ctx context.Context) { log.Printf("heartbeat: error: %v", err) return } - if isHeartbeatOK(resp) { + if IsHeartbeatOK(resp) { return } if strings.TrimSpace(resp) != "" { @@ -157,8 +160,9 @@ func isEmpty(content string) bool { return true } -func isHeartbeatOK(resp string) bool { - // Tolerate case/underscore/whitespace variations. +// IsHeartbeatOK reports whether the response should be treated as an OK acknowledgement. +// It tolerates case / whitespace / underscore variations. +func IsHeartbeatOK(resp string) bool { return strings.Contains(normalizeToken(resp), normalizeToken(OKToken)) } @@ -170,7 +174,16 @@ func truncateForLog(s string, max int) string { if len(s) <= max { return s } - return s[:max] + "...(truncated)" + // Truncate at UTF-8 boundary to avoid invalid encoding + truncated := s + for len(truncated) > max-3 { // Reserve space for "..." + _, size := utf8.DecodeLastRuneInString(truncated) + if size == 0 { + break +} + truncated = truncated[:len(truncated)-size] + } + return truncated + "...(truncated)" } func normalizeToken(s string) string { diff --git a/heartbeat/service_test.go b/heartbeat/service_test.go index f42d4b8..291b8a3 100644 --- a/heartbeat/service_test.go +++ b/heartbeat/service_test.go @@ -17,8 +17,8 @@ func TestIsHeartbeatOK(t *testing.T) { {"nothing to do: HEARTBEAT_OK", true}, } for _, c := range cases { - if got := isHeartbeatOK(c.in); got != c.want { - t.Fatalf("isHeartbeatOK(%q)=%v want %v", c.in, got, c.want) + if got := IsHeartbeatOK(c.in); got != c.want { + t.Fatalf("IsHeartbeatOK(%q)=%v want %v", c.in, got, c.want) } } } diff --git a/llm/anthropic.go b/llm/anthropic.go index 630ea68..30c8e1c 100644 --- a/llm/anthropic.go +++ b/llm/anthropic.go @@ -58,12 +58,19 @@ func (c *Client) chatAnthropic(ctx context.Context, messages []Message, tools [] req.Header.Set(k, v) } + // Debug log request + c.debugLog("LLM Request to %s: %s", endpoint, string(b)) + resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + + // Debug log response + c.debugLog("LLM Response from %s (status %d): %s", endpoint, resp.StatusCode, string(body)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("llm http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } @@ -109,6 +116,8 @@ func (c *Client) chatAnthropic(ctx context.Context, messages []Message, tools [] } } out.Content = strings.Join(textParts, "\n") + // Record token usage if available. + RecordTokenUsageFromResponse(body) return out, nil } diff --git a/llm/client.go b/llm/client.go index 3561ed9..a66ef16 100644 --- a/llm/client.go +++ b/llm/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "net/http" "strings" "time" @@ -17,6 +18,7 @@ type Client struct { MaxTokens int Temperature *float64 Headers map[string]string + LogLevel string HTTP HTTPDoer } @@ -41,8 +43,9 @@ func (c *Client) Chat(ctx context.Context, messages []Message, tools []ToolDefin if c.HTTP == nil { c.HTTP = &http.Client{Timeout: 120 * time.Second} } + switch normalizeProvider(c.Provider) { - case "", "openai", "openrouter", "ollama","shengsuanyun": + case "", "openai", "openrouter", "ollama", "shengsuanyun": return c.chatOpenAICompatible(ctx, messages, tools) case "anthropic": return c.chatAnthropic(ctx, messages, tools) @@ -55,6 +58,22 @@ func (c *Client) Chat(ctx context.Context, messages []Message, tools []ToolDefin } } +func (c *Client) shouldLogDebug() bool { + level := strings.ToLower(strings.TrimSpace(c.LogLevel)) + switch level { + case "debug", "trace": + return true + default: + return false + } +} + +func (c *Client) debugLog(format string, args ...interface{}) { + if c.shouldLogDebug() { + log.Printf("[DEBUG] "+format, args...) + } +} + func normalizeProvider(p string) string { switch strings.ToLower(strings.TrimSpace(p)) { case "local": diff --git a/llm/gemini.go b/llm/gemini.go index ea92ee6..a2ee8ee 100644 --- a/llm/gemini.go +++ b/llm/gemini.go @@ -60,12 +60,19 @@ func (c *Client) chatGemini(ctx context.Context, messages []Message, tools []Too req.Header.Set(k, v) } + // Debug log request + c.debugLog("LLM Request to %s: %s", endpoint, string(b)) + resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + + // Debug log response + c.debugLog("LLM Response from %s (status %d): %s", endpoint, resp.StatusCode, string(body)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("llm http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } @@ -117,6 +124,8 @@ func (c *Client) chatGemini(ctx context.Context, messages []Message, tools []Too } } out.Content = strings.Join(textParts, "\n") + // Record token usage if available. + RecordTokenUsageFromResponse(body) return out, nil } diff --git a/llm/openai.go b/llm/openai.go index f6a72c0..abf0792 100644 --- a/llm/openai.go +++ b/llm/openai.go @@ -51,6 +51,9 @@ func (c *Client) chatOpenAICompatible(ctx context.Context, messages []Message, t req.Header.Set(k, v) } + // Debug log request + c.debugLog("LLM Request to %s: %s", endpoint, string(b)) + hc := c.HTTP if hc == nil { hc = &http.Client{Timeout: 120 * time.Second} @@ -61,6 +64,10 @@ func (c *Client) chatOpenAICompatible(ctx context.Context, messages []Message, t } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + + // Debug log response + c.debugLog("LLM Response from %s (status %d): %s", endpoint, resp.StatusCode, string(body)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("llm http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } @@ -104,6 +111,8 @@ func (c *Client) chatOpenAICompatible(ctx context.Context, messages []Message, t Arguments: args, }) } + // Record token usage if available. + RecordTokenUsageFromResponse(body) return out, nil } diff --git a/llm/token_usage.go b/llm/token_usage.go new file mode 100644 index 0000000..24dca35 --- /dev/null +++ b/llm/token_usage.go @@ -0,0 +1,215 @@ +package llm + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/mosaxiv/clawlet/paths" +) + +// DailyTokenUsage tracks token usage per calendar day. +type DailyTokenUsage struct { + Calls int `json:"calls"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// TokenUsageStore is a persistent, thread-safe token usage tracker. +type TokenUsageStore struct { + mu sync.Mutex + path string + data map[string]*DailyTokenUsage +} + +// DefaultTokenUsageStore returns the singleton store used by the llm package. +func DefaultTokenUsageStore() *TokenUsageStore { + return globalTokenUsage +} + +// NewTokenUsageStore creates a usage store backed by the given file path. +// The file will be loaded if it exists. +func NewTokenUsageStore(path string) (*TokenUsageStore, error) { + store := &TokenUsageStore{ + path: path, + data: map[string]*DailyTokenUsage{}, + } + if err := store.load(); err != nil { + return nil, err + } + return store, nil +} + +func (s *TokenUsageStore) load() error { + if s.path == "" { + return fmt.Errorf("token usage path is empty") + } + b, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + var raw map[string]DailyTokenUsage + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + for k, v := range raw { + copy := v + s.data[k] = © + } + return nil +} + +func (s *TokenUsageStore) save() error { + if s.path == "" { + return fmt.Errorf("token usage path is empty") + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + out := map[string]DailyTokenUsage{} + for k, v := range s.data { + out[k] = *v + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, b, 0o600) +} + +func (s *TokenUsageStore) Add(prompt, completion, total int) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.data == nil { + s.data = map[string]*DailyTokenUsage{} + } + key := time.Now().UTC().Format("2006-01-02") + d, ok := s.data[key] + if !ok { + d = &DailyTokenUsage{} + s.data[key] = d + } + d.Calls++ + d.PromptTokens += prompt + d.CompletionTokens += completion + d.TotalTokens += total + return s.save() +} + +func (s *TokenUsageStore) Get(day string) (DailyTokenUsage, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if s.data == nil { + return DailyTokenUsage{}, false + } + d, ok := s.data[day] + if !ok { + return DailyTokenUsage{}, false + } + return *d, true +} + +func (s *TokenUsageStore) All() map[string]DailyTokenUsage { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]DailyTokenUsage{} + for k, v := range s.data { + out[k] = *v + } + return out +} + +var globalTokenUsage *TokenUsageStore + +func init() { + path := paths.TokenUsagePath() + store, err := NewTokenUsageStore(path) + if err != nil { + // Fail silently; token usage is non-critical. + store = &TokenUsageStore{path: path, data: map[string]*DailyTokenUsage{}} + } + globalTokenUsage = store +} + +func RecordTokenUsage(prompt, completion, total int) { + if globalTokenUsage == nil { + return + } + _ = globalTokenUsage.Add(prompt, completion, total) +} + +func RecordTokenUsageFromResponse(body []byte) { + prompt, completion, total := parseTokenUsage(body) + if prompt == 0 && completion == 0 && total == 0 { + return + } + RecordTokenUsage(prompt, completion, total) +} + +func parseTokenUsage(body []byte) (prompt, completion, total int) { + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return + } + + getInt := func(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case int64: + return int(t) + case json.Number: + i, _ := t.Int64() + return int(i) + default: + return 0 + } + } + + // OpenAI compatible + if u, ok := m["usage"].(map[string]any); ok { + prompt = getInt(u["prompt_tokens"]) + completion = getInt(u["completion_tokens"]) + total = getInt(u["total_tokens"]) + if total == 0 { + total = prompt + completion + } + } + + // Gemini tokenUsage + if total == 0 { + if u, ok := m["tokenUsage"].(map[string]any); ok { + prompt = getInt(u["promptTokens"]) + completion = getInt(u["completionTokens"]) + total = getInt(u["totalTokens"]) + if total == 0 { + total = prompt + completion + } + } + } + + // Anthropic / other providers might expose prompt/completion tokens at top level. + if prompt == 0 { + prompt = getInt(m["prompt_tokens"]) + } + if completion == 0 { + completion = getInt(m["completion_tokens"]) + } + if total == 0 { + total = getInt(m["total_tokens"]) + } + + if total == 0 { + total = prompt + completion + } + return +} diff --git a/llm/token_usage_test.go b/llm/token_usage_test.go new file mode 100644 index 0000000..7c93444 --- /dev/null +++ b/llm/token_usage_test.go @@ -0,0 +1,46 @@ +package llm + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestTokenUsageStore_AddAndPersist(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "usage.json") + + store, err := NewTokenUsageStore(path) + if err != nil { + t.Fatalf("NewTokenUsageStore: %v", err) + } + + // Add usage for today. + if err := store.Add(10, 20, 30); err != nil { + t.Fatalf("Add: %v", err) + } + + // Ensure file was written. + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if len(b) == 0 { + t.Fatalf("expected non-empty file") + } + + // Reload and check values. + store2, err := NewTokenUsageStore(path) + if err != nil { + t.Fatalf("NewTokenUsageStore (reload): %v", err) + } + day := time.Now().UTC().Format("2006-01-02") + usage, ok := store2.Get(day) + if !ok { + t.Fatalf("expected usage for %q", day) + } + if usage.Calls != 1 || usage.PromptTokens != 10 || usage.CompletionTokens != 20 || usage.TotalTokens != 30 { + t.Fatalf("unexpected usage: %+v", usage) + } +} diff --git a/paths/paths.go b/paths/paths.go index 3dcc1bf..b4d9ff7 100644 --- a/paths/paths.go +++ b/paths/paths.go @@ -47,6 +47,14 @@ func WorkspaceDir() string { return filepath.Join(dir, "workspace") } +func TokenUsagePath() string { + dir, err := ConfigDir() + if err != nil { + return ".clawlet/token_usage.json" + } + return filepath.Join(dir, "token_usage.json") +} + func EnsureStateDirs() error { cfgDir, err := ConfigDir() if err != nil { diff --git a/session/session.go b/session/session.go index f3512f0..8d8e7b0 100644 --- a/session/session.go +++ b/session/session.go @@ -235,6 +235,12 @@ func Save(dir string, s *Session) error { s.mu.Lock() defer s.mu.Unlock() + // Ensure the session key is stored on disk so it can be recovered from the session file later. + if s.Metadata == nil { + s.Metadata = map[string]any{} + } + s.Metadata["session_key"] = s.Key + f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err @@ -272,6 +278,59 @@ func Save(dir string, s *Session) error { return nil } +// ListKeys returns the session keys for all sessions stored in the given directory. +// +// It reads the first JSON line of each session file and tries to extract the stored +// "session_key" metadata value. If the metadata is missing, it falls back to the +// filename (without the .jsonl extension). +func ListKeys(dir string) ([]string, error) { + files, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var keys []string + for _, de := range files { + if de.IsDir() { + continue + } + name := de.Name() + if !strings.HasSuffix(name, ".jsonl") { + continue + } + path := filepath.Join(dir, name) + f, err := os.Open(path) + if err != nil { + continue + } + sc := bufio.NewScanner(f) + var k string + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var ml metadataLine + if err := json.Unmarshal([]byte(line), &ml); err == nil { + if v, ok := ml.Metadata["session_key"]; ok { + if ks, ok := v.(string); ok && ks != "" { + k = ks + } + } + } + break + } + _ = f.Close() + if k == "" { + k = strings.TrimSuffix(name, ".jsonl") + } + keys = append(keys, k) + } + return keys, nil +} + func cloneMessages(in []Message) []Message { out := make([]Message, 0, len(in)) for _, m := range in { diff --git a/session/session_test.go b/session/session_test.go index d1a10b7..ae27382 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -90,3 +90,23 @@ func TestSave_AfterConsolidationPersistsTrimmedMessages(t *testing.T) { t.Fatalf("messages=%d want=%d", got, keep) } } + +func TestListKeysIncludesSavedSessionKey(t *testing.T) { + dir := t.TempDir() + key := "cli:test" + s := New(key) + if err := Save(dir, s); err != nil { + t.Fatalf("save: %v", err) + } + + keys, err := ListKeys(dir) + if err != nil { + t.Fatalf("list keys: %v", err) + } + if len(keys) != 1 { + t.Fatalf("keys=%v", keys) + } + if keys[0] != key { + t.Fatalf("unexpected key %q", keys[0]) + } +} From 896e0d69cea78b0c48e37697098f42c3bc55db9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BF=97=E5=8D=8E?= <> Date: Sat, 14 Mar 2026 20:59:19 +0800 Subject: [PATCH 3/4] add feishu channel (only text) --- channels/feishu/feishu.go | 1444 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1444 insertions(+) create mode 100644 channels/feishu/feishu.go diff --git a/channels/feishu/feishu.go b/channels/feishu/feishu.go new file mode 100644 index 0000000..f58e7c7 --- /dev/null +++ b/channels/feishu/feishu.go @@ -0,0 +1,1444 @@ +package feishu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + "github.com/larksuite/oapi-sdk-go/v3/ws" + "github.com/mosaxiv/clawlet/bus" + "github.com/mosaxiv/clawlet/channels" + "github.com/mosaxiv/clawlet/config" +) + +// Feishu API constants +const ( + feishuAPIBase = "https://open.feishu.cn" +) + +// Event payload structure for im.message.receive_v1/v2 +// V2 format: {"header":{...}, "event":{"sender":{...}, "message":{...}}} +// V1 format: {"header":{...}, "event":{"sender_id":{...}, "sender_type":"...", "message":{...}}} +type feishuEventPayload struct { + Schema string `json:"schema"` + Header *feishuEventHeader `json:"header"` + Event *feishuEventDataV1 `json:"event"` +} + +type feishuEventHeader struct { + EventType string `json:"event_type"` + EventID string `json:"event_id"` + AppID string `json:"app_id"` + TenantKey string `json:"tenant_key"` + Timestamp string `json:"timestamp"` +} + +// V1 event data format (direct fields) +type feishuEventDataV1 struct { + SenderID *feishuUserID `json:"sender_id,omitempty"` + SenderType *string `json:"sender_type,omitempty"` + TenantKey *string `json:"tenant_key,omitempty"` + Message *feishuEventMessage `json:"message,omitempty"` + // V2 format fields (nested) + Sender *feishuEventSender `json:"sender,omitempty"` +} + +type feishuEventSender struct { + SenderID *feishuUserID `json:"sender_id,omitempty"` + SenderType *string `json:"sender_type,omitempty"` + TenantKey *string `json:"tenant_key,omitempty"` +} + +type feishuUserID struct { + UserId *string `json:"user_id,omitempty"` + OpenId *string `json:"open_id,omitempty"` + UnionId *string `json:"union_id,omitempty"` +} + +type feishuEventMessage struct { + MessageID *string `json:"message_id,omitempty"` + RootID *string `json:"root_id,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + ChatID *string `json:"chat_id,omitempty"` + MsgType *string `json:"message_type,omitempty"` // v1 uses "message_type" + Content *string `json:"content,omitempty"` + SenderID *feishuUserID `json:"sender_id,omitempty"` + SenderType *string `json:"sender_type,omitempty"` + CreateTime *string `json:"create_time,omitempty"` + UpdateID *int64 `json:"update_id,omitempty"` + ChatType *string `json:"chat_type,omitempty"` + MentionAll *bool `json:"mention_all,omitempty"` +} + +// Legacy structure for direct message parsing +type feishuMessageV2 struct { + MessageID string `json:"message_id"` + RootID string `json:"root_id"` + ParentID string `json:"parent_id"` + ChatID string `json:"chat_id"` + MsgType string `json:"msg_type"` + Content string `json:"content"` + SenderID string `json:"sender_id"` + SenderType string `json:"sender_type"` + SenderTenantKey string `json:"sender_tenant_key"` + CreateTime string `json:"create_time"` + UpdateID int64 `json:"update_id"` + ChatType string `json:"chat_type"` + MentionAll *bool `json:"mention_all"` +} + +type feishuMessageContent struct { + Text string `json:"text"` +} + +type Channel struct { + cfg config.FeishuConfig + bus *bus.Bus + allow channels.AllowList + + running atomic.Bool + + mu sync.Mutex + conn *websocket.Conn + client *http.Client + appID string + appSecret string + cancel context.CancelFunc + + // Bot info + botOpenID string + + // Processed message dedup + processedIDs map[string]bool + processedMu sync.Mutex + + // Connection info + connURL *url.URL + connID string + serviceID string + + // Config + reconnectCount int + reconnectInterval time.Duration + pingInterval time.Duration +} + +func New(cfg config.FeishuConfig, b *bus.Bus) *Channel { + return &Channel{ + cfg: cfg, + bus: b, + allow: channels.AllowList{AllowFrom: cfg.AllowFrom}, + client: &http.Client{Timeout: 30 * time.Second}, + processedIDs: make(map[string]bool), + reconnectCount: -1, // unlimited + reconnectInterval: 120 * time.Second, + pingInterval: 120 * time.Second, + } +} + +func (c *Channel) Name() string { return "feishu" } +func (c *Channel) IsRunning() bool { return c.running.Load() } + +func (c *Channel) Start(ctx context.Context) error { + if strings.TrimSpace(c.cfg.AppID) == "" { + return fmt.Errorf("feishu appID is empty") + } + if strings.TrimSpace(c.cfg.AppSecret) == "" { + return fmt.Errorf("feishu appSecret is empty") + } + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + + c.mu.Lock() + c.appID = strings.TrimSpace(c.cfg.AppID) + c.appSecret = strings.TrimSpace(c.cfg.AppSecret) + c.cancel = cancel + c.mu.Unlock() + + c.running.Store(true) + defer c.running.Store(false) + + // Fetch bot open_id + c.fetchBotOpenID(runCtx) + + // Connect to WebSocket + if err := c.connect(runCtx); err != nil { + return fmt.Errorf("failed to connect to feishu websocket: %w", err) + } + + fmt.Printf("feishu channel started (app_id=%s)\n", c.appID[:min(12, len(c.appID))]) + + return runCtx.Err() +} + +func (c *Channel) Stop() error { + c.running.Store(false) + c.mu.Lock() + cancel := c.cancel + c.cancel = nil + c.mu.Unlock() + + if cancel != nil { + cancel() + } + + c.mu.Lock() + conn := c.conn + c.conn = nil + c.mu.Unlock() + + if conn != nil { + conn.Close() + } + return nil +} + +func (c *Channel) connect(ctx context.Context) error { + // Get WebSocket URL + connURL, err := c.getConnURL(ctx) + if err != nil { + return fmt.Errorf("failed to get connection URL: %w", err) + } + + u, err := url.Parse(connURL) + if err != nil { + return fmt.Errorf("failed to parse URL: %w", err) + } + + connID := u.Query().Get("device_id") + serviceID := u.Query().Get("service_id") + + fmt.Printf("feishu: got websocket endpoint (conn_id=%s, service_id=%s)\n", connID, serviceID) + + // Connect + conn, _, err := websocket.DefaultDialer.Dial(connURL, nil) + if err != nil { + return err + } + + c.mu.Lock() + c.conn = conn + c.connURL = u + c.connID = connID + c.serviceID = serviceID + c.mu.Unlock() + + fmt.Println("feishu: websocket connected") + + // Start ping loop + go c.pingLoop(ctx) + + // Run receive loop in current goroutine (don't return) + c.receiveMessageLoop(ctx) + + return nil +} + +func (c *Channel) getConnURL(ctx context.Context) (string, error) { + body := map[string]string{ + "AppID": c.appID, + "AppSecret": c.appSecret, + } + bs, _ := json.Marshal(body) + + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, feishuAPIBase+"/callback/ws/endpoint", bytes.NewBuffer(bs)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("locale", "zh") + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *struct { + URL string `json:"url"` + ClientConfig *struct { + ReconnectCount int `json:"reconnect_count"` + ReconnectInterval int `json:"reconnect_interval"` + ReconnectNonce int `json:"reconnect_nonce"` + PingInterval int `json:"ping_interval"` + } `json:"client_config"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if result.Code != 0 { + return "", fmt.Errorf("feishu endpoint error: code=%d, msg=%s", result.Code, result.Msg) + } + + if result.Data == nil || result.Data.URL == "" { + return "", fmt.Errorf("feishu endpoint returned empty URL") + } + + if result.Data.ClientConfig != nil { + c.reconnectCount = result.Data.ClientConfig.ReconnectCount + c.reconnectInterval = time.Duration(result.Data.ClientConfig.ReconnectInterval) * time.Second + c.pingInterval = time.Duration(result.Data.ClientConfig.PingInterval) * time.Second + } + + return result.Data.URL, nil +} + +func (c *Channel) receiveMessageLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + fmt.Println("feishu: receiveMessageLoop: context done") + return + default: + } + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + fmt.Println("feishu: receiveMessageLoop: connection is nil") + return + } + + mt, msg, err := conn.ReadMessage() + if err != nil { + fmt.Printf("feishu: receiveMessageLoop: read error: %v\n", err) + c.disconnect(ctx) + go c.reconnect(ctx) + return + } + + fmt.Printf("feishu: <<< received WS message: type=%d, len=%d\n", mt, len(msg)) + + if mt != websocket.BinaryMessage { + fmt.Printf("feishu: received non-binary message type: %d\n", mt) + continue + } + + go c.handleMessage(ctx, msg) + } +} + +func (c *Channel) handleMessage(ctx context.Context, msg []byte) { + defer func() { + if r := recover(); r != nil { + fmt.Printf("feishu: handleMessage panic: %v\n", r) + } + }() + + fmt.Printf("feishu: handling message (%d bytes): % x\n", len(msg), msg[:min(32, len(msg))]) + + // Parse frame using official SDK + var frame ws.Frame + if err := frame.Unmarshal(msg); err != nil { + fmt.Printf("feishu: unmarshal frame failed: %v\n", err) + return + } + + fmt.Printf("feishu: parsed frame: method=%d, service=%d, len(headers)=%d, len(payload)=%d\n", + frame.Method, frame.Service, len(frame.Headers), len(frame.Payload)) + + // Handle based on frame type + if frame.Method == 0 { // CONTROL + fmt.Println("feishu: handling CONTROL frame") + c.handleControlFrame(ctx, frame) + } else if frame.Method == 1 { // DATA + fmt.Println("feishu: handling DATA frame") + c.handleDataFrame(ctx, frame) + } else { + fmt.Printf("feishu: unknown frame method: %d\n", frame.Method) + } +} + +func (c *Channel) handleControlFrame(ctx context.Context, frame ws.Frame) { + hs := ws.Headers(frame.Headers) + t := hs.GetString("type") + + if t == "pong" { + fmt.Println("feishu: received pong") + if len(frame.Payload) > 0 { + var conf struct { + PingInterval int `json:"ping_interval"` + ReconnectCount int `json:"reconnect_count"` + ReconnectInterval int `json:"reconnect_interval"` + } + if err := json.Unmarshal(frame.Payload, &conf); err == nil { + if conf.PingInterval > 0 { + c.pingInterval = time.Duration(conf.PingInterval) * time.Second + } + } + } + } +} + +func (c *Channel) handleDataFrame(ctx context.Context, frame ws.Frame) { + hs := ws.Headers(frame.Headers) + msgID := hs.GetString("message_id") + traceID := hs.GetString("trace_id") + type_ := hs.GetString("type") + + fmt.Printf("feishu: received data frame: type=%s, msg_id=%s, trace_id=%s\n", type_, msgID, traceID) + + if type_ != "event" { + fmt.Printf("feishu: skipping non-event frame\n") + return + } + + // Parse event payload + var payload feishuEventPayload + if err := json.Unmarshal(frame.Payload, &payload); err != nil { + fmt.Printf("feishu: unmarshal event payload failed: %v\n", err) + return + } + + fmt.Printf("feishu: event type: %s\n", payload.Header.EventType) + + // Handle message receive events + if payload.Header.EventType == "im.message.receive_v1" || payload.Header.EventType == "im.message.receive_v2" { + fmt.Printf("feishu: processing %s event\n", payload.Header.EventType) + + if payload.Event == nil { + fmt.Println("feishu: event is nil") + return + } + if payload.Event.Message == nil { + fmt.Println("feishu: event.Message is nil") + return + } + msg := payload.Event.Message + + // Get sender ID from event level (v1) or message level (v2) + var senderIDStr string + if payload.Event.SenderID != nil && payload.Event.SenderID.OpenId != nil { + senderIDStr = *payload.Event.SenderID.OpenId + } else if msg.SenderID != nil && msg.SenderID.OpenId != nil { + senderIDStr = *msg.SenderID.OpenId + } + + // Safe access with nil checks + chatID := "" + if msg.ChatID != nil { + chatID = *msg.ChatID + } + msgType := "" + if msg.MsgType != nil { + msgType = *msg.MsgType + } + content := "" + if msg.Content != nil { + content = *msg.Content + } + chatType := "" + if msg.ChatType != nil { + chatType = *msg.ChatType + } + + fmt.Printf("feishu: received message: chatID=%s, senderID=%s, msgType=%s, chatType=%s, content=%s\n", + chatID, senderIDStr, msgType, chatType, content) + + // Process the message + c.processMessageV2(ctx, msg, frame.Payload, hs, senderIDStr) + } else { + fmt.Printf("feishu: unhandled event type: %s\n", payload.Header.EventType) + } +} + +func (c *Channel) processMessageV2(ctx context.Context, msg *feishuEventMessage, rawPayload []byte, hs ws.Headers, senderID string) { + if msg == nil { + fmt.Println("feishu: message is nil") + return + } + + // Extract values with nil checks + messageID := "" + if msg.MessageID != nil { + messageID = *msg.MessageID + } + chatID := "" + if msg.ChatID != nil { + chatID = *msg.ChatID + } + msgType := "" + if msg.MsgType != nil { + msgType = *msg.MsgType + } + content := "" + if msg.Content != nil { + content = *msg.Content + } + chatType := "" + if msg.ChatType != nil { + chatType = *msg.ChatType + } + rootID := "" + if msg.RootID != nil { + rootID = *msg.RootID + } + parentID := "" + if msg.ParentID != nil { + parentID = *msg.ParentID + } + + fmt.Printf("feishu: processing message: ID=%s, chatID=%s, senderID=%s, msgType=%s\n", messageID, chatID, senderID, msgType) + + // Dedup check + c.processedMu.Lock() + if c.processedIDs[messageID] { + c.processedMu.Unlock() + fmt.Printf("feishu: duplicate message %s, skipping\n", messageID) + return + } + c.processedIDs[messageID] = true + if len(c.processedIDs) > 1000 { + for k := range c.processedIDs { + delete(c.processedIDs, k) + if len(c.processedIDs) <= 500 { + break + } + } + } + c.processedMu.Unlock() + + // Ignore bot's own messages + if c.botOpenID != "" && senderID == c.botOpenID { + fmt.Println("feishu: ignoring bot's own message") + return + } + + // Handle different message types + isDirect := chatType == "p2p" || strings.HasPrefix(chatID, "oc_") + switch msgType { + case "text": + c.processTextMessage(ctx, content, senderID, chatID, messageID, rootID, parentID, isDirect) + case "image": + c.processImageMessage(ctx, content, senderID, chatID, messageID, rootID, parentID, isDirect) + case "file": + c.processFileMessage(ctx, content, senderID, chatID, messageID, rootID, parentID, isDirect) + default: + fmt.Printf("feishu: unsupported message type: %s\n", msgType) + } +} + +func (c *Channel) processTextMessage(ctx context.Context, content, senderID, chatID, messageID, rootID, parentID string, isDirect bool) { + var msgContent feishuMessageContent + if err := json.Unmarshal([]byte(content), &msgContent); err != nil { + fmt.Printf("feishu: failed to parse content: %v\n", err) + return + } + + text := strings.TrimSpace(msgContent.Text) + fmt.Printf("feishu: message text: %s\n", text) + if text == "" { + fmt.Println("feishu: empty message text") + return + } + + // Check allowlist + if !c.allow.Allowed(senderID) { + fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + return + } + + // Strip bot @mention + text = c.stripBotMention(text) + fmt.Printf("feishu: after strip mention: %s\n", text) + if text == "" { + fmt.Println("feishu: empty text after stripping mentions") + return + } + + // Publish to bus + fmt.Println("feishu: publishing to bus") + _ = c.bus.PublishInbound(ctx, bus.InboundMessage{ + Channel: "feishu", + SenderID: senderID, + ChatID: chatID, + Content: text, + SessionKey: "feishu:" + chatID, + Delivery: bus.Delivery{ + MessageID: messageID, + ThreadID: rootID, + ReplyToID: parentID, + IsDirect: isDirect, + }, + }) + + // Send acknowledgment response to Feishu + c.sendEventAck(ctx, messageID, "") +} + +func (c *Channel) processImageMessage(ctx context.Context, content, senderID, chatID, messageID, rootID, parentID string, isDirect bool) { + // Parse image content - format: {"image_key":"img_xxx"} + var imgContent struct { + ImageKey string `json:"image_key"` + } + if err := json.Unmarshal([]byte(content), &imgContent); err != nil { + fmt.Printf("feishu: failed to parse image content: %v, raw: %s\n", err, content) + return + } + + if imgContent.ImageKey == "" { + fmt.Println("feishu: empty image_key") + return + } + + fmt.Printf("feishu: received image: image_key=%s\n", imgContent.ImageKey) + + // Check allowlist + if !c.allow.Allowed(senderID) { + fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + return + } + + // Download image to local workspace (optional - continue even if download fails) + localPath := "" + downloadedPath, err := c.downloadImage(ctx, imgContent.ImageKey, chatID) + if err != nil { + fmt.Printf("feishu: failed to download image: %v (continuing without local file)\n", err) + } else { + localPath = downloadedPath + } + + _ = c.bus.PublishInbound(ctx, bus.InboundMessage{ + Channel: "feishu", + SenderID: senderID, + ChatID: chatID, + Content: "[Image]", + Attachments: []bus.Attachment{ + { + ID: imgContent.ImageKey, + Name: "image.jpg", + MIMEType: "image/jpeg", + Kind: "image", + LocalPath: localPath, + }, + }, + SessionKey: "feishu:" + chatID, + Delivery: bus.Delivery{ + MessageID: messageID, + ThreadID: rootID, + ReplyToID: parentID, + IsDirect: isDirect, + }, + }) + + // Send acknowledgment response to Feishu + c.sendEventAck(ctx, messageID, "") + fmt.Println("feishu: image message published to bus") +} + +func (c *Channel) processFileMessage(ctx context.Context, content, senderID, chatID, messageID, rootID, parentID string, isDirect bool) { + var fileContent struct { + FileKey string `json:"file_key"` + FileName string `json:"file_name"` + } + if err := json.Unmarshal([]byte(content), &fileContent); err != nil { + fmt.Printf("feishu: failed to parse file content: %v\n", err) + return + } + + if fileContent.FileKey == "" { + fmt.Println("feishu: empty file_key") + return + } + + fmt.Printf("feishu: received file: file_key=%s\n", fileContent.FileKey) + + // Check allowlist + if !c.allow.Allowed(senderID) { + fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + return + } + + // Download file to local workspace (optional - continue even if download fails) + localPath := "" + downloadedPath, err := c.downloadFile(ctx, fileContent.FileKey, chatID, fileContent.FileName) + if err != nil { + fmt.Printf("feishu: failed to download file: %v (continuing without local file)\n", err) + } else { + localPath = downloadedPath + } + + fileName := fileContent.FileName + if fileName == "" { + fileName = "file" + } + + _ = c.bus.PublishInbound(ctx, bus.InboundMessage{ + Channel: "feishu", + SenderID: senderID, + ChatID: chatID, + Content: "[File]", + Attachments: []bus.Attachment{ + { + ID: fileContent.FileKey, + Name: fileName, + MIMEType: "application/octet-stream", + Kind: "file", + LocalPath: localPath, + }, + }, + SessionKey: "feishu:" + chatID, + Delivery: bus.Delivery{ + MessageID: messageID, + ThreadID: rootID, + ReplyToID: parentID, + IsDirect: isDirect, + }, + }) + + // Send acknowledgment response to Feishu + c.sendEventAck(ctx, messageID, "") + fmt.Println("feishu: file message published to bus") +} + +func (c *Channel) publishMessageToBus(ctx context.Context, senderID, chatID, text, messageID, rootID, parentID string, isDirect bool) { + // Check allowlist + if !c.allow.Allowed(senderID) { + fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + return + } + + // Strip bot @mention + text = c.stripBotMention(text) + fmt.Printf("feishu: after strip mention: %s\n", text) + if text == "" { + fmt.Println("feishu: empty text after stripping mentions") + return + } + + // Determine if direct message + isDirect = isDirect || strings.HasPrefix(chatID, "oc_") + fmt.Printf("feishu: isDirect=%v\n", isDirect) + + // Publish to bus + fmt.Println("feishu: publishing to bus") + _ = c.bus.PublishInbound(ctx, bus.InboundMessage{ + Channel: "feishu", + SenderID: senderID, + ChatID: chatID, + Content: text, + SessionKey: "feishu:" + chatID, + Delivery: bus.Delivery{ + MessageID: messageID, + ThreadID: rootID, + ReplyToID: parentID, + IsDirect: isDirect, + }, + }) +} + +// sendEventAck sends an acknowledgment response to Feishu for event processing +func (c *Channel) sendEventAck(ctx context.Context, msgID, traceID string) { + c.mu.Lock() + conn := c.conn + serviceID := c.serviceID + c.mu.Unlock() + + if conn == nil || serviceID == "" { + return + } + + sid := int32(0) + fmt.Sscanf(serviceID, "%d", &sid) + + // Build response frame + response := map[string]interface{}{ + "code": 0, // HTTP 200 OK + } + responseJSON, _ := json.Marshal(response) + + frame := &ws.Frame{ + SeqID: 0, + LogID: 0, + Service: sid, + Method: 1, // DATA frame + Headers: []ws.Header{ + {Key: "message_id", Value: msgID}, + {Key: "trace_id", Value: traceID}, + {Key: "type", Value: "event"}, + }, + Payload: responseJSON, + } + + data, _ := frame.Marshal() + if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil { + fmt.Printf("feishu: failed to send event ack: %v\n", err) + } else { + fmt.Println("feishu: sent event acknowledgment") + } +} + +func (c *Channel) stripBotMention(text string) string { + text = strings.TrimSpace(text) + + for { + start := strings.Index(text, "") + if end == -1 { + break + } + end += start + 5 + mention := text[start:end] + + if strings.Contains(mention, `user_id="`) { + text = strings.Replace(text, mention, "", 1) + text = strings.TrimSpace(text) + text = strings.TrimLeft(text, ":,,.。::") + text = strings.TrimSpace(text) + continue + } + break + } + return text +} + +func (c *Channel) pingLoop(ctx context.Context) { + defer func() { + if r := recover(); r != nil { + fmt.Printf("feishu: ping loop panic: %v\n", r) + } + }() + + for { + select { + case <-ctx.Done(): + return + case <-time.After(c.pingInterval): + c.sendPing(ctx) + } + } +} + +func (c *Channel) sendPing(ctx context.Context) { + c.mu.Lock() + conn := c.conn + serviceID := c.serviceID + c.mu.Unlock() + + if conn == nil || serviceID == "" { + return + } + + i := int32(0) + fmt.Sscanf(serviceID, "%d", &i) + + frame := ws.NewPingFrame(i) + bs, _ := frame.Marshal() + + if err := conn.WriteMessage(websocket.BinaryMessage, bs); err != nil { + fmt.Printf("feishu: ping failed: %v\n", err) + } else { + fmt.Println("feishu: sent ping") + } +} + +func (c *Channel) reconnect(ctx context.Context) { + if !c.running.Load() { + return + } + + fmt.Println("feishu: attempting to reconnect...") + + // Random nonce for first reconnect + if c.reconnectCount < 0 || c.reconnectCount > 0 { + time.Sleep(time.Duration(randInt(0, 30000)) * time.Millisecond) + } + + count := 0 + for c.reconnectCount < 0 || count < c.reconnectCount { + if err := c.connect(ctx); err != nil { + fmt.Printf("feishu: reconnect failed: %v\n", err) + count++ + time.Sleep(c.reconnectInterval) + continue + } + return + } + + fmt.Printf("feishu: reconnect failed after %d attempts\n", count) +} + +func (c *Channel) disconnect(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return + } + + c.conn.Close() + fmt.Printf("feishu: disconnected from %s\n", c.connURL) + + c.conn = nil + c.connURL = nil + c.connID = "" + c.serviceID = "" +} + +func (c *Channel) getImageURL(ctx context.Context, imageKey string) (string, error) { + token, err := c.getTenantAccessToken(ctx) + if err != nil { + return "", err + } + + // Get image download URL + url := fmt.Sprintf("%s/open-apis/im/v1/images/%s", feishuAPIBase, imageKey) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + ImageKey string `json:"image_key"` + ImageURL string `json:"image_url"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if result.Code != 0 { + return "", fmt.Errorf("feishu API error: code=%d, msg=%s", result.Code, result.Msg) + } + + return result.Data.ImageURL, nil +} + +func (c *Channel) downloadImage(ctx context.Context, imageKey, chatID string) (string, error) { + token, err := c.getTenantAccessToken(ctx) + if err != nil { + return "", err + } + + // Get image download URL + url := fmt.Sprintf("%s/open-apis/im/v1/images/%s", feishuAPIBase, imageKey) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download image: status=%d", resp.StatusCode) + } + + // Read image data + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // Save to workspace directory + filename := fmt.Sprintf("feishu_image_%s_%s.jpg", chatID, imageKey) + localPath, err := c.saveToWorkspace(filename, data) + if err != nil { + return "", err + } + + fmt.Printf("feishu: image saved to %s\n", localPath) + return localPath, nil +} + +func (c *Channel) downloadFile(ctx context.Context, fileKey, chatID, fileName string) (string, error) { + token, err := c.getTenantAccessToken(ctx) + if err != nil { + return "", err + } + + // Get file download URL + url := fmt.Sprintf("%s/open-apis/im/v1/files/%s", feishuAPIBase, fileKey) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download file: status=%d", resp.StatusCode) + } + + // Read file data + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // Save to workspace directory + if fileName == "" { + fileName = fmt.Sprintf("feishu_file_%s_%s", chatID, fileKey) + } else { + fileName = fmt.Sprintf("feishu_%s_%s", chatID, fileName) + } + localPath, err := c.saveToWorkspace(fileName, data) + if err != nil { + return "", err + } + + fmt.Printf("feishu: file saved to %s\n", localPath) + return localPath, nil +} + +func (c *Channel) saveToWorkspace(filename string, data []byte) (string, error) { + // Get workspace directory (use current directory or CLAWLET_WORKSPACE env) + workspace := os.Getenv("CLAWLET_WORKSPACE") + if workspace == "" { + workspace = "." + } + + // Create feishu subdirectory + feishuDir := filepath.Join(workspace, "feishu_files") + if err := os.MkdirAll(feishuDir, 0755); err != nil { + return "", err + } + + // Sanitize filename + filename = strings.ReplaceAll(filename, "/", "_") + filename = strings.ReplaceAll(filename, "\\", "_") + filename = strings.ReplaceAll(filename, "..", "_") + + localPath := filepath.Join(feishuDir, filename) + if err := os.WriteFile(localPath, data, 0644); err != nil { + return "", err + } + + return localPath, nil +} + +func (c *Channel) fetchBotOpenID(ctx context.Context) { + token, err := c.getTenantAccessToken(ctx) + if err != nil { + fmt.Printf("feishu: failed to get token for bot info: %v\n", err) + return + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, feishuAPIBase+"/open-apis/bot/v3/info", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := c.client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Bot struct { + OpenID string `json:"open_id"` + } `json:"bot"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return + } + + if result.Code == 0 && result.Bot.OpenID != "" { + c.botOpenID = result.Bot.OpenID + fmt.Printf("feishu: bot open_id=%s\n", c.botOpenID[:min(12, len(c.botOpenID))]) + } +} + +func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { + fmt.Printf("feishu: Send called: channel=%s, chatID=%s, content=%s, attachments=%d\n", + msg.Channel, msg.ChatID, msg.Content, len(msg.Attachments)) + + text := strings.TrimSpace(msg.Content) + attachments := msg.Attachments + + // If only attachments, send them + if text == "" && len(attachments) > 0 { + fmt.Printf("feishu: sending only attachments\n") + for i, att := range attachments { + fmt.Printf("feishu: attachment[%d]: name=%s, kind=%s, localPath=%s, hasData=%v\n", + i, att.Name, att.Kind, att.LocalPath, att.Data != nil) + if err := c.sendAttachment(ctx, msg.ChatID, att, msg); err != nil { + fmt.Printf("feishu: failed to send attachment: %v\n", err) + return err + } + } + return nil + } + + // If only text, send text + if text != "" && len(attachments) == 0 { + fmt.Printf("feishu: sending only text\n") + return c.sendTextMessage(ctx, msg.ChatID, text, msg) + } + + // If both text and attachments, send text first then attachments + if text != "" { + fmt.Printf("feishu: sending text first\n") + if err := c.sendTextMessage(ctx, msg.ChatID, text, msg); err != nil { + return err + } + } + + for i, att := range attachments { + fmt.Printf("feishu: sending attachment[%d]: name=%s, kind=%s\n", i, att.Name, att.Kind) + if err := c.sendAttachment(ctx, msg.ChatID, att, msg); err != nil { + fmt.Printf("feishu: failed to send attachment: %v\n", err) + return err + } + } + + return nil +} + +func (c *Channel) sendTextMessage(ctx context.Context, chatID, text string, msg bus.OutboundMessage) error { + token, err := c.getTenantAccessToken(ctx) + if err != nil { + return err + } + + // Build message content - text message format + content := map[string]string{"text": text} + contentJSON, _ := json.Marshal(content) + + // Determine receiver type and ID + receiverType, receiveID := c.resolveReceiverType(chatID) + + // Build request body + body := map[string]interface{}{ + "receive_id": receiveID, + "msg_type": "text", + "content": string(contentJSON), + } + + // Add reply_id if present (use message_id for reply) + replyTo := c.resolveReplyTo(msg) + if replyTo != "" { + body["reply_id"] = replyTo + } + + reqBody, _ := json.Marshal(body) + url := fmt.Sprintf("%s/open-apis/im/v1/messages?receive_id_type=%s", feishuAPIBase, receiverType) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + fmt.Printf("feishu: sending message to %s (%s): %s\n", receiveID, receiverType, text) + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + fmt.Printf("feishu: send response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *struct { + MessageID string `json:"message_id"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return err + } + + if result.Code != 0 { + return fmt.Errorf("feishu API error: code=%d, msg=%s", result.Code, result.Msg) + } + + fmt.Printf("feishu: message sent successfully, message_id=%s\n", result.Data.MessageID) + return nil +} + +func (c *Channel) sendAttachment(ctx context.Context, chatID string, att bus.Attachment, msg bus.OutboundMessage) error { + token, err := c.getTenantAccessToken(ctx) + if err != nil { + return err + } + + var fileData []byte + var fileName string + + if att.Data != nil { + fileData = att.Data + fileName = att.Name + } else if att.LocalPath != "" { + data, err := os.ReadFile(att.LocalPath) + if err != nil { + return err + } + fileData = data + fileName = att.Name + } else { + return fmt.Errorf("attachment has no data or local path") + } + + fileID, err := c.uploadFile(ctx, token, fileData, fileName, att.MIMEType) + if err != nil { + return err + } + + msgType := "image" + contentKey := "image_key" + if strings.HasPrefix(att.MIMEType, "video/") { + msgType = "video" + contentKey = "video_key" + } else if !strings.HasPrefix(att.MIMEType, "image/") { + msgType = "file" + contentKey = "file_key" + } + + _, receiveID := c.resolveReceiverType(chatID) + + content := map[string]string{contentKey: fileID} + contentJSON, _ := json.Marshal(content) + + body := map[string]interface{}{ + "receive_id": receiveID, + "msg_type": msgType, + "content": string(contentJSON), + } + + replyTo := c.resolveReplyTo(msg) + if replyTo != "" { + body["reply_id"] = replyTo + } + + reqBody, _ := json.Marshal(body) + receiverType, _ := c.resolveReceiverType(chatID) + url := fmt.Sprintf("%s/open-apis/im/v1/messages?receive_id_type=%s", feishuAPIBase, receiverType) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + fmt.Printf("feishu: sending %s: key=%s\n", msgType, fileID) + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + fmt.Printf("feishu: send response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *struct { + MessageID string `json:"message_id"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return err + } + + if result.Code != 0 { + return fmt.Errorf("feishu API error: code=%d, msg=%s", result.Code, result.Msg) + } + + fmt.Printf("feishu: %s sent successfully, message_id=%s\n", msgType, result.Data.MessageID) + + return nil +} + +func (c *Channel) uploadFile(ctx context.Context, token string, data []byte, filename, mimeType string) (string, error) { + // Determine file type and use appropriate API + isImage := strings.HasPrefix(mimeType, "image/") + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // Create form-data for file/image + fieldName := "file" + url := fmt.Sprintf("%s/open-apis/im/v1/files", feishuAPIBase) + + if isImage { + // Use image API for images + fieldName = "image" + url = fmt.Sprintf("%s/open-apis/im/v1/images", feishuAPIBase) + } + + filePart, err := writer.CreateFormFile(fieldName, filename) + if err != nil { + return "", err + } + if _, err := filePart.Write(data); err != nil { + return "", err + } + + if err := writer.Close(); err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &body) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + fmt.Printf("feishu: uploading %s (name=%s, size=%d) to %s\n", fieldName, filename, len(data), url) + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + fmt.Printf("feishu: upload response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) + + if isImage { + // Parse image response + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + ImageKey string `json:"image_key"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", err + } + + if result.Code != 0 { + return "", fmt.Errorf("feishu upload error: code=%d, msg=%s", result.Code, result.Msg) + } + + if result.Data.ImageKey == "" { + return "", fmt.Errorf("feishu upload succeeded but no image_key returned") + } + fmt.Printf("feishu: image upload succeeded, image_key=%s\n", result.Data.ImageKey) + return result.Data.ImageKey, nil + } else { + // Parse file response + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + FileKey string `json:"file_key"` + FileToken string `json:"file_token"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", err + } + + if result.Code != 0 { + return "", fmt.Errorf("feishu upload error: code=%d, msg=%s", result.Code, result.Msg) + } + + key := result.Data.FileKey + if key == "" { + key = result.Data.FileToken + } + if key == "" { + return "", fmt.Errorf("feishu upload succeeded but no file_key returned") + } + fmt.Printf("feishu: file upload succeeded, file_key=%s\n", key) + return key, nil + } +} + +func (c *Channel) getTenantAccessToken(ctx context.Context) (string, error) { + url := fmt.Sprintf("%s/open-apis/auth/v3/tenant_access_token/internal", feishuAPIBase) + body := map[string]string{ + "app_id": c.appID, + "app_secret": c.appSecret, + } + reqBody, _ := json.Marshal(body) + + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + Expire int `json:"expire"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if result.Code != 0 { + return "", fmt.Errorf("feishu auth error: code=%d, msg=%s", result.Code, result.Msg) + } + + return result.TenantAccessToken, nil +} + +func (c *Channel) resolveReceiverType(chatID string) (receiverType, receiveID string) { + chatID = strings.TrimSpace(chatID) + + if strings.HasPrefix(chatID, "ou_") { + return "open_id", chatID + } + if strings.HasPrefix(chatID, "u_") { + return "user_id", chatID + } + if strings.HasPrefix(chatID, "on_") { + return "union_id", chatID + } + return "chat_id", chatID +} + +func (c *Channel) resolveReplyTo(msg bus.OutboundMessage) string { + if strings.TrimSpace(msg.Delivery.ReplyToID) != "" { + return msg.Delivery.ReplyToID + } + if strings.TrimSpace(msg.ReplyTo) != "" { + return msg.ReplyTo + } + if strings.TrimSpace(msg.Delivery.ThreadID) != "" { + return msg.Delivery.ThreadID + } + return "" +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func randInt(min, max int) int { + return min + int(time.Now().UnixNano()%int64(max-min)) +} From dfc8950d7ee6e902f5577a6cb2ae62358ed80b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BF=97=E5=8D=8E?= <> Date: Sat, 14 Mar 2026 21:24:37 +0800 Subject: [PATCH 4/4] modify README.md and do some clean... --- README.md | 53 +++++++ channels/feishu/feishu.go | 175 ++++++++++----------- channels/feishu/feishu_test.go | 277 +++++++++++++++++++++++++++++++++ cmd/clawlet/cmd_channels.go | 1 + go.mod | 4 +- go.sum | 43 +++-- 6 files changed, 455 insertions(+), 98 deletions(-) create mode 100644 channels/feishu/feishu_test.go diff --git a/README.md b/README.md index 5ee9830..59f82b6 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,59 @@ clawlet gateway +
+Feishu (Lark) + +Uses **WebSocket** (no public URL required). clawlet connects to Feishu's WebSocket gateway to receive events in real-time. + +1. Create a Feishu/Lark app + - Go to https://open.feishu.cn/ and create a new enterprise app + - Copy `App ID` (cli_xxx) and `App Secret` from credentials + +2. Configure event subscription + - Enable **Event Subscription** and subscribe to `im.message.receive_v1` + - No webhook URL needed (WebSocket mode) + +3. Add bot permissions + - Bot read/write message permissions + - Install the app to your workspace + +4. Get user OpenID (for allowFrom) + - In Feishu, user IDs are OpenIDs (format: `ou_xxxxx`) + - Empty `allowFrom` allows all users + +5. Configure clawlet + - Set `channels.feishu.enabled=true` + - Configure `appId` and `appSecret` + +Example config (merge into `~/.clawlet/config.json`): + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "appId": "cli_xxxxxxxxxxxxx", + "appSecret": "your-app-secret-here", + "allowFrom": ["ou_xxxxxxxxxxxxx"] + } + } +} +``` + +Then run: + +```bash +clawlet gateway +``` + +Notes: +- The connection is automatic once the app is properly configured +- Both private messages and group messages are supported +- For group messages, the bot needs to be @mentioned by default + +
+ ## CLI Reference | Command | Description | diff --git a/channels/feishu/feishu.go b/channels/feishu/feishu.go index f58e7c7..e6c1679 100644 --- a/channels/feishu/feishu.go +++ b/channels/feishu/feishu.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "log" "mime/multipart" "net/http" "net/url" @@ -179,7 +180,7 @@ func (c *Channel) Start(ctx context.Context) error { return fmt.Errorf("failed to connect to feishu websocket: %w", err) } - fmt.Printf("feishu channel started (app_id=%s)\n", c.appID[:min(12, len(c.appID))]) + log.Printf("feishu channel started (app_id=%s)\n", c.appID[:min(12, len(c.appID))]) return runCtx.Err() } @@ -221,7 +222,7 @@ func (c *Channel) connect(ctx context.Context) error { connID := u.Query().Get("device_id") serviceID := u.Query().Get("service_id") - fmt.Printf("feishu: got websocket endpoint (conn_id=%s, service_id=%s)\n", connID, serviceID) + log.Printf("feishu: got websocket endpoint (conn_id=%s, service_id=%s)\n", connID, serviceID) // Connect conn, _, err := websocket.DefaultDialer.Dial(connURL, nil) @@ -236,7 +237,7 @@ func (c *Channel) connect(ctx context.Context) error { c.serviceID = serviceID c.mu.Unlock() - fmt.Println("feishu: websocket connected") + log.Println("feishu: websocket connected") // Start ping loop go c.pingLoop(ctx) @@ -302,7 +303,7 @@ func (c *Channel) receiveMessageLoop(ctx context.Context) { for { select { case <-ctx.Done(): - fmt.Println("feishu: receiveMessageLoop: context done") + log.Println("feishu: receiveMessageLoop: context done") return default: } @@ -312,22 +313,22 @@ func (c *Channel) receiveMessageLoop(ctx context.Context) { c.mu.Unlock() if conn == nil { - fmt.Println("feishu: receiveMessageLoop: connection is nil") + log.Println("feishu: receiveMessageLoop: connection is nil") return } mt, msg, err := conn.ReadMessage() if err != nil { - fmt.Printf("feishu: receiveMessageLoop: read error: %v\n", err) + log.Printf("feishu: receiveMessageLoop: read error: %v\n", err) c.disconnect(ctx) go c.reconnect(ctx) return } - fmt.Printf("feishu: <<< received WS message: type=%d, len=%d\n", mt, len(msg)) + log.Printf("feishu: <<< received WS message: type=%d, len=%d\n", mt, len(msg)) if mt != websocket.BinaryMessage { - fmt.Printf("feishu: received non-binary message type: %d\n", mt) + log.Printf("feishu: received non-binary message type: %d\n", mt) continue } @@ -338,31 +339,31 @@ func (c *Channel) receiveMessageLoop(ctx context.Context) { func (c *Channel) handleMessage(ctx context.Context, msg []byte) { defer func() { if r := recover(); r != nil { - fmt.Printf("feishu: handleMessage panic: %v\n", r) + log.Printf("feishu: handleMessage panic: %v\n", r) } }() - fmt.Printf("feishu: handling message (%d bytes): % x\n", len(msg), msg[:min(32, len(msg))]) + log.Printf("feishu: handling message (%d bytes): % x\n", len(msg), msg[:min(32, len(msg))]) // Parse frame using official SDK var frame ws.Frame if err := frame.Unmarshal(msg); err != nil { - fmt.Printf("feishu: unmarshal frame failed: %v\n", err) + log.Printf("feishu: unmarshal frame failed: %v\n", err) return } - fmt.Printf("feishu: parsed frame: method=%d, service=%d, len(headers)=%d, len(payload)=%d\n", + log.Printf("feishu: parsed frame: method=%d, service=%d, len(headers)=%d, len(payload)=%d\n", frame.Method, frame.Service, len(frame.Headers), len(frame.Payload)) // Handle based on frame type if frame.Method == 0 { // CONTROL - fmt.Println("feishu: handling CONTROL frame") + log.Println("feishu: handling CONTROL frame") c.handleControlFrame(ctx, frame) } else if frame.Method == 1 { // DATA - fmt.Println("feishu: handling DATA frame") + log.Println("feishu: handling DATA frame") c.handleDataFrame(ctx, frame) } else { - fmt.Printf("feishu: unknown frame method: %d\n", frame.Method) + log.Printf("feishu: unknown frame method: %d\n", frame.Method) } } @@ -371,7 +372,7 @@ func (c *Channel) handleControlFrame(ctx context.Context, frame ws.Frame) { t := hs.GetString("type") if t == "pong" { - fmt.Println("feishu: received pong") + log.Println("feishu: received pong") if len(frame.Payload) > 0 { var conf struct { PingInterval int `json:"ping_interval"` @@ -393,32 +394,32 @@ func (c *Channel) handleDataFrame(ctx context.Context, frame ws.Frame) { traceID := hs.GetString("trace_id") type_ := hs.GetString("type") - fmt.Printf("feishu: received data frame: type=%s, msg_id=%s, trace_id=%s\n", type_, msgID, traceID) + log.Printf("feishu: received data frame: type=%s, msg_id=%s, trace_id=%s\n", type_, msgID, traceID) if type_ != "event" { - fmt.Printf("feishu: skipping non-event frame\n") + log.Printf("feishu: skipping non-event frame\n") return } // Parse event payload var payload feishuEventPayload if err := json.Unmarshal(frame.Payload, &payload); err != nil { - fmt.Printf("feishu: unmarshal event payload failed: %v\n", err) + log.Printf("feishu: unmarshal event payload failed: %v\n", err) return } - fmt.Printf("feishu: event type: %s\n", payload.Header.EventType) + log.Printf("feishu: event type: %s\n", payload.Header.EventType) // Handle message receive events if payload.Header.EventType == "im.message.receive_v1" || payload.Header.EventType == "im.message.receive_v2" { - fmt.Printf("feishu: processing %s event\n", payload.Header.EventType) + log.Printf("feishu: processing %s event\n", payload.Header.EventType) if payload.Event == nil { - fmt.Println("feishu: event is nil") + log.Println("feishu: event is nil") return } if payload.Event.Message == nil { - fmt.Println("feishu: event.Message is nil") + log.Println("feishu: event.Message is nil") return } msg := payload.Event.Message @@ -449,19 +450,19 @@ func (c *Channel) handleDataFrame(ctx context.Context, frame ws.Frame) { chatType = *msg.ChatType } - fmt.Printf("feishu: received message: chatID=%s, senderID=%s, msgType=%s, chatType=%s, content=%s\n", + log.Printf("feishu: received message: chatID=%s, senderID=%s, msgType=%s, chatType=%s, content=%s\n", chatID, senderIDStr, msgType, chatType, content) // Process the message c.processMessageV2(ctx, msg, frame.Payload, hs, senderIDStr) } else { - fmt.Printf("feishu: unhandled event type: %s\n", payload.Header.EventType) + log.Printf("feishu: unhandled event type: %s\n", payload.Header.EventType) } } func (c *Channel) processMessageV2(ctx context.Context, msg *feishuEventMessage, rawPayload []byte, hs ws.Headers, senderID string) { if msg == nil { - fmt.Println("feishu: message is nil") + log.Println("feishu: message is nil") return } @@ -495,13 +496,13 @@ func (c *Channel) processMessageV2(ctx context.Context, msg *feishuEventMessage, parentID = *msg.ParentID } - fmt.Printf("feishu: processing message: ID=%s, chatID=%s, senderID=%s, msgType=%s\n", messageID, chatID, senderID, msgType) + log.Printf("feishu: processing message: ID=%s, chatID=%s, senderID=%s, msgType=%s\n", messageID, chatID, senderID, msgType) // Dedup check c.processedMu.Lock() if c.processedIDs[messageID] { c.processedMu.Unlock() - fmt.Printf("feishu: duplicate message %s, skipping\n", messageID) + log.Printf("feishu: duplicate message %s, skipping\n", messageID) return } c.processedIDs[messageID] = true @@ -517,7 +518,7 @@ func (c *Channel) processMessageV2(ctx context.Context, msg *feishuEventMessage, // Ignore bot's own messages if c.botOpenID != "" && senderID == c.botOpenID { - fmt.Println("feishu: ignoring bot's own message") + log.Println("feishu: ignoring bot's own message") return } @@ -531,40 +532,40 @@ func (c *Channel) processMessageV2(ctx context.Context, msg *feishuEventMessage, case "file": c.processFileMessage(ctx, content, senderID, chatID, messageID, rootID, parentID, isDirect) default: - fmt.Printf("feishu: unsupported message type: %s\n", msgType) + log.Printf("feishu: unsupported message type: %s\n", msgType) } } func (c *Channel) processTextMessage(ctx context.Context, content, senderID, chatID, messageID, rootID, parentID string, isDirect bool) { var msgContent feishuMessageContent if err := json.Unmarshal([]byte(content), &msgContent); err != nil { - fmt.Printf("feishu: failed to parse content: %v\n", err) + log.Printf("feishu: failed to parse content: %v\n", err) return } text := strings.TrimSpace(msgContent.Text) - fmt.Printf("feishu: message text: %s\n", text) + log.Printf("feishu: message text: %s\n", text) if text == "" { - fmt.Println("feishu: empty message text") + log.Println("feishu: empty message text") return } // Check allowlist if !c.allow.Allowed(senderID) { - fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + log.Printf("feishu: sender %s not in allowlist\n", senderID) return } // Strip bot @mention text = c.stripBotMention(text) - fmt.Printf("feishu: after strip mention: %s\n", text) + log.Printf("feishu: after strip mention: %s\n", text) if text == "" { - fmt.Println("feishu: empty text after stripping mentions") + log.Println("feishu: empty text after stripping mentions") return } // Publish to bus - fmt.Println("feishu: publishing to bus") + log.Println("feishu: publishing to bus") _ = c.bus.PublishInbound(ctx, bus.InboundMessage{ Channel: "feishu", SenderID: senderID, @@ -589,20 +590,20 @@ func (c *Channel) processImageMessage(ctx context.Context, content, senderID, ch ImageKey string `json:"image_key"` } if err := json.Unmarshal([]byte(content), &imgContent); err != nil { - fmt.Printf("feishu: failed to parse image content: %v, raw: %s\n", err, content) + log.Printf("feishu: failed to parse image content: %v, raw: %s\n", err, content) return } if imgContent.ImageKey == "" { - fmt.Println("feishu: empty image_key") + log.Println("feishu: empty image_key") return } - fmt.Printf("feishu: received image: image_key=%s\n", imgContent.ImageKey) + log.Printf("feishu: received image: image_key=%s\n", imgContent.ImageKey) // Check allowlist if !c.allow.Allowed(senderID) { - fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + log.Printf("feishu: sender %s not in allowlist\n", senderID) return } @@ -610,7 +611,7 @@ func (c *Channel) processImageMessage(ctx context.Context, content, senderID, ch localPath := "" downloadedPath, err := c.downloadImage(ctx, imgContent.ImageKey, chatID) if err != nil { - fmt.Printf("feishu: failed to download image: %v (continuing without local file)\n", err) + log.Printf("feishu: failed to download image: %v (continuing without local file)\n", err) } else { localPath = downloadedPath } @@ -640,7 +641,7 @@ func (c *Channel) processImageMessage(ctx context.Context, content, senderID, ch // Send acknowledgment response to Feishu c.sendEventAck(ctx, messageID, "") - fmt.Println("feishu: image message published to bus") + log.Println("feishu: image message published to bus") } func (c *Channel) processFileMessage(ctx context.Context, content, senderID, chatID, messageID, rootID, parentID string, isDirect bool) { @@ -649,20 +650,20 @@ func (c *Channel) processFileMessage(ctx context.Context, content, senderID, cha FileName string `json:"file_name"` } if err := json.Unmarshal([]byte(content), &fileContent); err != nil { - fmt.Printf("feishu: failed to parse file content: %v\n", err) + log.Printf("feishu: failed to parse file content: %v\n", err) return } if fileContent.FileKey == "" { - fmt.Println("feishu: empty file_key") + log.Println("feishu: empty file_key") return } - fmt.Printf("feishu: received file: file_key=%s\n", fileContent.FileKey) + log.Printf("feishu: received file: file_key=%s\n", fileContent.FileKey) // Check allowlist if !c.allow.Allowed(senderID) { - fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + log.Printf("feishu: sender %s not in allowlist\n", senderID) return } @@ -670,7 +671,7 @@ func (c *Channel) processFileMessage(ctx context.Context, content, senderID, cha localPath := "" downloadedPath, err := c.downloadFile(ctx, fileContent.FileKey, chatID, fileContent.FileName) if err != nil { - fmt.Printf("feishu: failed to download file: %v (continuing without local file)\n", err) + log.Printf("feishu: failed to download file: %v (continuing without local file)\n", err) } else { localPath = downloadedPath } @@ -705,30 +706,30 @@ func (c *Channel) processFileMessage(ctx context.Context, content, senderID, cha // Send acknowledgment response to Feishu c.sendEventAck(ctx, messageID, "") - fmt.Println("feishu: file message published to bus") + log.Println("feishu: file message published to bus") } func (c *Channel) publishMessageToBus(ctx context.Context, senderID, chatID, text, messageID, rootID, parentID string, isDirect bool) { // Check allowlist if !c.allow.Allowed(senderID) { - fmt.Printf("feishu: sender %s not in allowlist\n", senderID) + log.Printf("feishu: sender %s not in allowlist\n", senderID) return } // Strip bot @mention text = c.stripBotMention(text) - fmt.Printf("feishu: after strip mention: %s\n", text) + log.Printf("feishu: after strip mention: %s\n", text) if text == "" { - fmt.Println("feishu: empty text after stripping mentions") + log.Println("feishu: empty text after stripping mentions") return } // Determine if direct message isDirect = isDirect || strings.HasPrefix(chatID, "oc_") - fmt.Printf("feishu: isDirect=%v\n", isDirect) + log.Printf("feishu: isDirect=%v\n", isDirect) // Publish to bus - fmt.Println("feishu: publishing to bus") + log.Println("feishu: publishing to bus") _ = c.bus.PublishInbound(ctx, bus.InboundMessage{ Channel: "feishu", SenderID: senderID, @@ -756,7 +757,7 @@ func (c *Channel) sendEventAck(ctx context.Context, msgID, traceID string) { } sid := int32(0) - fmt.Sscanf(serviceID, "%d", &sid) + _, _ = fmt.Sscanf(serviceID, "%d", &sid) // Build response frame response := map[string]interface{}{ @@ -779,9 +780,9 @@ func (c *Channel) sendEventAck(ctx context.Context, msgID, traceID string) { data, _ := frame.Marshal() if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil { - fmt.Printf("feishu: failed to send event ack: %v\n", err) + log.Printf("feishu: failed to send event ack: %v\n", err) } else { - fmt.Println("feishu: sent event acknowledgment") + log.Println("feishu: sent event acknowledgment") } } @@ -815,7 +816,7 @@ func (c *Channel) stripBotMention(text string) string { func (c *Channel) pingLoop(ctx context.Context) { defer func() { if r := recover(); r != nil { - fmt.Printf("feishu: ping loop panic: %v\n", r) + log.Printf("feishu: ping loop panic: %v\n", r) } }() @@ -840,15 +841,15 @@ func (c *Channel) sendPing(ctx context.Context) { } i := int32(0) - fmt.Sscanf(serviceID, "%d", &i) + _, _ = fmt.Sscanf(serviceID, "%d", &i) frame := ws.NewPingFrame(i) bs, _ := frame.Marshal() if err := conn.WriteMessage(websocket.BinaryMessage, bs); err != nil { - fmt.Printf("feishu: ping failed: %v\n", err) + log.Printf("feishu: ping failed: %v\n", err) } else { - fmt.Println("feishu: sent ping") + log.Println("feishu: sent ping") } } @@ -857,7 +858,7 @@ func (c *Channel) reconnect(ctx context.Context) { return } - fmt.Println("feishu: attempting to reconnect...") + log.Println("feishu: attempting to reconnect...") // Random nonce for first reconnect if c.reconnectCount < 0 || c.reconnectCount > 0 { @@ -867,7 +868,7 @@ func (c *Channel) reconnect(ctx context.Context) { count := 0 for c.reconnectCount < 0 || count < c.reconnectCount { if err := c.connect(ctx); err != nil { - fmt.Printf("feishu: reconnect failed: %v\n", err) + log.Printf("feishu: reconnect failed: %v\n", err) count++ time.Sleep(c.reconnectInterval) continue @@ -875,7 +876,7 @@ func (c *Channel) reconnect(ctx context.Context) { return } - fmt.Printf("feishu: reconnect failed after %d attempts\n", count) + log.Printf("feishu: reconnect failed after %d attempts\n", count) } func (c *Channel) disconnect(ctx context.Context) { @@ -887,7 +888,7 @@ func (c *Channel) disconnect(ctx context.Context) { } c.conn.Close() - fmt.Printf("feishu: disconnected from %s\n", c.connURL) + log.Printf("feishu: disconnected from %s\n", c.connURL) c.conn = nil c.connURL = nil @@ -965,7 +966,7 @@ func (c *Channel) downloadImage(ctx context.Context, imageKey, chatID string) (s return "", err } - fmt.Printf("feishu: image saved to %s\n", localPath) + log.Printf("feishu: image saved to %s\n", localPath) return localPath, nil } @@ -1007,7 +1008,7 @@ func (c *Channel) downloadFile(ctx context.Context, fileKey, chatID, fileName st return "", err } - fmt.Printf("feishu: file saved to %s\n", localPath) + log.Printf("feishu: file saved to %s\n", localPath) return localPath, nil } @@ -1040,7 +1041,7 @@ func (c *Channel) saveToWorkspace(filename string, data []byte) (string, error) func (c *Channel) fetchBotOpenID(ctx context.Context) { token, err := c.getTenantAccessToken(ctx) if err != nil { - fmt.Printf("feishu: failed to get token for bot info: %v\n", err) + log.Printf("feishu: failed to get token for bot info: %v\n", err) return } @@ -1066,12 +1067,12 @@ func (c *Channel) fetchBotOpenID(ctx context.Context) { if result.Code == 0 && result.Bot.OpenID != "" { c.botOpenID = result.Bot.OpenID - fmt.Printf("feishu: bot open_id=%s\n", c.botOpenID[:min(12, len(c.botOpenID))]) + log.Printf("feishu: bot open_id=%s\n", c.botOpenID[:min(12, len(c.botOpenID))]) } } func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { - fmt.Printf("feishu: Send called: channel=%s, chatID=%s, content=%s, attachments=%d\n", + log.Printf("feishu: Send called: channel=%s, chatID=%s, content=%s, attachments=%d\n", msg.Channel, msg.ChatID, msg.Content, len(msg.Attachments)) text := strings.TrimSpace(msg.Content) @@ -1079,12 +1080,12 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { // If only attachments, send them if text == "" && len(attachments) > 0 { - fmt.Printf("feishu: sending only attachments\n") + log.Printf("feishu: sending only attachments\n") for i, att := range attachments { - fmt.Printf("feishu: attachment[%d]: name=%s, kind=%s, localPath=%s, hasData=%v\n", + log.Printf("feishu: attachment[%d]: name=%s, kind=%s, localPath=%s, hasData=%v\n", i, att.Name, att.Kind, att.LocalPath, att.Data != nil) if err := c.sendAttachment(ctx, msg.ChatID, att, msg); err != nil { - fmt.Printf("feishu: failed to send attachment: %v\n", err) + log.Printf("feishu: failed to send attachment: %v\n", err) return err } } @@ -1093,22 +1094,22 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { // If only text, send text if text != "" && len(attachments) == 0 { - fmt.Printf("feishu: sending only text\n") + log.Printf("feishu: sending only text\n") return c.sendTextMessage(ctx, msg.ChatID, text, msg) } // If both text and attachments, send text first then attachments if text != "" { - fmt.Printf("feishu: sending text first\n") + log.Printf("feishu: sending text first\n") if err := c.sendTextMessage(ctx, msg.ChatID, text, msg); err != nil { return err } } for i, att := range attachments { - fmt.Printf("feishu: sending attachment[%d]: name=%s, kind=%s\n", i, att.Name, att.Kind) + log.Printf("feishu: sending attachment[%d]: name=%s, kind=%s\n", i, att.Name, att.Kind) if err := c.sendAttachment(ctx, msg.ChatID, att, msg); err != nil { - fmt.Printf("feishu: failed to send attachment: %v\n", err) + log.Printf("feishu: failed to send attachment: %v\n", err) return err } } @@ -1148,7 +1149,7 @@ func (c *Channel) sendTextMessage(ctx context.Context, chatID, text string, msg req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") - fmt.Printf("feishu: sending message to %s (%s): %s\n", receiveID, receiverType, text) + log.Printf("feishu: sending message to %s (%s): %s\n", receiveID, receiverType, text) resp, err := c.client.Do(req) if err != nil { @@ -1157,7 +1158,7 @@ func (c *Channel) sendTextMessage(ctx context.Context, chatID, text string, msg defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) - fmt.Printf("feishu: send response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) + log.Printf("feishu: send response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) var result struct { Code int `json:"code"` @@ -1174,7 +1175,7 @@ func (c *Channel) sendTextMessage(ctx context.Context, chatID, text string, msg return fmt.Errorf("feishu API error: code=%d, msg=%s", result.Code, result.Msg) } - fmt.Printf("feishu: message sent successfully, message_id=%s\n", result.Data.MessageID) + log.Printf("feishu: message sent successfully, message_id=%s\n", result.Data.MessageID) return nil } @@ -1239,7 +1240,7 @@ func (c *Channel) sendAttachment(ctx context.Context, chatID string, att bus.Att req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") - fmt.Printf("feishu: sending %s: key=%s\n", msgType, fileID) + log.Printf("feishu: sending %s: key=%s\n", msgType, fileID) resp, err := c.client.Do(req) if err != nil { @@ -1248,7 +1249,7 @@ func (c *Channel) sendAttachment(ctx context.Context, chatID string, att bus.Att defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) - fmt.Printf("feishu: send response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) + log.Printf("feishu: send response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) var result struct { Code int `json:"code"` @@ -1265,7 +1266,7 @@ func (c *Channel) sendAttachment(ctx context.Context, chatID string, att bus.Att return fmt.Errorf("feishu API error: code=%d, msg=%s", result.Code, result.Msg) } - fmt.Printf("feishu: %s sent successfully, message_id=%s\n", msgType, result.Data.MessageID) + log.Printf("feishu: %s sent successfully, message_id=%s\n", msgType, result.Data.MessageID) return nil } @@ -1307,7 +1308,7 @@ func (c *Channel) uploadFile(ctx context.Context, token string, data []byte, fil req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", writer.FormDataContentType()) - fmt.Printf("feishu: uploading %s (name=%s, size=%d) to %s\n", fieldName, filename, len(data), url) + log.Printf("feishu: uploading %s (name=%s, size=%d) to %s\n", fieldName, filename, len(data), url) resp, err := c.client.Do(req) if err != nil { @@ -1316,7 +1317,7 @@ func (c *Channel) uploadFile(ctx context.Context, token string, data []byte, fil defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) - fmt.Printf("feishu: upload response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) + log.Printf("feishu: upload response: status=%d, body=%s\n", resp.StatusCode, string(respBody)) if isImage { // Parse image response @@ -1338,7 +1339,7 @@ func (c *Channel) uploadFile(ctx context.Context, token string, data []byte, fil if result.Data.ImageKey == "" { return "", fmt.Errorf("feishu upload succeeded but no image_key returned") } - fmt.Printf("feishu: image upload succeeded, image_key=%s\n", result.Data.ImageKey) + log.Printf("feishu: image upload succeeded, image_key=%s\n", result.Data.ImageKey) return result.Data.ImageKey, nil } else { // Parse file response @@ -1365,7 +1366,7 @@ func (c *Channel) uploadFile(ctx context.Context, token string, data []byte, fil if key == "" { return "", fmt.Errorf("feishu upload succeeded but no file_key returned") } - fmt.Printf("feishu: file upload succeeded, file_key=%s\n", key) + log.Printf("feishu: file upload succeeded, file_key=%s\n", key) return key, nil } } diff --git a/channels/feishu/feishu_test.go b/channels/feishu/feishu_test.go new file mode 100644 index 0000000..f9c4e1e --- /dev/null +++ b/channels/feishu/feishu_test.go @@ -0,0 +1,277 @@ +package feishu + +import ( + "context" + "testing" + + "github.com/mosaxiv/clawlet/bus" + "github.com/mosaxiv/clawlet/config" +) + +func TestFeishuChannelName(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "test_secret", + } + ch := New(cfg, b) + + if ch.Name() != "feishu" { + t.Errorf("expected name 'feishu', got %s", ch.Name()) + } +} + +func TestFeishuChannelNotRunningInitially(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "test_secret", + } + ch := New(cfg, b) + + if ch.IsRunning() { + t.Error("expected channel to not be running initially") + } +} + +func TestFeishuChannelRequiresAppID(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "", + AppSecret: "test_secret", + } + ch := New(cfg, b) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := ch.Start(ctx) + if err == nil { + t.Error("expected error for empty AppID") + } +} + +func TestFeishuChannelRequiresAppSecret(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "", + } + ch := New(cfg, b) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := ch.Start(ctx) + if err == nil { + t.Error("expected error for empty AppSecret") + } +} + +func TestFeishuStripBotMention(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "test_secret", + } + ch := New(cfg, b) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "no mention", + input: "hello", + expected: "hello", + }, + { + name: "mention at start", + input: `bot hello`, + expected: "hello", + }, + { + name: "mention with colon", + input: `bot: hello`, + expected: "hello", + }, + { + name: "mention with comma", + input: `bot, hello`, + expected: "hello", + }, + { + name: "chinese punctuation", + input: `bot:你好`, + expected: "你好", + }, + { + name: "multiple mentions", + input: `botuser hello`, + expected: "hello", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ch.stripBotMention(tt.input) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestFeishuResolveReceiverType(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "test_secret", + } + ch := New(cfg, b) + + tests := []struct { + name string + chatID string + expectedType string + expectedID string + }{ + { + name: "open_id", + chatID: "ou_xxxxx", + expectedType: "open_id", + expectedID: "ou_xxxxx", + }, + { + name: "user_id", + chatID: "u_xxxxx", + expectedType: "user_id", + expectedID: "u_xxxxx", + }, + { + name: "union_id", + chatID: "on_xxxxx", + expectedType: "union_id", + expectedID: "on_xxxxx", + }, + { + name: "chat_id p2p", + chatID: "oc_xxxxx", + expectedType: "chat_id", + expectedID: "oc_xxxxx", + }, + { + name: "chat_id group", + chatID: "ch_xxxxx", + expectedType: "chat_id", + expectedID: "ch_xxxxx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + receiverType, receiveID := ch.resolveReceiverType(tt.chatID) + if receiverType != tt.expectedType { + t.Errorf("expected receiver type %s, got %s", tt.expectedType, receiverType) + } + if receiveID != tt.expectedID { + t.Errorf("expected receiver ID %s, got %s", tt.expectedID, receiveID) + } + }) + } +} + +func TestFeishuResolveReplyTo(t *testing.T) { + b := bus.New(8) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "test_secret", + } + ch := New(cfg, b) + + tests := []struct { + name string + msg bus.OutboundMessage + expectedReply string + }{ + { + name: "from Delivery.ReplyToID", + msg: bus.OutboundMessage{ + Delivery: bus.Delivery{ + ReplyToID: "msg123", + }, + }, + expectedReply: "msg123", + }, + { + name: "from ReplyTo field", + msg: bus.OutboundMessage{ + ReplyTo: "msg456", + }, + expectedReply: "msg456", + }, + { + name: "from Delivery.ThreadID", + msg: bus.OutboundMessage{ + Delivery: bus.Delivery{ + ThreadID: "thread789", + }, + }, + expectedReply: "thread789", + }, + { + name: "empty", + msg: bus.OutboundMessage{}, + expectedReply: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ch.resolveReplyTo(tt.msg) + if result != tt.expectedReply { + t.Errorf("expected %s, got %s", tt.expectedReply, result) + } + }) + } +} + +func TestFeishuAllowList(t *testing.T) { + b := bus.New(8) + + // Test with empty allowFrom (allow all) + cfg := config.FeishuConfig{ + Enabled: true, + AppID: "cli_test", + AppSecret: "test_secret", + AllowFrom: []string{}, + } + ch := New(cfg, b) + + ctx := context.Background() + + // Create a test message + msg := &feishuEventMessage{ + MessageID: strPtr("msg123"), + ChatID: strPtr("oc_test"), + SenderID: &feishuUserID{OpenId: strPtr("ou_sender")}, + MsgType: strPtr("text"), + Content: strPtr(`{"text":"hello"}`), + ChatType: strPtr("p2p"), + } + + // Should not panic with empty allowFrom + ch.processMessageV2(ctx, msg, []byte{}, nil, "ou_sender") +} + +func strPtr(s string) *string { + return &s +} diff --git a/cmd/clawlet/cmd_channels.go b/cmd/clawlet/cmd_channels.go index b45e6ad..5239e06 100644 --- a/cmd/clawlet/cmd_channels.go +++ b/cmd/clawlet/cmd_channels.go @@ -31,6 +31,7 @@ func cmdChannels() *cli.Command { fmt.Printf("slack.enabled=%v\n", cfg.Channels.Slack.Enabled) fmt.Printf("telegram.enabled=%v\n", cfg.Channels.Telegram.Enabled) fmt.Printf("whatsapp.enabled=%v\n", cfg.Channels.WhatsApp.Enabled) + fmt.Printf("feishu.enabled=%v\n", cfg.Channels.Feishu.Enabled) return nil }, }, diff --git a/go.mod b/go.mod index 5b6eab4..b1408c6 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.26.0 require ( github.com/bwmarrin/discordgo v0.29.0 github.com/go-telegram/bot v1.19.0 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/ncruces/go-sqlite3 v0.30.5 github.com/slack-go/slack v0.17.3 @@ -19,8 +21,8 @@ require ( github.com/beeper/argo-go v1.1.2 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 920763f..d025f95 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -21,18 +19,19 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/go-telegram/bot v1.18.0 h1:yQzv437DY42SYTPBY48RinAvwbmf1ox5QICskIYWCD8= -github.com/go-telegram/bot v1.18.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/go-telegram/bot v1.19.0 h1:tuvTQhgNietHFRN0HUDhuXsgfgkGSaO8WWwZQW3DMQg= github.com/go-telegram/bot v1.19.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= @@ -41,6 +40,10 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -48,9 +51,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= -github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE= @@ -77,24 +79,37 @@ github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k= github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4= -go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ= go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= -go.mau.fi/whatsmeow v0.0.0-20260211193157-7b33f6289f98 h1:4ePal8sykeD3vUcUWvECtfqoGyNr5UHYn8pPwrBittY= -go.mau.fi/whatsmeow v0.0.0-20260211193157-7b33f6289f98/go.mod h1:jDLOQLLiYXcm4vMB6vtPcBLU387sRY+P3vOElxX8srA= go.mau.fi/whatsmeow v0.0.0-20260218135554-9cbe80fb25a4 h1:+3FE6cq5NzELYVD7uxa0yDpbUB+poSQmJV8zENTjHZA= go.mau.fi/whatsmeow v0.0.0-20260218135554-9cbe80fb25a4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -104,10 +119,18 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=