Merge pull request #190 from derodero24/feat/extract-block-kit-text

feat: extract text content from message blocks in conversations_history
This commit is contained in:
Dmitrii Korotovskii
2026-05-14 21:02:19 +02:00
committed by GitHub
3 changed files with 414 additions and 2 deletions
+10 -2
View File
@@ -1533,7 +1533,11 @@ func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack
continue
}
msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments)
msgText := msg.Text
if msgText == "" {
msgText = text.BlocksToText(msg.Blocks)
}
msgText += text.AttachmentsTo2CSV(msgText, msg.Attachments)
var reactionParts []string
for _, r := range msg.Reactions {
@@ -1609,7 +1613,11 @@ func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.
continue
}
msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments)
msgText := msg.Text
if msgText == "" {
msgText = text.BlocksToText(msg.Blocks)
}
msgText += text.AttachmentsTo2CSV(msgText, msg.Attachments)
hasMedia := hasImageBlocks(msg.Blocks)
+100
View File
@@ -39,6 +39,10 @@ func AttachmentToText(att slack.Attachment) string {
parts = append(parts, fmt.Sprintf("Footer: %s @ %s", att.Footer, ts))
}
if blocksText := BlocksToText(att.Blocks); blocksText != "" {
parts = append(parts, fmt.Sprintf("Blocks: %s", blocksText))
}
result := strings.Join(parts, "; ")
result = strings.ReplaceAll(result, "\n", " ")
@@ -51,6 +55,102 @@ func AttachmentToText(att slack.Attachment) string {
return result
}
// BlocksToText extracts text content from Slack Block Kit structures.
func BlocksToText(blocks slack.Blocks) string {
if len(blocks.BlockSet) == 0 {
return ""
}
var parts []string
for _, block := range blocks.BlockSet {
switch b := block.(type) {
case *slack.HeaderBlock:
if b.Text != nil && b.Text.Text != "" {
parts = append(parts, b.Text.Text)
}
case *slack.SectionBlock:
if b.Text != nil && b.Text.Text != "" {
parts = append(parts, b.Text.Text)
}
for _, field := range b.Fields {
if field != nil && field.Text != "" {
parts = append(parts, field.Text)
}
}
case *slack.RichTextBlock:
if t := richTextBlockToText(b); t != "" {
parts = append(parts, t)
}
case *slack.ContextBlock:
for _, elem := range b.ContextElements.Elements {
if txt, ok := elem.(*slack.TextBlockObject); ok && txt != nil && txt.Text != "" {
parts = append(parts, txt.Text)
}
}
}
}
return strings.Join(parts, " ")
}
func richTextBlockToText(rtb *slack.RichTextBlock) string {
var parts []string
for _, elem := range rtb.Elements {
if t := richTextElementToText(elem); t != "" {
parts = append(parts, t)
}
}
return strings.Join(parts, " ")
}
func richTextElementToText(elem slack.RichTextElement) string {
switch e := elem.(type) {
case *slack.RichTextSection:
return richTextSectionToText(e)
case *slack.RichTextList:
var parts []string
for _, listElem := range e.Elements {
if t := richTextElementToText(listElem); t != "" {
parts = append(parts, t)
}
}
return strings.Join(parts, " ")
case *slack.RichTextQuote:
return richTextSectionToText((*slack.RichTextSection)(e))
case *slack.RichTextPreformatted:
return richTextSectionToText(&e.RichTextSection)
}
return ""
}
func richTextSectionToText(section *slack.RichTextSection) string {
var parts []string
for _, elem := range section.Elements {
switch e := elem.(type) {
case *slack.RichTextSectionTextElement:
if e.Text != "" {
parts = append(parts, e.Text)
}
case *slack.RichTextSectionLinkElement:
if e.Text != "" {
parts = append(parts, e.Text)
} else if e.URL != "" {
parts = append(parts, e.URL)
}
case *slack.RichTextSectionBroadcastElement:
if e.Range != "" {
parts = append(parts, "@"+e.Range)
}
}
}
return strings.Join(parts, "")
}
func AttachmentsTo2CSV(msgText string, attachments []slack.Attachment) string {
if len(attachments) == 0 {
return ""
+304
View File
@@ -2,8 +2,312 @@ package text
import (
"testing"
"github.com/slack-go/slack"
)
func TestBlocksToText(t *testing.T) {
tests := []struct {
name string
blocks slack.Blocks
want string
}{
{
name: "empty blocks",
blocks: slack.Blocks{},
want: "",
},
{
name: "header block",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.HeaderBlock{
Type: slack.MBTHeader,
Text: &slack.TextBlockObject{
Type: "plain_text",
Text: "Important Header",
},
},
},
},
want: "Important Header",
},
{
name: "section block with text",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: "mrkdwn",
Text: "Hello from section",
},
},
},
},
want: "Hello from section",
},
{
name: "section block with fields",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: "mrkdwn",
Text: "Main text",
},
Fields: []*slack.TextBlockObject{
{Type: "mrkdwn", Text: "Field 1"},
{Type: "mrkdwn", Text: "Field 2"},
},
},
},
},
want: "Main text Field 1 Field 2",
},
{
name: "context block",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.ContextBlock{
Type: slack.MBTContext,
ContextElements: slack.ContextElements{
Elements: []slack.MixedElement{
&slack.TextBlockObject{
Type: "mrkdwn",
Text: "context info",
},
},
},
},
},
},
want: "context info",
},
{
name: "rich text block with text elements",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.RichTextBlock{
Type: slack.MBTRichText,
Elements: []slack.RichTextElement{
&slack.RichTextSection{
Type: slack.RTESection,
Elements: []slack.RichTextSectionElement{
&slack.RichTextSectionTextElement{
Type: slack.RTSEText,
Text: "Hello ",
},
&slack.RichTextSectionTextElement{
Type: slack.RTSEText,
Text: "World",
},
},
},
},
},
},
},
want: "Hello World",
},
{
name: "rich text block with link element",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.RichTextBlock{
Type: slack.MBTRichText,
Elements: []slack.RichTextElement{
&slack.RichTextSection{
Type: slack.RTESection,
Elements: []slack.RichTextSectionElement{
&slack.RichTextSectionTextElement{
Type: slack.RTSEText,
Text: "Click ",
},
&slack.RichTextSectionLinkElement{
Type: slack.RTSELink,
URL: "https://example.com",
Text: "here",
},
},
},
},
},
},
},
want: "Click here",
},
{
name: "rich text link without display text falls back to URL",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.RichTextBlock{
Type: slack.MBTRichText,
Elements: []slack.RichTextElement{
&slack.RichTextSection{
Type: slack.RTESection,
Elements: []slack.RichTextSectionElement{
&slack.RichTextSectionLinkElement{
Type: slack.RTSELink,
URL: "https://example.com",
},
},
},
},
},
},
},
want: "https://example.com",
},
{
name: "multiple blocks combined",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.HeaderBlock{
Type: slack.MBTHeader,
Text: &slack.TextBlockObject{
Type: "plain_text",
Text: "Alert",
},
},
&slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: "mrkdwn",
Text: "Server is down",
},
},
&slack.ContextBlock{
Type: slack.MBTContext,
ContextElements: slack.ContextElements{
Elements: []slack.MixedElement{
&slack.TextBlockObject{
Type: "plain_text",
Text: "sent by monitoring",
},
},
},
},
},
},
want: "Alert Server is down sent by monitoring",
},
{
name: "divider and image blocks are skipped",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: "mrkdwn",
Text: "visible text",
},
},
&slack.DividerBlock{
Type: slack.MBTDivider,
},
},
},
want: "visible text",
},
{
name: "rich text with broadcast element",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.RichTextBlock{
Type: slack.MBTRichText,
Elements: []slack.RichTextElement{
&slack.RichTextSection{
Type: slack.RTESection,
Elements: []slack.RichTextSectionElement{
&slack.RichTextSectionBroadcastElement{
Type: slack.RTSEBroadcast,
Range: "channel",
},
&slack.RichTextSectionTextElement{
Type: slack.RTSEText,
Text: " please review",
},
},
},
},
},
},
},
want: "@channel please review",
},
{
name: "section block with nil text",
blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.SectionBlock{
Type: slack.MBTSection,
Fields: []*slack.TextBlockObject{
{Type: "mrkdwn", Text: "Only fields"},
},
},
},
},
want: "Only fields",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BlocksToText(tt.blocks)
if got != tt.want {
t.Errorf("BlocksToText() = %q, want %q", got, tt.want)
}
})
}
}
func TestAttachmentToTextWithBlocks(t *testing.T) {
att := slack.Attachment{
Blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: "mrkdwn",
Text: "block content",
},
},
},
},
}
result := AttachmentToText(att)
if result != "Blocks: block content" {
t.Errorf("AttachmentToText() = %q, want %q", result, "Blocks: block content")
}
}
func TestAttachmentToTextWithBlocksAndText(t *testing.T) {
att := slack.Attachment{
Title: "Email Subject",
Text: "email body",
Blocks: slack.Blocks{
BlockSet: []slack.Block{
&slack.SectionBlock{
Type: slack.MBTSection,
Text: &slack.TextBlockObject{
Type: "mrkdwn",
Text: "extra block info",
},
},
},
},
}
result := AttachmentToText(att)
expected := "Title: Email Subject; Text: email body; Blocks: extra block info"
if result != expected {
t.Errorf("AttachmentToText() = %q, want %q", result, expected)
}
}
func TestIsUnfurlingEnabled(t *testing.T) {
tests := []struct {
name string