From a29a8be02bd72cbc171fd33ecbb999b5276d894c Mon Sep 17 00:00:00 2001 From: decomarval Date: Fri, 6 Mar 2026 13:28:50 +0800 Subject: [PATCH] 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. --- pkg/channels/feishu/common.go | 73 +++++ pkg/channels/feishu/common_test.go | 485 +++++++++++++++++++++++++++++ pkg/channels/feishu/feishu_64.go | 90 ++++-- 3 files changed, 630 insertions(+), 18 deletions(-) diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go index fbe085b73..024db9326 100644 --- a/pkg/channels/feishu/common.go +++ b/pkg/channels/feishu/common.go @@ -8,9 +8,36 @@ import ( 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. 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. func stringValue(v *string) string { 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. 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. // These are inserted by Feishu when users @mention someone in a message. func stripMentionPlaceholders(content string, mentions []*larkim.MentionEvent) string { diff --git a/pkg/channels/feishu/common_test.go b/pkg/channels/feishu/common_test.go index fefc9f7c1..003f5f246 100644 --- a/pkg/channels/feishu/common_test.go +++ b/pkg/channels/feishu/common_test.go @@ -2,6 +2,8 @@ package feishu import ( "encoding/json" + "fmt" + "strings" "testing" 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() +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5217dd4e9..f65c6cf1c 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "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. +// 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 { if !c.IsRunning() { 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) } - // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.Content) - if err != nil { - return fmt.Errorf("feishu send: card build failed: %w", err) + // Split content into multiple parts if it contains too many tables + parts := splitContentByTableCount(msg.Content) + + // 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. // 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 { - cardContent, err := buildMarkdownCard(content) - if err != nil { - return fmt.Errorf("feishu edit: card build failed: %w", err) + // 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 { + 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(). - MessageId(messageID). - Body(larkim.NewPatchMessageReqBodyBuilder().Content(cardContent).Build()). - Build() + // Send remaining parts as new messages + for i := 1; i < len(parts); i++ { + if strings.TrimSpace(parts[i]) == "" { + continue + } - 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) + 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 }