Files
chenhg5andClaude 3f438f0055 feat(card): add tagged notes and improve delete mode flow
Add Tag field to CardNote struct and TaggedNote method to CardBuilder for programmatic note handling. Simplify delete mode toggle logic by returning updated cards instead of nil and using native Feishu form checkers. Improve Discord command description truncation using runes.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-14 08:46:38 +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
}