From 1bf0d898deffda90d12356cbfb053985199e6ed8 Mon Sep 17 00:00:00 2001 From: Anton Bogdanovich <27antonb@gmail.com> Date: Mon, 11 May 2026 16:45:01 -0700 Subject: [PATCH] test(message): cover slack and feishu media fallbacks --- pkg/channels/feishu/feishu_64.go | 8 ++- pkg/channels/feishu/feishu_64_test.go | 39 ++++++++++++++ pkg/channels/slack/slack.go | 22 +++++--- pkg/channels/slack/slack_test.go | 78 +++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5fd28806c..2fef72273 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -52,6 +52,8 @@ type FeishuChannel struct { progress *channels.ToolFeedbackAnimator deleteMessageFn func(context.Context, string, string) error + sendMediaPartFn func(context.Context, string, bus.MediaPart, media.MediaStore) error + sendTextFn func(context.Context, string, string) (string, error) } type cachedMessage struct { @@ -78,6 +80,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } ch.deleteMessageFn = ch.deleteMessageAPI + ch.sendMediaPartFn = ch.sendMediaPart + ch.sendTextFn = ch.sendText ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil @@ -500,13 +504,13 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess caption := firstMediaCaption(msg.Parts) sentAny := false for _, part := range msg.Parts { - if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { + if err := c.sendMediaPartFn(ctx, msg.ChatID, part, store); err != nil { return nil, err } sentAny = true } if sentAny && caption != "" { - if _, err := c.sendText(ctx, msg.ChatID, caption); err != nil { + if _, err := c.sendTextFn(ctx, msg.ChatID, caption); err != nil { return nil, err } } diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index d256325ad..1dbacab89 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -9,7 +9,9 @@ import ( larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/media" ) func TestExtractContent(t *testing.T) { @@ -319,6 +321,43 @@ func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing. } } +func TestSendMedia_SendsCaptionFallbackAfterMedia(t *testing.T) { + ch := &FeishuChannel{ + BaseChannel: channels.NewBaseChannel("feishu", nil, nil, nil), + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.SetRunning(true) + ch.SetMediaStore(media.NewFileMediaStore()) + + var mediaOrder []string + var textCalls []string + ch.sendMediaPartFn = func(ctx context.Context, chatID string, part bus.MediaPart, store media.MediaStore) error { + mediaOrder = append(mediaOrder, part.Type) + return nil + } + ch.sendTextFn = func(ctx context.Context, chatID, text string) (string, error) { + textCalls = append(textCalls, chatID+"|"+text) + return "msg-1", nil + } + + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "oc_123", + Parts: []bus.MediaPart{ + {Type: "image", Caption: "shared caption"}, + {Type: "file"}, + }, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + if len(mediaOrder) != 2 { + t.Fatalf("media sends = %v, want 2 sends", mediaOrder) + } + if len(textCalls) != 1 || textCalls[0] != "oc_123|shared caption" { + t.Fatalf("textCalls = %v, want [oc_123|shared caption]", textCalls) + } +} + func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { ch := &FeishuChannel{ progress: channels.NewToolFeedbackAnimator(nil), diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 566422fdb..b021feda9 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -29,6 +29,8 @@ type SlackChannel struct { ctx context.Context cancel context.CancelFunc pendingAcks sync.Map + uploadFileFn func(context.Context, slack.UploadFileParameters) error + postTextFn func(context.Context, string, string, string) error } type slackMessageRef struct { @@ -63,6 +65,18 @@ func NewSlackChannel( config: cfg, api: api, socketClient: socketClient, + uploadFileFn: func(ctx context.Context, params slack.UploadFileParameters) error { + _, err := api.UploadFileContext(ctx, params) + return err + }, + postTextFn: func(ctx context.Context, channelID, threadTS, text string) error { + opts := []slack.MsgOption{slack.MsgOptionText(text, false)} + if threadTS != "" { + opts = append(opts, slack.MsgOptionTS(threadTS)) + } + _, _, err := api.PostMessageContext(ctx, channelID, opts...) + return err + }, }, nil } @@ -193,7 +207,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa title = filename } - _, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{ + err = c.uploadFileFn(ctx, slack.UploadFileParameters{ Channel: channelID, ThreadTimestamp: threadTS, File: localPath, @@ -211,11 +225,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa } if sentAny && caption != "" { - opts := []slack.MsgOption{slack.MsgOptionText(caption, false)} - if threadTS != "" { - opts = append(opts, slack.MsgOptionTS(threadTS)) - } - if _, _, err := c.api.PostMessageContext(ctx, channelID, opts...); err != nil { + if err := c.postTextFn(ctx, channelID, threadTS, caption); err != nil { return nil, fmt.Errorf("slack send media caption fallback: %w", channels.ErrTemporary) } } diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go index a72521d67..b85f3f028 100644 --- a/pkg/channels/slack/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -1,10 +1,17 @@ package slack import ( + "context" + "os" + "path/filepath" "testing" + slacksdk "github.com/slack-go/slack" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func TestParseSlackChatID(t *testing.T) { @@ -184,3 +191,74 @@ func TestSlackChannelIsAllowed(t *testing.T) { } }) } + +func TestSendMedia_SendsCaptionFallbackAfterUploads(t *testing.T) { + ch := &SlackChannel{ + BaseChannel: channels.NewBaseChannel("slack", nil, nil, nil), + } + ch.SetRunning(true) + + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + var uploaded []slackUploadRecord + var posted []string + ch.uploadFileFn = func(ctx context.Context, params slacksdk.UploadFileParameters) error { + uploaded = append(uploaded, slackUploadRecord{ + Channel: params.Channel, + Thread: params.ThreadTimestamp, + File: params.File, + Name: params.Filename, + Title: params.Title, + }) + return nil + } + ch.postTextFn = func(ctx context.Context, channelID, threadTS, text string) error { + posted = append(posted, channelID+"|"+threadTS+"|"+text) + return nil + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "C123456/1234567890.123456", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + Caption: "shared caption", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + if len(uploaded) != 1 { + t.Fatalf("uploads = %v, want 1 upload", uploaded) + } + if uploaded[0].Title != "shared caption" { + t.Fatalf("upload title = %q, want shared caption", uploaded[0].Title) + } + if len(posted) != 1 || posted[0] != "C123456|1234567890.123456|shared caption" { + t.Fatalf("posted = %v, want fallback text in same thread", posted) + } +} + +type slackUploadRecord struct { + Channel string + Thread string + File string + Name string + Title string +}