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) { tests := []struct { name string chatID string wantChanID string wantThread string }{ { name: "channel only", chatID: "C123456", wantChanID: "C123456", wantThread: "", }, { name: "channel with thread", chatID: "C123456/1234567890.123456", wantChanID: "C123456", wantThread: "1234567890.123456", }, { name: "DM channel", chatID: "D987654", wantChanID: "D987654", wantThread: "", }, { name: "empty string", chatID: "", wantChanID: "", wantThread: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { chanID, threadTS := parseSlackChatID(tt.chatID) if chanID != tt.wantChanID { t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID) } if threadTS != tt.wantThread { t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread) } }) } } func TestResolveSlackOutboundTarget_PrefersContextTopicID(t *testing.T) { deliveryChatID, channelID, threadTS := resolveSlackOutboundTarget("C123456", &bus.InboundContext{ Channel: "slack", ChatID: "C123456", TopicID: "1234567890.123456", }) if deliveryChatID != "C123456/1234567890.123456" { t.Fatalf("deliveryChatID = %q, want %q", deliveryChatID, "C123456/1234567890.123456") } if channelID != "C123456" { t.Fatalf("channelID = %q, want %q", channelID, "C123456") } if threadTS != "1234567890.123456" { t.Fatalf("threadTS = %q, want %q", threadTS, "1234567890.123456") } } func TestStripBotMention(t *testing.T) { ch := &SlackChannel{botUserID: "U12345BOT"} tests := []struct { name string input string want string }{ { name: "mention at start", input: "<@U12345BOT> hello there", want: "hello there", }, { name: "mention in middle", input: "hey <@U12345BOT> can you help", want: "hey can you help", }, { name: "no mention", input: "hello world", want: "hello world", }, { name: "empty string", input: "", want: "", }, { name: "only mention", input: "<@U12345BOT>", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ch.stripBotMention(tt.input) if got != tt.want { t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestNewSlackChannel(t *testing.T) { msgBus := bus.NewMessageBus() bc := &config.Channel{Type: "slack", Enabled: true} t.Run("missing bot token", func(t *testing.T) { cfg := &config.SlackSettings{} cfg.AppToken = *config.NewSecureString("xapp-test") _, err := NewSlackChannel(bc, cfg, msgBus) if err == nil { t.Error("expected error for missing bot_token, got nil") } }) t.Run("missing app token", func(t *testing.T) { cfg := &config.SlackSettings{} cfg.BotToken = *config.NewSecureString("xoxb-test") _, err := NewSlackChannel(bc, cfg, msgBus) if err == nil { t.Error("expected error for missing app_token, got nil") } }) t.Run("valid config", func(t *testing.T) { cfg := &config.SlackSettings{} cfg.BotToken = *config.NewSecureString("xoxb-test") cfg.AppToken = *config.NewSecureString("xapp-test") bc := &config.Channel{Type: "slack", Enabled: true, AllowFrom: []string{"U123"}} ch, err := NewSlackChannel(bc, cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } if ch.Name() != "slack" { t.Errorf("Name() = %q, want %q", ch.Name(), "slack") } if ch.IsRunning() { t.Error("new channel should not be running") } }) } func TestSlackChannelIsAllowed(t *testing.T) { msgBus := bus.NewMessageBus() t.Run("empty allowlist allows all", func(t *testing.T) { bc := &config.Channel{Type: config.ChannelSlack, Enabled: true, AllowFrom: []string{}} cfg := &config.SlackSettings{} cfg.BotToken = *config.NewSecureString("xoxb-test") cfg.AppToken = *config.NewSecureString("xapp-test") ch, _ := NewSlackChannel(bc, cfg, msgBus) if !ch.IsAllowed("U_ANYONE") { t.Error("empty allowlist should allow all users") } }) t.Run("allowlist restricts users", func(t *testing.T) { bc := &config.Channel{Type: config.ChannelSlack, Enabled: true, AllowFrom: []string{"U_ALLOWED"}} cfg := &config.SlackSettings{} cfg.BotToken = *config.NewSecureString("xoxb-test") cfg.AppToken = *config.NewSecureString("xapp-test") ch, _ := NewSlackChannel(bc, cfg, msgBus) if !ch.IsAllowed("U_ALLOWED") { t.Error("allowed user should pass allowlist check") } if ch.IsAllowed("U_BLOCKED") { t.Error("non-allowed user should be blocked") } }) } 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 }