Files
cc-connect/core/card.go
T
2026-06-02 23:14:41 +08:00

311 lines
8.9 KiB
Go

package core
import (
"fmt"
"strings"
)
// Card represents a structured rich message that can be rendered as
// platform-specific cards (Feishu Interactive Card, Telegram message, etc.)
// or degraded to plain text for platforms without card support.
type Card struct {
Header *CardHeader
Elements []CardElement
}
// CardHeader is the optional colored title bar of a card.
type CardHeader struct {
Title string
Color string // blue, green, red, orange, purple, grey, turquoise, violet, indigo, wathet, yellow, carmine
}
// CardElement is the interface satisfied by all card content elements.
type CardElement interface {
cardElement()
}
// CardMarkdown renders markdown-formatted text.
type CardMarkdown struct{ Content string }
// CardDivider renders a horizontal rule.
type CardDivider struct{}
// CardActions renders a row of clickable buttons.
type CardActions struct {
Buttons []CardButton
Layout CardActionLayout
}
// CardNote renders small footnote text at the bottom.
// Tag is an optional machine-readable identifier (not displayed) used by
// platform renderers to recognize and handle specific notes programmatically.
type CardNote struct {
Text string
Tag string
}
// CardListItem renders a row with description text on the left and a button on the right.
// On Feishu this maps to div+extra; on other platforms it degrades to a text line.
type CardListItem struct {
Text string // left-side description
BtnText string // button label
BtnType string // "primary", "default", "danger"
BtnValue string // callback data
Extra map[string]string // additional key-value pairs carried in the callback
}
// CardSelect renders a dropdown selector.
// On Feishu this maps to select_static; on other platforms it degrades to text.
type CardSelect struct {
Placeholder string
Options []CardSelectOption
InitValue string // pre-selected option value (empty = none)
}
// CardSelectOption is one item in a CardSelect dropdown.
type CardSelectOption struct {
Text string
Value string
}
func (CardMarkdown) cardElement() {}
func (CardDivider) cardElement() {}
func (CardActions) cardElement() {}
func (CardNote) cardElement() {}
func (CardListItem) cardElement() {}
func (CardSelect) cardElement() {}
// CardButton represents a clickable button inside a CardActions element.
type CardButton struct {
Text string // display label
Type string // "primary", "default", "danger"
Value string // callback data, e.g. "cmd:/new", "cmd:/switch 3"
Extra map[string]string // additional key-value pairs carried in the callback (platform-specific)
}
// CardActionLayout controls how a CardActions row should be rendered by
// platforms with richer layout capabilities.
type CardActionLayout string
const (
CardActionLayoutRow CardActionLayout = "row"
CardActionLayoutEqualColumns CardActionLayout = "equal_columns"
)
// Btn is a shorthand constructor for CardButton.
func Btn(text, typ, value string) CardButton {
return CardButton{Text: text, Type: typ, Value: value}
}
// PrimaryBtn creates a primary-styled button.
func PrimaryBtn(text, value string) CardButton {
return CardButton{Text: text, Type: "primary", Value: value}
}
// DefaultBtn creates a default-styled button.
func DefaultBtn(text, value string) CardButton {
return CardButton{Text: text, Type: "default", Value: value}
}
// DangerBtn creates a danger-styled button.
func DangerBtn(text, value string) CardButton {
return CardButton{Text: text, Type: "danger", Value: value}
}
// --- Builder API ---
// CardBuilder provides a fluent API for constructing Card instances.
type CardBuilder struct {
card Card
}
// NewCard returns a new CardBuilder.
func NewCard() *CardBuilder {
return &CardBuilder{}
}
// Title sets the card header with a title and color.
func (b *CardBuilder) Title(title, color string) *CardBuilder {
b.card.Header = &CardHeader{Title: title, Color: color}
return b
}
// Markdown appends a markdown text element.
func (b *CardBuilder) Markdown(content string) *CardBuilder {
if content != "" {
b.card.Elements = append(b.card.Elements, CardMarkdown{Content: content})
}
return b
}
// Markdownf appends a formatted markdown text element.
func (b *CardBuilder) Markdownf(format string, args ...any) *CardBuilder {
return b.Markdown(fmt.Sprintf(format, args...))
}
// Divider appends a horizontal divider.
func (b *CardBuilder) Divider() *CardBuilder {
b.card.Elements = append(b.card.Elements, CardDivider{})
return b
}
// Buttons appends an action row with the given buttons.
func (b *CardBuilder) Buttons(buttons ...CardButton) *CardBuilder {
if len(buttons) > 0 {
b.card.Elements = append(b.card.Elements, CardActions{Buttons: buttons, Layout: CardActionLayoutRow})
}
return b
}
// ButtonsEqual appends an action row where each button should take equal width
// on platforms that support richer layouts.
func (b *CardBuilder) ButtonsEqual(buttons ...CardButton) *CardBuilder {
if len(buttons) > 0 {
b.card.Elements = append(b.card.Elements, CardActions{Buttons: buttons, Layout: CardActionLayoutEqualColumns})
}
return b
}
// ListItem appends a list row: description on the left, button on the right.
func (b *CardBuilder) ListItem(desc, btnText, btnValue string) *CardBuilder {
b.card.Elements = append(b.card.Elements, CardListItem{
Text: desc, BtnText: btnText, BtnType: "default", BtnValue: btnValue,
})
return b
}
// ListItemBtn is like ListItem but allows specifying the button type.
func (b *CardBuilder) ListItemBtn(desc, btnText, btnType, btnValue string) *CardBuilder {
b.card.Elements = append(b.card.Elements, CardListItem{
Text: desc, BtnText: btnText, BtnType: btnType, BtnValue: btnValue,
})
return b
}
// ListItemBtnExtra is like ListItemBtn but with extra callback data.
func (b *CardBuilder) ListItemBtnExtra(desc, btnText, btnType, btnValue string, extra map[string]string) *CardBuilder {
b.card.Elements = append(b.card.Elements, CardListItem{
Text: desc, BtnText: btnText, BtnType: btnType, BtnValue: btnValue, Extra: extra,
})
return b
}
// Select appends a dropdown selector element.
func (b *CardBuilder) Select(placeholder string, options []CardSelectOption, initValue string) *CardBuilder {
if len(options) > 0 {
b.card.Elements = append(b.card.Elements, CardSelect{
Placeholder: placeholder, Options: options, InitValue: initValue,
})
}
return b
}
// Note appends a footnote element.
func (b *CardBuilder) Note(text string) *CardBuilder {
if text != "" {
b.card.Elements = append(b.card.Elements, CardNote{Text: text})
}
return b
}
func (b *CardBuilder) TaggedNote(tag, text string) *CardBuilder {
if text != "" {
b.card.Elements = append(b.card.Elements, CardNote{Text: text, Tag: tag})
}
return b
}
// Build returns the constructed Card.
func (b *CardBuilder) Build() *Card {
c := b.card
return &c
}
// --- Text Fallback ---
// RenderText converts the card to a plain-text representation for platforms
// that do not support rich cards.
func (c *Card) RenderText() string {
var sb strings.Builder
if c.Header != nil && c.Header.Title != "" {
sb.WriteString("**")
sb.WriteString(c.Header.Title)
sb.WriteString("**\n\n")
}
for _, elem := range c.Elements {
switch e := elem.(type) {
case CardMarkdown:
sb.WriteString(e.Content)
sb.WriteString("\n\n")
case CardDivider:
sb.WriteString("---\n\n")
case CardActions:
// Render buttons as a hint line
for i, btn := range e.Buttons {
if i > 0 {
sb.WriteString(" ")
}
sb.WriteString("[")
sb.WriteString(btn.Text)
sb.WriteString("]")
}
sb.WriteString("\n\n")
case CardListItem:
sb.WriteString(e.Text)
sb.WriteString(" [")
sb.WriteString(e.BtnText)
sb.WriteString("]\n")
case CardSelect:
sb.WriteString(e.Placeholder)
sb.WriteString(": ")
for i, opt := range e.Options {
if i > 0 {
sb.WriteString(" | ")
}
sb.WriteString(opt.Text)
}
sb.WriteString("\n\n")
case CardNote:
sb.WriteString(e.Text)
sb.WriteString("\n")
}
}
return strings.TrimRight(sb.String(), "\n")
}
// HasButtons returns true if the card contains any interactive elements.
func (c *Card) HasButtons() bool {
for _, elem := range c.Elements {
switch elem.(type) {
case CardActions, CardListItem, CardSelect:
return true
}
}
return false
}
// CollectButtons extracts all buttons from the card as a 2D slice
// (one inner slice per CardActions element), suitable for InlineButtonSender.
// CardListItem elements are collected as single-button rows.
func (c *Card) CollectButtons() [][]ButtonOption {
var rows [][]ButtonOption
for _, elem := range c.Elements {
switch e := elem.(type) {
case CardActions:
var row []ButtonOption
for _, btn := range e.Buttons {
row = append(row, ButtonOption{Text: btn.Text, Data: btn.Value})
}
if len(row) > 0 {
rows = append(rows, row)
}
case CardListItem:
rows = append(rows, []ButtonOption{{Text: e.BtnText, Data: e.BtnValue}})
}
}
return rows
}