Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions internal/slack/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"sort"
"strconv"
"strings"

"github.com/nullify/slack-cli/internal/api"
"github.com/nullify/slack-cli/internal/types"
Expand Down Expand Up @@ -375,6 +376,24 @@ func parseRawMessage(channelID string, raw map[string]interface{}, maxBodyChars
botID := api.GetStringFromMap(raw, "bot_id")

content := text

// Many bot integrations (Grafana alerts, legacy webhook posters, etc.) never
// populate the plain "text" field and instead put the entire message body in
// legacy "attachments" or top-level Block Kit "blocks". Without this fallback
// those messages come back with empty content even though they carry a full
// alert/body — fall back to extracting human-readable text from whichever
// structure actually holds it.
if content == "" {
if attachments := api.GetSliceFromMap(raw, "attachments"); len(attachments) > 0 {
content = extractAttachmentsText(attachments)
}
}
if content == "" {
if blocks := api.GetSliceFromMap(raw, "blocks"); len(blocks) > 0 {
content = extractBlockText(blocks)
}
}

if maxBodyChars >= 0 && len(content) > maxBodyChars {
content = content[:maxBodyChars] + "\n..."
}
Expand Down Expand Up @@ -440,6 +459,102 @@ func parseRawMessage(channelID string, raw map[string]interface{}, maxBodyChars
return msg
}

// extractAttachmentsText extracts human-readable text from legacy Slack
// "attachments" (title/pretext/text/fallback/fields/footer, plus any nested
// Block Kit content some integrations attach instead of the legacy fields).
func extractAttachmentsText(attachments []interface{}) string {
var parts []string
for _, a := range attachments {
am, ok := a.(map[string]interface{})
if !ok {
continue
}

if t := api.GetStringFromMap(am, "title"); t != "" {
parts = append(parts, t)
}
if t := api.GetStringFromMap(am, "pretext"); t != "" {
parts = append(parts, t)
}
if t := api.GetStringFromMap(am, "text"); t != "" {
parts = append(parts, t)
} else if t := api.GetStringFromMap(am, "fallback"); t != "" {
parts = append(parts, t)
}

for _, f := range api.GetSliceFromMap(am, "fields") {
fm, ok := f.(map[string]interface{})
if !ok {
continue
}
title := api.GetStringFromMap(fm, "title")
value := api.GetStringFromMap(fm, "value")
switch {
case title != "" && value != "":
parts = append(parts, fmt.Sprintf("%s: %s", title, value))
case value != "":
parts = append(parts, value)
}
}

// Some integrations attach full Block Kit content instead of (or as
// well as) the legacy fields above.
if blocks := api.GetSliceFromMap(am, "blocks"); len(blocks) > 0 {
if t := extractBlockText(blocks); t != "" {
parts = append(parts, t)
}
}

if t := api.GetStringFromMap(am, "footer"); t != "" {
parts = append(parts, t)
}
}
return strings.Join(parts, "\n")
}

// extractBlockText recursively extracts human-readable text from a slice of
// Block Kit blocks (top-level "blocks", or "blocks" nested inside an
// attachment).
func extractBlockText(blocks []interface{}) string {
var parts []string
for _, b := range blocks {
bm, ok := b.(map[string]interface{})
if !ok {
continue
}

if textObj := api.GetMapFromMap(bm, "text"); textObj != nil {
if t := api.GetStringFromMap(textObj, "text"); t != "" {
parts = append(parts, t)
}
}

for _, f := range api.GetSliceFromMap(bm, "fields") {
if fm, ok := f.(map[string]interface{}); ok {
if t := api.GetStringFromMap(fm, "text"); t != "" {
parts = append(parts, t)
}
}
}

if elements := api.GetSliceFromMap(bm, "elements"); len(elements) > 0 {
// Elements are either nested blocks/rich-text sections (recurse)
// or leaf nodes carrying their own "text" string.
if t := extractBlockText(elements); t != "" {
parts = append(parts, t)
}
for _, el := range elements {
if em, ok := el.(map[string]interface{}); ok {
if t := api.GetStringFromMap(em, "text"); t != "" {
parts = append(parts, t)
}
}
}
}
}
return strings.Join(parts, "\n")
}

func normalizeReactionName(input string) string {
// Strip surrounding colons: :thumbsup: -> thumbsup
name := input
Expand Down
111 changes: 111 additions & 0 deletions internal/slack/messages_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package slack

import (
"strings"
"testing"
)

func TestParseRawMessage_PlainTextUnaffected(t *testing.T) {
raw := map[string]interface{}{
"ts": "1788613505.366269",
"text": "hello world",
}
msg := parseRawMessage("C123", raw, 8000, false)
if msg.Content != "hello world" {
t.Fatalf("got content %q, want %q", msg.Content, "hello world")
}
}

func TestParseRawMessage_LegacyAttachmentsFallback(t *testing.T) {
// Mirrors Grafana-style alert posts: empty "text", body lives entirely in
// legacy "attachments" (title/text/fields/footer).
raw := map[string]interface{}{
"ts": "1788098788.262629",
"text": "",
"attachments": []interface{}{
map[string]interface{}{
"title": "Step Function Failure Rate High",
"text": "latenthealth-tactical-holomap-sbom-sandbox is failing",
"fallback": "should not be used, text took priority",
"fields": []interface{}{
map[string]interface{}{"title": "Severity", "value": "critical"},
},
"footer": "Grafana",
},
},
}
msg := parseRawMessage("C123", raw, 8000, false)
if msg.Content == "" {
t.Fatal("expected content to be populated from attachments, got empty string")
}
for _, want := range []string{"Step Function Failure Rate High", "is failing", "Severity: critical", "Grafana"} {
if !strings.Contains(msg.Content, want) {
t.Errorf("expected content to contain %q, got: %s", want, msg.Content)
}
}
}

func TestParseRawMessage_AttachmentFallbackWhenNoText(t *testing.T) {
raw := map[string]interface{}{
"ts": "1",
"text": "",
"attachments": []interface{}{
map[string]interface{}{
"fallback": "only a fallback string here",
},
},
}
msg := parseRawMessage("C123", raw, 8000, false)
if !strings.Contains(msg.Content, "only a fallback string here") {
t.Errorf("expected fallback text to be used, got: %s", msg.Content)
}
}

func TestParseRawMessage_AttachmentNestedBlocks(t *testing.T) {
// Mirrors the "threat investigation" bot pattern: the real content lives
// in attachments[0].blocks[], not in any legacy attachment field.
raw := map[string]interface{}{
"ts": "1788613505.366269",
"text": "A threat investigation has completed",
"attachments": []interface{}{
map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": "CVE-2026-73912/73920 Oracle Helidon RCE, no exposure found at SmithRx",
},
},
},
},
},
}
msg := parseRawMessage("C123", raw, 8000, false)
// text is non-empty ("A threat investigation has completed"), so the
// attachment-fallback path deliberately does not run — that stub text is
// the current behavior for messages with any plain text at all.
if msg.Content != "A threat investigation has completed" {
t.Fatalf("got content %q", msg.Content)
}
}

func TestParseRawMessage_TopLevelBlocksFallback(t *testing.T) {
raw := map[string]interface{}{
"ts": "1",
"text": "",
"blocks": []interface{}{
map[string]interface{}{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": "block kit body",
},
},
},
}
msg := parseRawMessage("C123", raw, 8000, false)
if !strings.Contains(msg.Content, "block kit body") {
t.Errorf("expected block text to be used, got: %s", msg.Content)
}
}
Loading