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.
This commit is contained in:
Doug Lauder
2026-03-05 09:49:20 -05:00
committed by GitHub
parent 7e7a1b582f
commit 5d6e6733d5
3 changed files with 360 additions and 64 deletions
@@ -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))
}
}
}
}
}
@@ -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) {
@@ -272,7 +272,7 @@ export default class MessageAttachment extends React.PureComponent<Props, State>
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<Props, State>
>
<Markdown
message={String(field.value)}
options={this.props.options}
postId={this.props.postId}
/>
</td>,
@@ -391,6 +392,7 @@ export default class MessageAttachment extends React.PureComponent<Props, State>
<div className='attachment__thumb-pretext'>
<Markdown
message={attachment.pretext}
options={options}
postId={this.props.postId}
/>
</div>
@@ -461,6 +463,7 @@ export default class MessageAttachment extends React.PureComponent<Props, State>
<Markdown
message={attachment.title}
options={{
...options,
atMentions: false,
mentionHighlight: false,
renderer: new LinkOnlyRenderer(),