Files
2026-06-02 23:14:41 +08:00

465 lines
12 KiB
Go

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)
}