From 027466646ce6952c3482057715096c47edf305ce Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 10 Feb 2026 20:29:52 -0500 Subject: [PATCH] feat(messages): include reactions in JSON output The Message struct was missing a Reactions field, causing reaction data from the Slack API to be silently discarded during JSON unmarshalling. Added Reaction type and Reactions field to Message so that conversations.replies and conversations.history responses now pass through reaction data to JSON output. Closes #113 --- internal/client/client.go | 20 +++-- internal/client/client_test.go | 101 +++++++++++++++++++++++ internal/cmd/messages/messages_test.go | 110 +++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 6 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index e23961c..c9534dd 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -223,14 +223,22 @@ type User struct { } `json:"profile"` } +// Reaction represents an emoji reaction on a Slack message +type Reaction struct { + Name string `json:"name"` + Count int `json:"count"` + Users []string `json:"users"` +} + // Message represents a Slack message type Message struct { - Type string `json:"type"` - User string `json:"user"` - Text string `json:"text"` - TS string `json:"ts"` - ThreadTS string `json:"thread_ts,omitempty"` - ReplyCount int `json:"reply_count,omitempty"` + Type string `json:"type"` + User string `json:"user"` + Text string `json:"text"` + TS string `json:"ts"` + ThreadTS string `json:"thread_ts,omitempty"` + ReplyCount int `json:"reply_count,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` } // Team represents workspace info diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 76eb910..e416d4f 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -483,6 +483,107 @@ func TestClient_GetThreadReplies_Success(t *testing.T) { } } +func TestClient_GetThreadReplies_WithReactions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "ok": true, + "messages": []map[string]interface{}{ + { + "ts": "1234567890.123456", + "text": "Original", + "user": "U123", + "reactions": []map[string]interface{}{ + {"name": "thumbsup", "count": 2, "users": []string{"U123", "U456"}}, + {"name": "heart", "count": 1, "users": []string{"U789"}}, + }, + }, + { + "ts": "1234567890.123457", + "text": "Reply without reactions", + "user": "U456", + }, + }, + "response_metadata": map[string]string{"next_cursor": ""}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewWithConfig(server.URL, "test-token", nil) + messages, err := client.GetThreadReplies("C123", "1234567890.123456", 100) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(messages)) + } + + // First message should have reactions + if len(messages[0].Reactions) != 2 { + t.Fatalf("expected 2 reactions on first message, got %d", len(messages[0].Reactions)) + } + if messages[0].Reactions[0].Name != "thumbsup" { + t.Errorf("expected reaction name 'thumbsup', got %s", messages[0].Reactions[0].Name) + } + if messages[0].Reactions[0].Count != 2 { + t.Errorf("expected reaction count 2, got %d", messages[0].Reactions[0].Count) + } + if len(messages[0].Reactions[0].Users) != 2 { + t.Errorf("expected 2 users on thumbsup reaction, got %d", len(messages[0].Reactions[0].Users)) + } + if messages[0].Reactions[1].Name != "heart" { + t.Errorf("expected reaction name 'heart', got %s", messages[0].Reactions[1].Name) + } + + // Second message should have no reactions + if len(messages[1].Reactions) != 0 { + t.Errorf("expected 0 reactions on second message, got %d", len(messages[1].Reactions)) + } +} + +func TestClient_GetChannelHistory_WithReactions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "ok": true, + "messages": []map[string]interface{}{ + { + "ts": "1234567890.123456", + "text": "Hello", + "user": "U123", + "reactions": []map[string]interface{}{ + {"name": "wave", "count": 3, "users": []string{"U1", "U2", "U3"}}, + }, + }, + }, + "response_metadata": map[string]string{"next_cursor": ""}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewWithConfig(server.URL, "test-token", nil) + messages, err := client.GetChannelHistory("C123", 20, "", "") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(messages)) + } + if len(messages[0].Reactions) != 1 { + t.Fatalf("expected 1 reaction, got %d", len(messages[0].Reactions)) + } + if messages[0].Reactions[0].Name != "wave" { + t.Errorf("expected reaction name 'wave', got %s", messages[0].Reactions[0].Name) + } + if messages[0].Reactions[0].Count != 3 { + t.Errorf("expected reaction count 3, got %d", messages[0].Reactions[0].Count) + } +} + func TestClient_AddReaction_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.URL.Path, "reactions.add") { diff --git a/internal/cmd/messages/messages_test.go b/internal/cmd/messages/messages_test.go index d6c51b3..68e4f1d 100644 --- a/internal/cmd/messages/messages_test.go +++ b/internal/cmd/messages/messages_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/open-cli-collective/slack-chat-api/internal/client" + "github.com/open-cli-collective/slack-chat-api/internal/output" ) func TestFormatTimestamp(t *testing.T) { @@ -483,6 +484,115 @@ func TestRunThread_Success(t *testing.T) { require.NoError(t, err) } +func TestRunThread_JSONIncludesReactions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/conversations.replies": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "messages": []map[string]interface{}{ + { + "ts": "1234567890.123456", + "user": "U001", + "text": "Original", + "reactions": []map[string]interface{}{ + {"name": "thumbsup", "count": 2, "users": []string{"U001", "U002"}}, + }, + }, + { + "ts": "1234567890.123457", + "user": "U002", + "text": "Reply without reactions", + }, + }, + }) + case "/users.info": + mockUserInfoHandler(w, r) + } + })) + defer server.Close() + + c := client.NewWithConfig(server.URL, "test-token", nil) + opts := &threadOptions{limit: 100} + + // Capture JSON output + output.OutputFormat = output.FormatJSON + defer func() { output.OutputFormat = output.FormatText }() + + var buf strings.Builder + output.Writer = &buf + defer func() { output.Writer = os.Stdout }() + + err := runThread("C123", "1234567890.123456", opts, c) + require.NoError(t, err) + + // Parse the JSON output + var messages []client.Message + err = json.Unmarshal([]byte(buf.String()), &messages) + require.NoError(t, err) + + require.Len(t, messages, 2) + + // First message should have reactions + require.Len(t, messages[0].Reactions, 1) + assert.Equal(t, "thumbsup", messages[0].Reactions[0].Name) + assert.Equal(t, 2, messages[0].Reactions[0].Count) + assert.Equal(t, []string{"U001", "U002"}, messages[0].Reactions[0].Users) + + // Second message should have no reactions + assert.Empty(t, messages[1].Reactions) +} + +func TestRunHistory_JSONIncludesReactions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/conversations.history": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "messages": []map[string]interface{}{ + { + "ts": "1234567890.123456", + "user": "U001", + "text": "Hello", + "reactions": []map[string]interface{}{ + {"name": "wave", "count": 1, "users": []string{"U002"}}, + {"name": "heart", "count": 3, "users": []string{"U001", "U002", "U003"}}, + }, + }, + }, + }) + case "/users.info": + mockUserInfoHandler(w, r) + } + })) + defer server.Close() + + c := client.NewWithConfig(server.URL, "test-token", nil) + opts := &historyOptions{limit: 20} + + // Capture JSON output + output.OutputFormat = output.FormatJSON + defer func() { output.OutputFormat = output.FormatText }() + + var buf strings.Builder + output.Writer = &buf + defer func() { output.Writer = os.Stdout }() + + err := runHistory("C123", opts, c) + require.NoError(t, err) + + // Parse the JSON output + var messages []client.Message + err = json.Unmarshal([]byte(buf.String()), &messages) + require.NoError(t, err) + + require.Len(t, messages, 1) + require.Len(t, messages[0].Reactions, 2) + assert.Equal(t, "wave", messages[0].Reactions[0].Name) + assert.Equal(t, "heart", messages[0].Reactions[1].Name) + assert.Equal(t, 3, messages[0].Reactions[1].Count) +} + func TestRunReact_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/reactions.add", r.URL.Path)