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:
decomarval 2026-03-06 13:28:50 +08:00
parent abafa3c2aa
commit a29a8be02b
3 changed files with 630 additions and 18 deletions

View file

@ -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 {

View file

@ -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()
}

View file

@ -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,18 +124,46 @@ 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
} }
return c.sendCard(ctx, msg.ChatID, cardContent)
// 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 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
parts := splitContentByTableCount(content)
// Edit the original message with the first part
if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" {
cardContent, err := buildMarkdownCard(parts[0])
if err != nil { if err != nil {
return fmt.Errorf("feishu edit: card build failed: %w", err) return fmt.Errorf("feishu edit: card build failed: %w", err)
} }
@ -150,6 +180,30 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
if !resp.Success() { if !resp.Success() {
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
}
// Send remaining parts as new messages
for i := 1; i < len(parts); i++ {
if strings.TrimSpace(parts[i]) == "" {
continue
}
cardContent, err := buildMarkdownCard(parts[i])
if err != nil {
return fmt.Errorf("feishu edit: card build failed (part %d/%d): %w", i+1, len(parts), err)
}
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
} }