feat(channels/feishu): add message card and streaming support with security fixes

- Add FeishuCard struct for rich message rendering
- Add FeishuStreamingSession for real-time streaming output
- Add render_mode config (raw/auto/card)
- Add streaming_enabled and streaming_interval config
- Add ShouldUseCard for auto-detecting code blocks and tables
- Add token caching with expiration
- Add proper error handling and logging

🤖 Fully AI-generated
This commit is contained in:
rossiniortensia-ops 2026-02-28 18:25:07 +08:00
parent 172e6ebe5f
commit 2e0f9d6ee9
4 changed files with 595 additions and 7 deletions

View file

@ -30,9 +30,11 @@ type InboundMessage struct {
}
type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
MsgType string `json:"msg_type,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// MediaPart describes a single media attachment to send.

View file

@ -28,6 +28,9 @@ type FeishuChannel struct {
client *lark.Client
wsClient *larkws.Client
streamingMu sync.RWMutex
streamingCache map[string]*FeishuStreamingSession
mu sync.Mutex
cancel context.CancelFunc
}
@ -39,9 +42,10 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
)
return &FeishuChannel{
BaseChannel: base,
config: cfg,
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
BaseChannel: base,
config: cfg,
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
streamingCache: make(map[string]*FeishuStreamingSession),
}, nil
}
@ -102,6 +106,47 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("chat ID is empty")
}
msgType := c.resolveMessageType(msg.Content, msg.MsgType)
switch msgType {
case "card":
return c.sendCardMessage(ctx, msg)
case "streaming":
return c.sendStreamingMessage(ctx, msg)
default:
return c.sendTextMessage(ctx, msg)
}
}
func (c *FeishuChannel) resolveMessageType(content, explicitMsgType string) string {
if explicitMsgType == "card" || explicitMsgType == "interactive" {
return "card"
}
if explicitMsgType == "text" {
return "text"
}
renderMode := c.config.RenderMode
if renderMode == "" {
renderMode = "raw"
}
switch renderMode {
case "card":
return "card"
case "raw":
return "text"
case "auto":
if ShouldUseCard(content) {
return "card"
}
return "text"
default:
return "text"
}
}
func (c *FeishuChannel) sendTextMessage(ctx context.Context, msg bus.OutboundMessage) error {
payload, err := json.Marshal(map[string]string{"text": msg.Content})
if err != nil {
return fmt.Errorf("failed to marshal feishu content: %w", err)
@ -126,13 +171,88 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
}
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
logger.DebugCF("feishu", "Feishu text message sent", map[string]any{
"chat_id": msg.ChatID,
})
return nil
}
func (c *FeishuChannel) sendCardMessage(ctx context.Context, msg bus.OutboundMessage) error {
card := BuildMarkdownCard(msg.Content)
cardJSON, err := json.Marshal(card)
if err != nil {
return fmt.Errorf("failed to marshal card: %w", err)
}
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(msg.ChatID).
MsgType(larkim.MsgTypeInteractive).
Content(string(cardJSON)).
Uuid(fmt.Sprintf("picoclaw-card-%d", time.Now().UnixNano())).
Build()).
Build()
resp, err := c.client.Im.V1.Message.Create(ctx, req)
if err != nil {
return fmt.Errorf("failed to send feishu card: %w", err)
}
if !resp.Success() {
return fmt.Errorf("feishu card api error: code=%d msg=%s", resp.Code, resp.Msg)
}
logger.DebugCF("feishu", "Feishu card message sent", map[string]any{
"chat_id": msg.ChatID,
})
return nil
}
func (c *FeishuChannel) sendStreamingMessage(ctx context.Context, msg bus.OutboundMessage) error {
streamingEnabled := c.config.StreamingEnabled
if !streamingEnabled {
return c.sendCardMessage(ctx, msg)
}
c.streamingMu.Lock()
session, ok := c.streamingCache[msg.ChatID]
if !ok {
interval := c.config.StreamingInterval
if interval <= 0 {
interval = 100
}
session = NewStreamingSession(c.client, c.config.AppID, c.config.AppSecret, "", interval)
c.streamingCache[msg.ChatID] = session
}
c.streamingMu.Unlock()
if !session.IsActive() {
if err := session.Start(ctx, msg.ChatID); err != nil {
logger.ErrorCF("feishu", "Failed to start streaming session", map[string]any{
"error": err.Error(),
})
return c.sendCardMessage(ctx, msg)
}
}
session.Update(msg.Content)
if msg.Metadata != nil && msg.Metadata["streaming_end"] == "true" {
c.streamingMu.Lock()
delete(c.streamingCache, msg.ChatID)
c.streamingMu.Unlock()
session.Close(msg.Content)
logger.DebugCF("feishu", "Streaming session closed", map[string]any{
"chat_id": msg.ChatID,
})
}
return nil
}
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
if event == nil || event.Event == nil || event.Event.Message == nil {
return nil

View file

@ -0,0 +1,457 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package feishu
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
)
type FeishuCard struct {
Schema string `json:"schema"`
Config FeishuCardConfig `json:"config"`
Body FeishuCardBody `json:"body"`
}
type FeishuCardConfig struct {
WideScreenMode bool `json:"wide_screen_mode"`
StreamingMode bool `json:"streaming_mode,omitempty"`
Summary *FeishuCardSummary `json:"summary,omitempty"`
Streaming *FeishuStreamingCfg `json:"streaming_config,omitempty"`
}
type FeishuCardSummary struct {
Content string `json:"content"`
}
type FeishuStreamingCfg struct {
PrintFrequencyMs int `json:"print_frequency_ms,omitempty"`
PrintStep int `json:"print_step,omitempty"`
}
type FeishuCardBody struct {
Elements []FeishuCardElement `json:"elements"`
}
type FeishuCardElement struct {
Tag string `json:"tag"`
Content string `json:"content,omitempty"`
ElementID string `json:"element_id,omitempty"`
}
func BuildMarkdownCard(text string) *FeishuCard {
return &FeishuCard{
Schema: "2.0",
Config: FeishuCardConfig{
WideScreenMode: true,
},
Body: FeishuCardBody{
Elements: []FeishuCardElement{
{Tag: "markdown", Content: text, ElementID: "content"},
},
},
}
}
func BuildStreamingCard(initialText string) *FeishuCard {
return &FeishuCard{
Schema: "2.0",
Config: FeishuCardConfig{
WideScreenMode: true,
StreamingMode: true,
Summary: &FeishuCardSummary{Content: "[Generating...]"},
Streaming: &FeishuStreamingCfg{PrintFrequencyMs: 50, PrintStep: 2},
},
Body: FeishuCardBody{
Elements: []FeishuCardElement{
{Tag: "markdown", Content: initialText, ElementID: "content"},
},
},
}
}
func ShouldUseCard(text string) bool {
hasCodeBlock := strings.Contains(text, "```")
lines := strings.Split(text, "\n")
hasTable := false
if len(lines) >= 3 {
count := 0
for _, line := range lines {
if strings.Contains(line, "|") {
count++
}
}
hasTable = count >= 2
}
return hasCodeBlock || hasTable
}
func TruncateSummary(text string, maxLen int) string {
if maxLen <= 0 {
maxLen = 50
}
clean := strings.ReplaceAll(text, "\n", " ")
clean = strings.TrimSpace(clean)
if len(clean) <= maxLen {
return clean
}
return clean[:maxLen-3] + "..."
}
type tokenEntry struct {
token string
expiresAt time.Time
}
type tokenCache struct {
mu sync.RWMutex
tokens map[string]tokenEntry
}
var globalTokenCache = &tokenCache{
tokens: make(map[string]tokenEntry),
}
func (tc *tokenCache) Get(key string) (string, bool) {
tc.mu.RLock()
defer tc.mu.RUnlock()
if cached, ok := tc.tokens[key]; ok {
if time.Now().Before(cached.expiresAt.Add(-time.Minute)) {
return cached.token, true
}
}
return "", false
}
func (tc *tokenCache) Set(key, token string, expiresIn int) {
tc.mu.Lock()
defer tc.mu.Unlock()
tc.tokens[key] = tokenEntry{
token: token,
expiresAt: time.Now().Add(time.Duration(expiresIn) * time.Second),
}
}
func getFeishuToken(appID, appSecret, domain string) (string, error) {
cacheKey := fmt.Sprintf("%s|%s", domain, appID)
if token, ok := globalTokenCache.Get(cacheKey); ok {
return token, nil
}
apiBase := "https://open.feishu.cn/open-apis"
if domain == "lark" {
apiBase = "https://open.larksuite.com/open-apis"
}
reqBody := map[string]string{
"app_id": appID,
"app_secret": appSecret,
}
reqJSON, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal token request: %w", err)
}
req, err := http.NewRequest("POST", apiBase+"/auth/v3/tenant_access_token/internal", bytes.NewReader(reqJSON))
if err != nil {
return "", fmt.Errorf("create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request token: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
}
var tokenResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
Expire int `json:"expire"`
}
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return "", fmt.Errorf("unmarshal token response: %w", err)
}
if tokenResp.Code != 0 {
return "", fmt.Errorf("token error: code=%d msg=%s", tokenResp.Code, tokenResp.Msg)
}
globalTokenCache.Set(cacheKey, tokenResp.TenantAccessToken, tokenResp.Expire)
return tokenResp.TenantAccessToken, nil
}
type FeishuStreamingSession struct {
client *lark.Client
appID string
appSecret string
domain string
cardID string
messageID string
sequence int
currentText string
closed bool
mu sync.Mutex
updateCh chan string
lastUpdate time.Time
throttleMs int
pendingText string
done chan struct{}
apiBase string
}
func NewStreamingSession(client *lark.Client, appID, appSecret, domain string, throttleMs int) *FeishuStreamingSession {
if throttleMs <= 0 {
throttleMs = 100
}
apiBase := "https://open.feishu.cn/open-apis"
if domain == "lark" {
apiBase = "https://open.larksuite.com/open-apis"
}
return &FeishuStreamingSession{
client: client,
appID: appID,
appSecret: appSecret,
domain: domain,
updateCh: make(chan string, 100),
throttleMs: throttleMs,
done: make(chan struct{}),
apiBase: apiBase,
}
}
func (s *FeishuStreamingSession) Start(ctx context.Context, chatID string) error {
token, err := getFeishuToken(s.appID, s.appSecret, s.domain)
if err != nil {
return fmt.Errorf("get token: %w", err)
}
card := BuildStreamingCard("⏳ Thinking...")
cardJSON, err := json.Marshal(map[string]string{"type": "card_json", "data": JSONMarshal(card)})
if err != nil {
return fmt.Errorf("marshal card: %w", err)
}
createReq, err := http.NewRequest("POST", s.apiBase+"/cardkit/v1/cards", bytes.NewReader(cardJSON))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
createReq.Header.Set("Authorization", "Bearer "+token)
createReq.Header.Set("Content-Type", "application/json")
createResp, err := (&http.Client{Timeout: 10 * time.Second}).Do(createReq)
if err != nil {
return fmt.Errorf("create card: %w", err)
}
defer createResp.Body.Close()
var createResult struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
CardID string `json:"card_id"`
} `json:"data"`
}
if err := json.NewDecoder(createResp.Body).Decode(&createResult); err != nil {
return fmt.Errorf("decode create response: %w", err)
}
if createResult.Code != 0 {
return fmt.Errorf("create card error: code=%d msg=%s", createResult.Code, createResult.Msg)
}
s.cardID = createResult.Data.CardID
content := fmt.Sprintf(`{"type":"card","data":{"card_id":"%s"}}`, s.cardID)
req := larkim.NewCreateMessageReqBuilder().
ReceiveIdType(larkim.ReceiveIdTypeChatId).
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType(larkim.MsgTypeInteractive).
Content(content).
Build()).
Build()
resp, err := s.client.Im.V1.Message.Create(ctx, req)
if err != nil {
return fmt.Errorf("send card message: %w", err)
}
if !resp.Success() {
return fmt.Errorf("send error: code=%d msg=%s", resp.Code, resp.Msg)
}
if resp.Data == nil || resp.Data.MessageId == nil || *resp.Data.MessageId == "" {
return fmt.Errorf("no message_id returned")
}
s.messageID = *resp.Data.MessageId
s.sequence = 1
go s.runUpdateLoop()
return nil
}
func (s *FeishuStreamingSession) runUpdateLoop() {
for {
select {
case text := <-s.updateCh:
s.doUpdate(text)
case <-s.done:
return
}
}
}
func (s *FeishuStreamingSession) doUpdate(text string) {
s.mu.Lock()
s.currentText = text
s.lastUpdate = time.Now()
seq := s.sequence + 1
s.sequence = seq
s.mu.Unlock()
token, err := getFeishuToken(s.appID, s.appSecret, s.domain)
if err != nil {
return
}
updateURL := fmt.Sprintf("%s/cardkit/v1/cards/%s/elements/content/content", s.apiBase, s.cardID)
updateBody := map[string]interface{}{
"content": text,
"sequence": seq,
"uuid": fmt.Sprintf("s_%s_%d", s.cardID, seq),
}
updateJSON, err := json.Marshal(updateBody)
if err != nil {
return
}
req, err := http.NewRequest("PUT", updateURL, bytes.NewReader(updateJSON))
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
}
func (s *FeishuStreamingSession) Update(text string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
now := time.Now()
if now.Sub(s.lastUpdate) < time.Duration(s.throttleMs)*time.Millisecond {
s.pendingText = text
return
}
select {
case s.updateCh <- text:
default:
}
}
func (s *FeishuStreamingSession) Close(finalText string) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
close(s.done)
text := finalText
if text == "" {
text = s.pendingText
}
if text == "" {
text = s.currentText
}
s.mu.Unlock()
if text != s.currentText {
s.doUpdate(text)
}
return s.closeStreamingMode(text)
}
func (s *FeishuStreamingSession) closeStreamingMode(finalText string) error {
token, err := getFeishuToken(s.appID, s.appSecret, s.domain)
if err != nil {
return err
}
s.mu.Lock()
seq := s.sequence + 1
s.sequence = seq
s.mu.Unlock()
summary := TruncateSummary(finalText, 50)
settings := map[string]interface{}{
"settings": JSONMarshal(map[string]interface{}{
"config": map[string]interface{}{
"streaming_mode": false,
"summary": map[string]string{"content": summary},
},
}),
"sequence": seq,
"uuid": fmt.Sprintf("c_%s_%d", s.cardID, seq),
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return err
}
url := fmt.Sprintf("%s/cardkit/v1/cards/%s/settings", s.apiBase, s.cardID)
req, err := http.NewRequest("PATCH", url, bytes.NewReader(settingsJSON))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
func (s *FeishuStreamingSession) IsActive() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.cardID != "" && !s.closed
}
func JSONMarshal(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}

View file

@ -252,6 +252,15 @@ type FeishuConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
// RenderMode: 消息渲染模式
// "raw" - 纯文本(默认,保持原有行为)
// "auto" - 自动检测,代码块/表格使用卡片
// "card" - 始终使用卡片
RenderMode string `json:"render_mode" env:"PICOCLAW_CHANNELS_FEISHU_RENDER_MODE"`
// StreamingEnabled: 启用流式输出(默认 false
StreamingEnabled bool `json:"streaming_enabled" env:"PICOCLAW_CHANNELS_FEISHU_STREAMING_ENABLED"`
// StreamingInterval: 流式更新间隔(毫秒,默认 100
StreamingInterval int `json:"streaming_interval" env:"PICOCLAW_CHANNELS_FEISHU_STREAMING_INTERVAL"`
}
type DiscordConfig struct {