package feishu import ( "context" "encoding/json" "fmt" "log/slog" "strings" "github.com/chenhg5/cc-connect/core" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" ) func plainText(content string) map[string]any { return map[string]any{"tag": "plain_text", "content": content} } // ReplyCard sends a structured card as a reply to the original message. func (p *interactivePlatform) ReplyCard(ctx context.Context, rctx any, card *core.Card) error { rc, ok := rctx.(replyContext) if !ok { return fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx) } cardJSON := renderCard(card, rc.sessionKey) if !p.shouldUseThreadOrReplyAPI(rc) { if rc.chatID == "" { return fmt.Errorf("%s: chatID is empty, cannot send card", p.tag()) } return p.createMessage(ctx, rc.chatID, larkim.MsgTypeInteractive, cardJSON, "send card") } return p.replyMessage(ctx, rc, larkim.MsgTypeInteractive, cardJSON) } // SendCard sends a structured card as a new message to the chat. func (p *interactivePlatform) SendCard(ctx context.Context, rctx any, card *core.Card) error { rc, ok := rctx.(replyContext) if !ok { return fmt.Errorf("%s: invalid reply context type %T", p.tag(), rctx) } if rc.chatID == "" { return fmt.Errorf("%s: chatID is empty, cannot send card", p.tag()) } if !p.noReplyToTrigger && p.shouldReplyInThread(rc) { return p.ReplyCard(ctx, rctx, card) } cardJSON := renderCard(card, rc.sessionKey) return p.createMessage(ctx, rc.chatID, larkim.MsgTypeInteractive, cardJSON, "send card") } // RefreshCard updates a previously rendered card in-place using the Patch API. // It looks up the messageID stored from the most recent card action callback // for the given session key and patches that message with the new card content. func (p *interactivePlatform) RefreshCard(ctx context.Context, sessionKey string, card *core.Card) error { p.cardActionMsgMu.Lock() msgID := p.cardActionMsgIDs[sessionKey] p.cardActionMsgMu.Unlock() if msgID == "" { return fmt.Errorf("%s: no tracked card messageID for session %q", p.tag(), sessionKey) } cardJSON := renderCard(card, sessionKey) req := larkim.NewPatchMessageReqBuilder(). MessageId(msgID). Body(larkim.NewPatchMessageReqBodyBuilder(). Content(cardJSON). Build()). Build() return p.withTransientRetry(ctx, "refresh card", func() error { return p.withFreshTenantAccessTokenRetry(ctx, "refresh card", func(client *lark.Client, options ...larkcore.RequestOptionFunc) error { resp, err := client.Im.Message.Patch(ctx, req, options...) if err != nil { return fmt.Errorf("%s: refresh card: %w", p.tag(), err) } if !resp.Success() { return fmt.Errorf("%s: refresh card code=%d msg=%s", p.tag(), resp.Code, resp.Msg) } return nil }) }) } // renderCardMap converts a core.Card into the Feishu Interactive Card map // using the v1 format. Used both for message API (via renderCard) and // callback responses (CardActionTriggerResponse). func renderCardMap(card *core.Card, sessionKey string) map[string]any { result := map[string]any{ "config": map[string]any{ "wide_screen_mode": true, }, } if card == nil { return result } if card.Header != nil && card.Header.Title != "" { color := card.Header.Color if color == "" { color = "blue" } result["header"] = map[string]any{ "title": plainText(card.Header.Title), "template": color, } } if transformed, ok := renderDeleteModeCheckerCard(card, result); ok { return transformed } var elements []map[string]any for _, elem := range card.Elements { switch e := elem.(type) { case core.CardMarkdown: elements = append(elements, map[string]any{ "tag": "markdown", "content": e.Content, }) case core.CardDivider: elements = append(elements, map[string]any{ "tag": "hr", }) case core.CardActions: var actions []map[string]any for _, btn := range e.Buttons { btnType := btn.Type if btnType == "" { btnType = "default" } valMap := map[string]string{"action": btn.Value} if sessionKey != "" { valMap["session_key"] = sessionKey } for k, v := range btn.Extra { valMap[k] = v } action := map[string]any{ "tag": "button", "text": plainText(btn.Text), "type": btnType, "value": valMap, } if e.Layout == core.CardActionLayoutEqualColumns { action["width"] = "fill" } actions = append(actions, action) } if len(actions) > 0 { if e.Layout == core.CardActionLayoutEqualColumns { columns := make([]map[string]any, 0, len(actions)) for _, action := range actions { columns = append(columns, map[string]any{ "tag": "column", "width": "weighted", "weight": 1, "vertical_align": "center", "horizontal_align": "center", "elements": []map[string]any{action}, }) } columnSet := map[string]any{ "tag": "column_set", "columns": columns, } if len(actions) == 2 { columnSet["flex_mode"] = "bisect" } elements = append(elements, columnSet) } else { elements = append(elements, map[string]any{ "tag": "action", "actions": actions, }) } } case core.CardListItem: btnType := e.BtnType if btnType == "" { btnType = "default" } valMap := map[string]string{"action": e.BtnValue} if sessionKey != "" { valMap["session_key"] = sessionKey } for k, v := range e.Extra { valMap[k] = v } elements = append(elements, map[string]any{ "tag": "column_set", "flex_mode": "none", "columns": []map[string]any{ { "tag": "column", "width": "weighted", "weight": 5, "vertical_align": "center", "elements": []map[string]any{ { "tag": "markdown", "content": e.Text, }, }, }, { "tag": "column", "width": "auto", "vertical_align": "center", "elements": []map[string]any{ { "tag": "button", "text": plainText(e.BtnText), "type": btnType, "value": valMap, }, }, }, }, }) case core.CardSelect: var options []map[string]any for _, opt := range e.Options { options = append(options, map[string]any{ "text": plainText(opt.Text), "value": opt.Value, }) } selectElem := map[string]any{ "tag": "select_static", "placeholder": plainText(e.Placeholder), "options": options, } if sessionKey != "" { selectElem["value"] = map[string]string{"session_key": sessionKey} } if e.InitValue != "" { selectElem["initial_option"] = e.InitValue } elements = append(elements, map[string]any{ "tag": "action", "actions": []map[string]any{selectElem}, }) case core.CardNote: elements = append(elements, map[string]any{ "tag": "note", "elements": []map[string]any{plainText(e.Text)}, }) } } if len(elements) == 0 { elements = []map[string]any{{"tag": "markdown", "content": " "}} } result["elements"] = elements return result } type deleteModeCheckerRow struct { id string text string checked bool } func renderDeleteModeCheckerCard(card *core.Card, base map[string]any) (map[string]any, bool) { if card == nil { return nil, false } formRowElements := make([]map[string]any, 0) notes := make([]core.CardNote, 0) navRows := make([]core.CardActions, 0) submitText := "" cancelText := "" for _, elem := range card.Elements { switch e := elem.(type) { case core.CardListItem: id, selectable, ok := parseDeleteModeListItemAction(e.BtnValue) if !ok { return nil, false } text := normalizeDeleteModeCheckerText(e.Text) if !selectable { formRowElements = append(formRowElements, map[string]any{ "tag": "markdown", "content": "▶ " + text, }) continue } row := deleteModeCheckerRow{ id: id, text: text, checked: strings.Contains(e.Text, "☑"), } formRowElements = append(formRowElements, map[string]any{ "tag": "checker", "name": deleteModeCheckerName(row.id), "checked": row.checked, "text": map[string]any{ "tag": "lark_md", "content": row.text, }, }) case core.CardNote: notes = append(notes, e) case core.CardActions: remaining := make([]core.CardButton, 0, len(e.Buttons)) for _, btn := range e.Buttons { switch btn.Value { case "act:/delete-mode confirm": submitText = btn.Text case "act:/delete-mode cancel": cancelText = btn.Text default: remaining = append(remaining, btn) } } if len(remaining) > 0 { navRows = append(navRows, core.CardActions{Buttons: remaining, Layout: e.Layout}) } case core.CardMarkdown, core.CardDivider, core.CardSelect: return nil, false } } if len(formRowElements) == 0 || submitText == "" { return nil, false } elements := make([]map[string]any, 0, len(notes)+1+len(navRows)) for _, n := range notes { if n.Text == "" { continue } if n.Tag == "delete-mode-selected-count" { continue } elements = append(elements, map[string]any{ "tag": "note", "elements": []map[string]any{plainText(n.Text)}, }) } formElements := append([]map[string]any{}, formRowElements...) buttonColumns := []map[string]any{ { "tag": "column", "width": "auto", "vertical_align": "center", "elements": []map[string]any{ { "tag": "button", "text": plainText(submitText), "type": "danger", "name": "delete_mode_submit", "form_action_type": "submit", "value": map[string]string{"action": "act:/delete-mode form-submit"}, }, }, }, } if cancelText != "" { buttonColumns = append(buttonColumns, map[string]any{ "tag": "column", "width": "auto", "vertical_align": "center", "elements": []map[string]any{ { "tag": "button", "text": plainText(cancelText), "type": "default", "name": "delete_mode_cancel", "value": map[string]string{"action": "act:/delete-mode cancel"}, }, }, }) } formElements = append(formElements, map[string]any{ "tag": "column_set", "horizontal_align": "left", "columns": buttonColumns, }) elements = append(elements, map[string]any{ "tag": "form", "name": "delete_mode_form", "elements": formElements, }) for _, row := range navRows { actions := make([]map[string]any, 0, len(row.Buttons)) for _, btn := range row.Buttons { btnType := btn.Type if btnType == "" { btnType = "default" } valMap := map[string]string{"action": btn.Value} for k, v := range btn.Extra { valMap[k] = v } action := map[string]any{ "tag": "button", "text": plainText(btn.Text), "type": btnType, "value": valMap, } if row.Layout == core.CardActionLayoutEqualColumns { action["width"] = "fill" } actions = append(actions, action) } if len(actions) > 0 { elements = append(elements, map[string]any{ "tag": "action", "actions": actions, }) } } base["elements"] = elements return base, true } func normalizeDeleteModeCheckerText(text string) string { trimmed := strings.TrimSpace(text) for _, prefix := range []string{"☑ ▶", "◻ ▶", "▶", "☑", "◻"} { if strings.HasPrefix(trimmed, prefix) { trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, prefix)) break } } return trimmed } func parseDeleteModeListItemAction(action string) (id string, selectable bool, ok bool) { const ( togglePrefix = "act:/delete-mode toggle " noopPrefix = "act:/delete-mode noop " ) switch { case strings.HasPrefix(action, togglePrefix): id = strings.TrimSpace(strings.TrimPrefix(action, togglePrefix)) return id, true, id != "" case strings.HasPrefix(action, noopPrefix): id = strings.TrimSpace(strings.TrimPrefix(action, noopPrefix)) return id, false, id != "" default: return "", false, false } } // renderCard converts a core.Card into the Feishu Interactive Card JSON string. func renderCard(card *core.Card, sessionKey string) string { b, err := json.Marshal(renderCardMap(card, sessionKey)) if err != nil { slog.Error("feishu: renderCard marshal failed", "error", err) return `{"config":{"wide_screen_mode":true},"elements":[]}` } return string(b) }