From 5d6e6733d594772e7cd289280462623b09acff47 Mon Sep 17 00:00:00 2001 From: Doug Lauder Date: Thu, 5 Mar 2026 09:49:20 -0500 Subject: [PATCH] MM-45293 Index all attachment content fields in Elasticsearch (#35454) * MM-45293 Index all Slack attachment content fields in Elasticsearch Previously only the "text" field from Slack attachments was indexed. This adds title, pretext, fallback, and field titles/values, making posts from integrations (JIRA, GitHub, CI bots) fully searchable. --- .../enterprise/elasticsearch/common/common.go | 57 ++- .../elasticsearch/common/common_test.go | 362 +++++++++++++++--- .../message_attachment/message_attachment.tsx | 5 +- 3 files changed, 360 insertions(+), 64 deletions(-) diff --git a/server/enterprise/elasticsearch/common/common.go b/server/enterprise/elasticsearch/common/common.go index 3070f212739..563fefdb938 100644 --- a/server/enterprise/elasticsearch/common/common.go +++ b/server/enterprise/elasticsearch/common/common.go @@ -123,9 +123,34 @@ func ESPostFromPostForIndexing(post *model.PostForIndexing) *ESPost { attachmentsInterfaceArray, ok := attachments.([]any) if ok { for _, attachment := range attachmentsInterfaceArray { - if attachment != nil { - if attachmentText := attachment.(map[string]any)["text"]; attachmentText != nil { - searchAttachments = append(searchAttachments, attachmentText.(string)) + if attachment == nil { + continue + } + m, mOk := attachment.(map[string]any) + if !mOk { + continue + } + for _, key := range []string{"text", "title", "pretext", "fallback"} { + if s, _ := m[key].(string); s != "" { + searchAttachments = append(searchAttachments, s) + } + } + if fields, fOk := m["fields"].([]any); fOk { + for _, f := range fields { + if fm, fmOk := f.(map[string]any); fmOk { + if s, _ := fm["title"].(string); s != "" { + searchAttachments = append(searchAttachments, s) + } + if v := fm["value"]; v != nil { + if s, vOk := v.(string); vOk { + if s != "" { + searchAttachments = append(searchAttachments, s) + } + } else { + searchAttachments = append(searchAttachments, fmt.Sprint(v)) + } + } + } } } } @@ -134,8 +159,30 @@ func ESPostFromPostForIndexing(post *model.PostForIndexing) *ESPost { attachmentsArray, ok := attachments.([]*model.SlackAttachment) if ok { for _, attachment := range attachmentsArray { - if attachment != nil { - searchAttachments = append(searchAttachments, attachment.Text) + if attachment == nil { + continue + } + for _, s := range []string{attachment.Text, attachment.Title, attachment.Pretext, attachment.Fallback} { + if s != "" { + searchAttachments = append(searchAttachments, s) + } + } + for _, field := range attachment.Fields { + if field == nil { + continue + } + if field.Title != "" { + searchAttachments = append(searchAttachments, field.Title) + } + if field.Value != nil { + if s, ok := field.Value.(string); ok { + if s != "" { + searchAttachments = append(searchAttachments, s) + } + } else { + searchAttachments = append(searchAttachments, fmt.Sprint(field.Value)) + } + } } } } diff --git a/server/enterprise/elasticsearch/common/common_test.go b/server/enterprise/elasticsearch/common/common_test.go index 763785c575d..1555845d9c1 100644 --- a/server/enterprise/elasticsearch/common/common_test.go +++ b/server/enterprise/elasticsearch/common/common_test.go @@ -36,75 +36,321 @@ func TestElasticsearchBuildPostIndexName(t *testing.T) { } func TestESPostFromPostForIndexing(t *testing.T) { - // Create one with attachments in 'any' form. - - post1 := model.PostForIndexing{ - TeamId: model.NewId(), - ParentCreateAt: nil, - Post: model.Post{ - Id: model.NewId(), - ChannelId: model.NewId(), - UserId: model.NewId(), - CreateAt: model.GetMillis(), - Message: "message", - Type: "", - Hashtags: "", - Props: map[string]any{ - "attachments": []any{ - map[string]any{ - "text": "text 1", + t.Run("any form with text only", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + ParentCreateAt: nil, + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "message", + Type: "", + Hashtags: "", + Props: map[string]any{ + model.PostPropsAttachments: []any{ + map[string]any{ + "text": "text 1", + }, }, }, }, - }, - } + } - espost1 := ESPostFromPostForIndexing(&post1) + espost := ESPostFromPostForIndexing(&post) - assert.Equal(t, post1.Id, espost1.Id) - assert.Equal(t, post1.TeamId, espost1.TeamId) - assert.Equal(t, post1.ChannelId, espost1.ChannelId) - assert.Equal(t, post1.UserId, espost1.UserId) - assert.Equal(t, post1.CreateAt, espost1.CreateAt) - assert.Equal(t, post1.Message, espost1.Message) - assert.Equal(t, "default", espost1.Type) - assert.Empty(t, espost1.Hashtags) - assert.Equal(t, "text 1", espost1.Attachments) + assert.Equal(t, post.Id, espost.Id) + assert.Equal(t, post.TeamId, espost.TeamId) + assert.Equal(t, post.ChannelId, espost.ChannelId) + assert.Equal(t, post.UserId, espost.UserId) + assert.Equal(t, post.CreateAt, espost.CreateAt) + assert.Equal(t, post.Message, espost.Message) + assert.Equal(t, "default", espost.Type) + assert.Empty(t, espost.Hashtags) + assert.Equal(t, "text 1", espost.Attachments) + }) - // Create one with attachments in model.SlackAttachment form. - - post2 := model.PostForIndexing{ - TeamId: model.NewId(), - ParentCreateAt: nil, - Post: model.Post{ - Id: model.NewId(), - ChannelId: model.NewId(), - UserId: model.NewId(), - CreateAt: model.GetMillis(), - Message: "message", - Type: "slack_attachment", - Hashtags: "#buh #boh", - Props: map[string]any{ - "attachments": []*model.SlackAttachment{ - { - Text: "text 2", + t.Run("SlackAttachment form with text only", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + ParentCreateAt: nil, + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "message", + Type: "slack_attachment", + Hashtags: "#buh #boh", + Props: map[string]any{ + model.PostPropsAttachments: []*model.SlackAttachment{ + { + Text: "text 2", + }, }, }, }, - }, - } + } - espost2 := ESPostFromPostForIndexing(&post2) + espost := ESPostFromPostForIndexing(&post) - assert.Equal(t, post2.Id, espost2.Id) - assert.Equal(t, post2.TeamId, espost2.TeamId) - assert.Equal(t, post2.ChannelId, espost2.ChannelId) - assert.Equal(t, post2.UserId, espost2.UserId) - assert.Equal(t, post2.CreateAt, espost2.CreateAt) - assert.Equal(t, post2.Message, espost2.Message) - assert.Equal(t, "slack_attachment", espost2.Type) - assert.Len(t, espost2.Hashtags, 2) - assert.Equal(t, "text 2", espost2.Attachments) + assert.Equal(t, post.Id, espost.Id) + assert.Equal(t, post.TeamId, espost.TeamId) + assert.Equal(t, post.ChannelId, espost.ChannelId) + assert.Equal(t, post.UserId, espost.UserId) + assert.Equal(t, post.CreateAt, espost.CreateAt) + assert.Equal(t, post.Message, espost.Message) + assert.Equal(t, "slack_attachment", espost.Type) + assert.Len(t, espost.Hashtags, 2) + assert.Equal(t, "text 2", espost.Attachments) + }) + + t.Run("any form indexes title, pretext, fallback, and fields", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []any{ + map[string]any{ + "title": "Build Failed", + "pretext": "CI notification", + "fallback": "Build Failed on main", + "text": "Details here", + "fields": []any{ + map[string]any{ + "title": "Branch", + "value": "main", + }, + map[string]any{ + "title": "Status", + "value": "failed", + }, + }, + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "Details here") + assert.Contains(t, espost.Attachments, "Build Failed") + assert.Contains(t, espost.Attachments, "CI notification") + assert.Contains(t, espost.Attachments, "Build Failed on main") + assert.Contains(t, espost.Attachments, "Branch") + assert.Contains(t, espost.Attachments, "main") + assert.Contains(t, espost.Attachments, "Status") + assert.Contains(t, espost.Attachments, "failed") + }) + + t.Run("SlackAttachment form indexes title, pretext, fallback, and fields", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []*model.SlackAttachment{ + { + Title: "Build Failed", + Pretext: "CI notification", + Fallback: "Build Failed on main", + Text: "Details here", + Fields: []*model.SlackAttachmentField{ + {Title: "Branch", Value: "main"}, + {Title: "Status", Value: "failed"}, + }, + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "Details here") + assert.Contains(t, espost.Attachments, "Build Failed") + assert.Contains(t, espost.Attachments, "CI notification") + assert.Contains(t, espost.Attachments, "Build Failed on main") + assert.Contains(t, espost.Attachments, "Branch") + assert.Contains(t, espost.Attachments, "main") + assert.Contains(t, espost.Attachments, "Status") + assert.Contains(t, espost.Attachments, "failed") + }) + + t.Run("empty fields are excluded", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []*model.SlackAttachment{ + { + Title: "Only Title", + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Equal(t, "Only Title", espost.Attachments) + }) + + t.Run("nil fields and attachments are handled", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []*model.SlackAttachment{ + nil, + { + Text: "valid", + Fields: []*model.SlackAttachmentField{ + nil, + {Title: "field title", Value: "field value"}, + }, + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "valid") + assert.Contains(t, espost.Attachments, "field title") + assert.Contains(t, espost.Attachments, "field value") + }) + + t.Run("non-string field values are indexed", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []any{ + map[string]any{ + "text": "metrics", + "fields": []any{ + map[string]any{ + "title": "Count", + "value": 42, + }, + map[string]any{ + "title": "Rate", + "value": 99.5, + }, + }, + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "metrics") + assert.Contains(t, espost.Attachments, "Count") + assert.Contains(t, espost.Attachments, "42") + assert.Contains(t, espost.Attachments, "Rate") + assert.Contains(t, espost.Attachments, "99.5") + }) + + t.Run("non-string field values in SlackAttachment form are indexed", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []*model.SlackAttachment{ + { + Text: "metrics", + Fields: []*model.SlackAttachmentField{ + {Title: "Count", Value: 42}, + {Title: "Rate", Value: 99.5}, + }, + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "metrics") + assert.Contains(t, espost.Attachments, "Count") + assert.Contains(t, espost.Attachments, "42") + assert.Contains(t, espost.Attachments, "Rate") + assert.Contains(t, espost.Attachments, "99.5") + }) + + t.Run("multiple attachments are combined", func(t *testing.T) { + post := model.PostForIndexing{ + TeamId: model.NewId(), + Post: model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + UserId: model.NewId(), + CreateAt: model.GetMillis(), + Message: "", + Type: "slack_attachment", + Props: map[string]any{ + model.PostPropsAttachments: []any{ + map[string]any{ + "title": "First", + "text": "one", + }, + map[string]any{ + "title": "Second", + "text": "two", + }, + }, + }, + }, + } + + espost := ESPostFromPostForIndexing(&post) + + assert.Contains(t, espost.Attachments, "First") + assert.Contains(t, espost.Attachments, "one") + assert.Contains(t, espost.Attachments, "Second") + assert.Contains(t, espost.Attachments, "two") + }) } func TestGetMatchesForHit(t *testing.T) { diff --git a/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx b/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx index 20c640848c8..ebf57864759 100644 --- a/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx +++ b/webapp/channels/src/components/post_view/message_attachments/message_attachment/message_attachment.tsx @@ -272,7 +272,7 @@ export default class MessageAttachment extends React.PureComponent let rowPos = 0; let lastWasLong = false; let nrTables = 0; - const markdown = {markdown: false, mentionHighlight: false, atMentions: false}; + const markdown = {...this.props.options, markdown: false, mentionHighlight: false, atMentions: false}; fields.forEach((field, i) => { if (rowPos === 2 || !(field.short === true) || lastWasLong) { @@ -319,6 +319,7 @@ export default class MessageAttachment extends React.PureComponent > , @@ -391,6 +392,7 @@ export default class MessageAttachment extends React.PureComponent
@@ -461,6 +463,7 @@ export default class MessageAttachment extends React.PureComponent