feat(feishu): split messages with >5 tables to avoid API limit
- Add countMarkdownTables() to count markdown tables in content - Add splitContentByTableCount() to split content when table count exceeds 5 - Update FeishuChannel.Send() to automatically split and send messages in parts - Add comprehensive unit tests for table counting and splitting logic Fixes API error when sending markdown messages with more than 5 tables per Feishu message limit.
This commit is contained in:
parent
abafa3c2aa
commit
a29a8be02b
3 changed files with 630 additions and 18 deletions
|
|
@ -8,9 +8,36 @@ import (
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxTablesPerMessage is the maximum number of tables allowed per message
|
||||||
|
const maxTablesPerMessage = 5
|
||||||
|
|
||||||
// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.
|
// mentionPlaceholderRegex matches @_user_N placeholders inserted by Feishu for mentions.
|
||||||
var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`)
|
var mentionPlaceholderRegex = regexp.MustCompile(`@_user_\d+`)
|
||||||
|
|
||||||
|
// isTableSeparator checks if a line is a markdown table separator row (|---|---| etc.)
|
||||||
|
func isTableSeparator(line string) bool {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || !strings.HasPrefix(trimmed, "|") || !strings.HasSuffix(trimmed, "|") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Remove leading and trailing |
|
||||||
|
trimmed = strings.Trim(trimmed, "|")
|
||||||
|
|
||||||
|
// Check if all remaining characters are -, |, : or space
|
||||||
|
hasDash := false
|
||||||
|
for _, c := range trimmed {
|
||||||
|
if c != '-' && c != '|' && c != ' ' && c != ':' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c == '-' {
|
||||||
|
hasDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A separator must have at least one dash character
|
||||||
|
return hasDash
|
||||||
|
}
|
||||||
|
|
||||||
// stringValue safely dereferences a *string pointer.
|
// stringValue safely dereferences a *string pointer.
|
||||||
func stringValue(v *string) string {
|
func stringValue(v *string) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
|
|
@ -69,6 +96,52 @@ func extractFileKey(content string) string { return extractJSONStringField(conte
|
||||||
// extractFileName extracts the file_name from a Feishu file message content JSON.
|
// extractFileName extracts the file_name from a Feishu file message content JSON.
|
||||||
func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
|
func extractFileName(content string) string { return extractJSONStringField(content, "file_name") }
|
||||||
|
|
||||||
|
// splitContentByTableCount splits the content into multiple parts if it contains too many tables.
|
||||||
|
// Each part will have at most maxTablesPerMessage tables.
|
||||||
|
func splitContentByTableCount(content string) []string {
|
||||||
|
var parts []string
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
|
||||||
|
var currentPart strings.Builder
|
||||||
|
var currentTableCount int
|
||||||
|
var inTable bool
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Check if this line starts with | and is not a separator
|
||||||
|
if strings.HasPrefix(trimmed, "|") && !isTableSeparator(trimmed) {
|
||||||
|
// Check if next line is a separator to confirm it's a table header
|
||||||
|
isTableHeader := i+1 < len(lines) && isTableSeparator(strings.TrimSpace(lines[i+1]))
|
||||||
|
|
||||||
|
if isTableHeader {
|
||||||
|
// Starting a new table (inTable may already be true for consecutive tables)
|
||||||
|
inTable = true
|
||||||
|
// Check if we need to start a new part
|
||||||
|
if currentTableCount >= maxTablesPerMessage && currentPart.Len() > 0 {
|
||||||
|
parts = append(parts, strings.TrimSpace(currentPart.String()))
|
||||||
|
currentPart.Reset()
|
||||||
|
currentTableCount = 0
|
||||||
|
}
|
||||||
|
currentTableCount++
|
||||||
|
}
|
||||||
|
} else if inTable && !strings.HasPrefix(trimmed, "|") {
|
||||||
|
// Non-table line ends a table
|
||||||
|
inTable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPart.WriteString(line)
|
||||||
|
currentPart.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the last part
|
||||||
|
if currentPart.Len() > 0 {
|
||||||
|
parts = append(parts, strings.TrimSpace(currentPart.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
// stripMentionPlaceholders removes @_user_N placeholders from the text content.
|
// stripMentionPlaceholders removes @_user_N placeholders from the text content.
|
||||||
// These are inserted by Feishu when users @mention someone in a message.
|
// These are inserted by Feishu when users @mention someone in a message.
|
||||||
func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
|
func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
|
@ -290,3 +292,486 @@ func TestStripMentionPlaceholders(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitContentByTableCount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
wantParts []string // Expected content of each part
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no tables - single part",
|
||||||
|
content: "Just some text without tables",
|
||||||
|
wantParts: []string{
|
||||||
|
"Just some text without tables",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single table - single part",
|
||||||
|
content: `| Col1 | Col2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| Col1 | Col2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exactly 5 tables - single part",
|
||||||
|
content: generateTableContent(1, 5),
|
||||||
|
wantParts: []string{generateTableContent(1, 5)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "6 tables - split into 2 parts",
|
||||||
|
content: generateTableContent(1, 6),
|
||||||
|
wantParts: []string{generateTableContent(1, 5), generateTableContent(6, 6)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text before and between tables",
|
||||||
|
content: `Intro text
|
||||||
|
|
||||||
|
| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
Middle text
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
Outro text`,
|
||||||
|
wantParts: []string{
|
||||||
|
`Intro text
|
||||||
|
|
||||||
|
| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
Middle text
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |`,
|
||||||
|
`| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| Data | Data |
|
||||||
|
|
||||||
|
Outro text`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "10 tables - split into 2 parts",
|
||||||
|
content: generateTableContent(1, 10),
|
||||||
|
wantParts: []string{
|
||||||
|
generateTableContent(1, 5),
|
||||||
|
generateTableContent(6, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "11 tables - split into 3 parts",
|
||||||
|
content: generateTableContent(1, 11),
|
||||||
|
wantParts: []string{
|
||||||
|
generateTableContent(1, 5),
|
||||||
|
generateTableContent(6, 10),
|
||||||
|
generateTableContent(11, 11),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "6 tables - verify tables are not truncated at split boundary",
|
||||||
|
content: `| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |
|
||||||
|
|
||||||
|
| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |`,
|
||||||
|
`| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consecutive tables without blank line between them",
|
||||||
|
content: `| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |
|
||||||
|
| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |`,
|
||||||
|
`| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content ends with table (no trailing newline or blank line)",
|
||||||
|
content: `| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |
|
||||||
|
|
||||||
|
| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |`,
|
||||||
|
`| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "table followed by text without blank line",
|
||||||
|
content: `| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
Some text immediately after table
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |
|
||||||
|
|
||||||
|
| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
| D1 | D1 |
|
||||||
|
Some text immediately after table
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
| D5 | D5 |`,
|
||||||
|
`| T6C1 | T6C2 |
|
||||||
|
|------|------|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text before 6th table goes to part 1",
|
||||||
|
content: `| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D1 | D1 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D5 | D5 |
|
||||||
|
|
||||||
|
Intro text for table 6
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D1 | D1 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D2 | D2 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D3 | D3 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D4 | D4 |
|
||||||
|
|
||||||
|
| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D5 | D5 |
|
||||||
|
|
||||||
|
Intro text for table 6`,
|
||||||
|
`| T1 | T2 |
|
||||||
|
|----|----|
|
||||||
|
| D6 | D6 |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single row table (header only, no data rows)",
|
||||||
|
content: `| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T6C1 | T6C2 |
|
||||||
|
|------|------|`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| T1C1 | T1C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T2C1 | T2C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T3C1 | T3C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T4C1 | T4C2 |
|
||||||
|
|------|------|
|
||||||
|
|
||||||
|
| T5C1 | T5C2 |
|
||||||
|
|------|------|`,
|
||||||
|
`| T6C1 | T6C2 |
|
||||||
|
|------|------|`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "table with alignment colons in separator",
|
||||||
|
content: `| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L1 | C1 | R1 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L2 | C2 | R2 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L3 | C3 | R3 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L4 | C4 | R4 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L5 | C5 | R5 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L6 | C6 | R6 |`,
|
||||||
|
wantParts: []string{
|
||||||
|
`| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L1 | C1 | R1 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L2 | C2 | R2 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L3 | C3 | R3 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L4 | C4 | R4 |
|
||||||
|
|
||||||
|
| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L5 | C5 | R5 |`,
|
||||||
|
`| Left | Center | Right |
|
||||||
|
|:-----|:------:|------:|
|
||||||
|
| L6 | C6 | R6 |`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := splitContentByTableCount(tt.content)
|
||||||
|
|
||||||
|
// Verify number of parts
|
||||||
|
if len(got) != len(tt.wantParts) {
|
||||||
|
t.Errorf("splitContentByTableCount() returned %d parts, want %d", len(got), len(tt.wantParts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content matches exactly
|
||||||
|
for i, expected := range tt.wantParts {
|
||||||
|
if got[i] != expected {
|
||||||
|
t.Errorf("Part %d mismatch:\ngot:\n%q\nwant:\n%q", i+1, got[i], expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to generate content with tables from start to end (inclusive)
|
||||||
|
func generateTableContent(start, end int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
sb.WriteString(fmt.Sprintf("| T%dC1 | T%dC2 |\n", i, i))
|
||||||
|
sb.WriteString("|------|------|\n")
|
||||||
|
sb.WriteString("| Data | Data |")
|
||||||
|
if i < end {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
|
|
@ -113,6 +114,7 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send sends a message using Interactive Card format for markdown rendering.
|
// Send sends a message using Interactive Card format for markdown rendering.
|
||||||
|
// If the content contains more than 5 tables, it will be split into multiple messages.
|
||||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
|
|
@ -122,34 +124,86 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build interactive card with markdown content
|
// Split content into multiple parts if it contains too many tables
|
||||||
cardContent, err := buildMarkdownCard(msg.Content)
|
parts := splitContentByTableCount(msg.Content)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("feishu send: card build failed: %w", err)
|
// Send each part as a separate message
|
||||||
|
for i, part := range parts {
|
||||||
|
if strings.TrimSpace(part) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build interactive card with markdown content
|
||||||
|
cardContent, err := buildMarkdownCard(part)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu send: card build failed (part %d/%d): %w", i+1, len(parts), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendCard(ctx, msg.ChatID, cardContent); err != nil {
|
||||||
|
return fmt.Errorf("feishu send: failed to send part %d/%d: %w", i+1, len(parts), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("feishu", "Feishu message part sent", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"part": i + 1,
|
||||||
|
"total": len(parts),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return c.sendCard(ctx, msg.ChatID, cardContent)
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
// Uses Message.Patch to update an interactive card message.
|
// Uses Message.Patch to update an interactive card message.
|
||||||
|
// If the content contains more than 5 tables, it will be split:
|
||||||
|
// the first part edits the original message, and remaining parts are sent as new messages.
|
||||||
func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||||
cardContent, err := buildMarkdownCard(content)
|
// Split content into multiple parts if it contains too many tables
|
||||||
if err != nil {
|
parts := splitContentByTableCount(content)
|
||||||
return fmt.Errorf("feishu edit: card build failed: %w", err)
|
|
||||||
|
// Edit the original message with the first part
|
||||||
|
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
|
||||||
|
cardContent, err := buildMarkdownCard(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu edit: card build failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := larkim.NewPatchMessageReqBuilder().
|
||||||
|
MessageId(messageID).
|
||||||
|
Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := c.client.Im.V1.Message.Patch(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("feishu edit: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req := larkim.NewPatchMessageReqBuilder().
|
// Send remaining parts as new messages
|
||||||
MessageId(messageID).
|
for i := 1; i < len(parts); i++ {
|
||||||
Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()).
|
if strings.TrimSpace(parts[i]) == "" {
|
||||||
Build()
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.Message.Patch(ctx, req)
|
cardContent, err := buildMarkdownCard(parts[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("feishu edit: %w", err)
|
return fmt.Errorf("feishu edit: card build failed (part %d/%d): %w", i+1, len(parts), err)
|
||||||
}
|
}
|
||||||
if !resp.Success() {
|
|
||||||
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
|
if err := c.sendCard(ctx, chatID, cardContent); err != nil {
|
||||||
|
return fmt.Errorf("feishu edit: failed to send part %d/%d: %w", i+1, len(parts), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("feishu", "Feishu edit: additional message part sent", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"part": i + 1,
|
||||||
|
"total": len(parts),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue