feat(feishu): 添加飞书富文本消息支持
- 实现 Markdown 到飞书交互卡片的转换功能 - 添加表格、标题等富文本元素解析支持 - 集成飞书交互式卡片消息发送机制 - 添加消息类型和内容构建逻辑 - 支持代码块保护和多表限制处理 - 实现表格行数限制和列宽配置功能
This commit is contained in:
parent
33f67e8275
commit
302cd05be4
2 changed files with 262 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
255
pkg/channels/feishu/richtext.go
Normal file
255
pkg/channels/feishu/richtext.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue