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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,18 +124,46 @@ 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
|
||||
}
|
||||
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.
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -150,6 +180,30 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
|
|||
if !resp.Success() {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue