feat(feishu): 添加飞书富文本消息支持

- 实现 Markdown 到飞书交互卡片的转换功能
- 添加表格、标题等富文本元素解析支持
- 集成飞书交互式卡片消息发送机制
- 添加消息类型和内容构建逻辑
- 支持代码块保护和多表限制处理
- 实现表格行数限制和列宽配置功能
This commit is contained in:
ario 2026-03-01 21:35:09 +08:00
parent 33f67e8275
commit 302cd05be4
2 changed files with 262 additions and 5 deletions

View file

@ -102,17 +102,18 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("chat ID is empty")
}
payload, err := json.Marshal(map[string]string{"text": msg.Content})
// Convert markdown to Feishu Interactive Card format
msgType, content, err := buildFeishuContent(msg.Content)
if err != nil {
return fmt.Errorf("failed to marshal feishu content: %w", err)
return fmt.Errorf("failed to build feishu content: %w", err)
}
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(msg.ChatID).
MsgType(larkim.MsgTypeText).
Content(string(payload)).
MsgType(msgType).
Content(content).
Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
Build()).
Build()
@ -128,6 +129,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
"chat_id": msg.ChatID,
"msg_type": msgType,
})
return nil

View file

@ -0,0 +1,255 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu
import (
"encoding/json"
"regexp"
"strings"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
// Feishu Interactive Card message format:
// {
// "config": { "wide_screen_mode": true },
// "elements": [
// { "tag": "markdown", "content": "..." },
// { "tag": "table", "columns": [...], "rows": [...] },
// { "tag": "div", "text": { "tag": "lark_md", "content": "**Heading**" } }
// ]
// }
// Feishu Card limits
const (
maxTablesInCard = 1 // Feishu limit: only 1 table per card
maxRowsInTable = 30 // Feishu limit: max rows per table
)
var (
// Match markdown tables (header + separator + data rows)
reTable = regexp.MustCompile(`(?m)((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)`)
// Match headings
reHeading = regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
// Match code blocks (to protect them from heading parsing)
reCodeBlock = regexp.MustCompile("(```[\\s\\S]*?```)")
)
// cardElement represents an element in Feishu interactive card.
type cardElement map[string]any
// cardColumn represents a column definition in Feishu table.
type cardColumn struct {
Tag string `json:"tag"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Width string `json:"width"`
}
// markdownToFeishuCard converts Markdown text to Feishu Interactive Card JSON content string.
func markdownToFeishuCard(text string) (string, error) {
elements := buildCardElements(text)
card := map[string]any{
"config": map[string]any{
"wide_screen_mode": true,
},
"elements": elements,
}
data, err := json.Marshal(card)
return string(data), err
}
// buildCardElements splits content into div/markdown + table elements for Feishu card.
// If there are multiple tables, all tables are rendered as markdown (Feishu limit).
func buildCardElements(content string) []cardElement {
// First, count all tables
tableMatches := reTable.FindAllStringSubmatchIndex(content, -1)
hasMultipleTables := len(tableMatches) > maxTablesInCard
var elements []cardElement
lastEnd := 0
// Process all tables
for _, m := range tableMatches {
// Add content before table
before := content[lastEnd:m[0]]
if strings.TrimSpace(before) != "" {
elements = append(elements, splitHeadings(before)...)
}
// Add table element
tableText := content[m[0]:m[1]]
if hasMultipleTables {
// Multiple tables: render as markdown
elements = append(elements, cardElement{"tag": "markdown", "content": tableText})
} else {
// Single table: try to parse as Feishu table
tableEl := parseMarkdownTable(tableText)
if tableEl != nil {
elements = append(elements, tableEl)
} else {
elements = append(elements, cardElement{"tag": "markdown", "content": tableText})
}
}
lastEnd = m[1]
}
// Add remaining content
remaining := content[lastEnd:]
if strings.TrimSpace(remaining) != "" {
elements = append(elements, splitHeadings(remaining)...)
}
if len(elements) == 0 {
return []cardElement{{"tag": "markdown", "content": content}}
}
return elements
}
// splitHeadings splits content by headings, converting headings to div elements.
func splitHeadings(content string) []cardElement {
// Protect code blocks from heading parsing
protected := content
codeBlocks := make(map[string]string)
for i, m := range reCodeBlock.FindAllString(content, -1) {
placeholder := "\x00CODE" + string(rune('0'+i)) + "\x00"
codeBlocks[placeholder] = m
protected = strings.Replace(protected, m, placeholder, 1)
}
var elements []cardElement
lastEnd := 0
for _, m := range reHeading.FindAllStringSubmatchIndex(protected, -1) {
// Add content before heading
before := strings.TrimSpace(protected[lastEnd:m[0]])
if before != "" {
elements = append(elements, cardElement{"tag": "markdown", "content": before})
}
// Add heading as div with bold text
headingText := strings.TrimSpace(protected[m[4]:m[5]])
elements = append(elements, cardElement{
"tag": "div",
"text": map[string]any{
"tag": "lark_md",
"content": "**" + headingText + "**",
},
})
lastEnd = m[1]
}
// Add remaining content
remaining := strings.TrimSpace(protected[lastEnd:])
if remaining != "" {
elements = append(elements, cardElement{"tag": "markdown", "content": remaining})
}
// Restore code blocks
for placeholder, code := range codeBlocks {
for i := range elements {
if el, ok := elements[i]["content"].(string); ok {
elements[i]["content"] = strings.Replace(el, placeholder, code, -1)
}
}
}
if len(elements) == 0 {
return []cardElement{{"tag": "markdown", "content": content}}
}
return elements
}
// parseMarkdownTable parses a markdown table into a Feishu table element.
func parseMarkdownTable(tableText string) cardElement {
lines := strings.Split(strings.TrimSpace(tableText), "\n")
if len(lines) < 3 {
return nil
}
// Parse header line
headers := parseTableRow(lines[0])
if len(headers) == 0 {
return nil
}
// Skip separator line (line 1)
// Parse data rows (limited)
var rows []map[string]any
for i := 2; i < len(lines) && len(rows) < maxRowsInTable; i++ {
cells := parseTableRow(lines[i])
if len(cells) == 0 {
continue
}
row := make(map[string]any)
for j, cell := range cells {
if j < len(headers) {
row[getColumnName(j)] = cell
}
}
rows = append(rows, row)
}
// Build columns
columns := make([]cardColumn, len(headers))
for i, h := range headers {
columns[i] = cardColumn{
Tag: "column",
Name: getColumnName(i),
DisplayName: h,
Width: "auto",
}
}
return cardElement{
"tag": "table",
"page_size": len(rows) + 1,
"columns": columns,
"rows": rows,
}
}
// parseTableRow parses a single markdown table row into cells.
func parseTableRow(line string) []string {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "|")
line = strings.TrimSuffix(line, "|")
parts := strings.Split(line, "|")
cells := make([]string, 0, len(parts))
for _, p := range parts {
cells = append(cells, strings.TrimSpace(p))
}
return cells
}
// getColumnName returns Feishu table column name by index (c0, c1, c2, ...).
func getColumnName(index int) string {
if index < 10 {
return "c" + string(rune('0'+index))
}
// For indices >= 10, use letter notation
return "c" + string(rune('a'+index-10))
}
// buildFeishuContent converts content to Feishu message format.
// Returns (msgType, jsonPayload, error).
func buildFeishuContent(content string) (string, string, error) {
cardPayload, err := markdownToFeishuCard(content)
if err == nil {
return larkim.MsgTypeInteractive, cardPayload, nil
}
// Fallback to plain text
payload, err := json.Marshal(map[string]string{"text": content})
if err != nil {
return "", "", err
}
return larkim.MsgTypeText, string(payload), nil
}