From 0b340da4eb481efaa74fbceada607f2935607c87 Mon Sep 17 00:00:00 2001 From: "Mr.QiuW" <107580018@qq.com> Date: Fri, 13 Mar 2026 21:31:58 +0800 Subject: [PATCH] feat(feishu): improve /delete card multi-select flow --- core/engine.go | 62 ++++++-- core/engine_test.go | 93 ++++++++++-- platform/feishu/card.go | 218 ++++++++++++++++++++++++++++ platform/feishu/card_test.go | 59 ++++++++ platform/feishu/delete_mode_form.go | 67 +++++++++ platform/feishu/feishu.go | 26 ++++ platform/feishu/platform_test.go | 172 ++++++++++++++++++++++ 7 files changed, 670 insertions(+), 27 deletions(-) create mode 100644 platform/feishu/delete_mode_form.go diff --git a/core/engine.go b/core/engine.go index d25fb88..2756ef8 100644 --- a/core/engine.go +++ b/core/engine.go @@ -129,9 +129,9 @@ type Engine struct { speech SpeechCfg tts *TTSCfg display DisplayCfg - defaultQuiet bool - injectSender bool - startedAt time.Time + defaultQuiet bool + injectSender bool + startedAt time.Time providerSaveFunc func(providerName string) error providerAddSaveFunc func(p ProviderConfig) error @@ -4311,6 +4311,11 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card { case "/switch": return e.renderListCardSafe(sessionKey, 1) case "/delete-mode": + if prefix == "act" { + if fields := strings.Fields(args); len(fields) > 0 && fields[0] == "toggle" { + return nil + } + } if strings.HasPrefix(args, "cancel") { return e.renderListCardSafe(sessionKey, 1) } @@ -4604,18 +4609,30 @@ func (e *Engine) renderDeleteModeSelectCard(sessionKey string, sessions *Session } cb := NewCard().Title(e.i18n.T(MsgDeleteModeTitle), "carmine") + activeAgentID := sessions.GetOrCreateActive(sessionKey).AgentSessionID + selectedCount := 0 for i := start; i < end; i++ { s := agentSessions[i] - marker := "◻" - if _, ok := dm.selectedIDs[s.ID]; ok { - marker = "☑" + isActive := activeAgentID == s.ID + isSelected := false + if !isActive { + _, isSelected = dm.selectedIDs[s.ID] } - if active := sessions.GetOrCreateActive(sessionKey).AgentSessionID; active == s.ID { - marker += " ▶" + marker := "◻" + if isActive { + marker = "▶" + } else if isSelected { + marker = "☑" + selectedCount++ } btnText := e.i18n.T(MsgDeleteModeSelect) btnType := "default" - if _, ok := dm.selectedIDs[s.ID]; ok { + action := fmt.Sprintf("act:/delete-mode toggle %s", s.ID) + if isActive { + btnText = e.i18n.T(MsgCardTitleCurrentSession) + btnType = "primary" + action = fmt.Sprintf("act:/delete-mode noop %s", s.ID) + } else if isSelected { btnText = e.i18n.T(MsgDeleteModeSelected) btnType = "primary" } @@ -4623,10 +4640,10 @@ func (e *Engine) renderDeleteModeSelectCard(sessionKey string, sessions *Session e.i18n.Tf(MsgListItem, marker, i+1, e.deleteSessionDisplayName(sessions, &s), s.MessageCount, s.ModifiedAt.Format("01-02 15:04")), btnText, btnType, - fmt.Sprintf("act:/delete-mode toggle %s", s.ID), + action, ) } - cb.Note(e.i18n.Tf(MsgDeleteModeSelectedCount, len(dm.selectedIDs))) + cb.Note(e.i18n.Tf(MsgDeleteModeSelectedCount, selectedCount)) if dm.hint != "" { cb.Note(dm.hint) } @@ -4739,11 +4756,34 @@ func (e *Engine) executeDeleteModeAction(sessionKey, args string) { dm.result = strings.Join(lines, "\n") dm.hint = "" dm.phase = "result" + case "form-submit": + dm.selectedIDs = parseDeleteModeSelectedIDs(fields[1:]) + if len(dm.selectedIDs) == 0 { + dm.phase = "select" + dm.hint = e.i18n.T(MsgDeleteModeEmptySelection) + return + } + dm.phase = "confirm" + dm.hint = "" case "cancel": state.deleteMode = nil } } +func parseDeleteModeSelectedIDs(args []string) map[string]struct{} { + ids := make(map[string]struct{}) + for _, arg := range args { + for _, id := range strings.Split(arg, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + ids[id] = struct{}{} + } + } + return ids +} + func (e *Engine) submitDeleteModeSelection(sessionKey string, dm *deleteModeState) []string { agent, _ := e.sessionContextForKey(sessionKey) deleter, ok := agent.(SessionDeleter) diff --git a/core/engine_test.go b/core/engine_test.go index f20fb8a..d1621e2 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -24,11 +24,11 @@ func (a *stubAgent) Stop() error type stubAgentSession struct{} func (s *stubAgentSession) Send(_ string, _ []ImageAttachment, _ []FileAttachment) error { return nil } -func (s *stubAgentSession) RespondPermission(_ string, _ PermissionResult) error { return nil } -func (s *stubAgentSession) Events() <-chan Event { return make(chan Event) } -func (s *stubAgentSession) CurrentSessionID() string { return "stub-session" } -func (s *stubAgentSession) Alive() bool { return true } -func (s *stubAgentSession) Close() error { return nil } +func (s *stubAgentSession) RespondPermission(_ string, _ PermissionResult) error { return nil } +func (s *stubAgentSession) Events() <-chan Event { return make(chan Event) } +func (s *stubAgentSession) CurrentSessionID() string { return "stub-session" } +func (s *stubAgentSession) Alive() bool { return true } +func (s *stubAgentSession) Close() error { return nil } type recordingAgentSession struct { stubAgentSession @@ -1071,7 +1071,7 @@ func TestCmdDelete_NoArgsOnCardPlatformShowsDeleteModeCard(t *testing.T) { } } -func TestDeleteMode_ToggleSelectionUpdatesCard(t *testing.T) { +func TestDeleteMode_ToggleSelectionDoesNotReturnCardButPersistsSelection(t *testing.T) { p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{ {ID: "session-1", Summary: "One"}, @@ -1082,19 +1082,16 @@ func TestDeleteMode_ToggleSelectionUpdatesCard(t *testing.T) { e.cmdDelete(p, msg, nil) card := e.handleCardNav("act:/delete-mode toggle session-2", msg.SessionKey) - if card == nil { - t.Fatal("expected delete mode card after toggle") + if card != nil { + t.Fatal("expected no card update after toggle") } - btn, ok := findCardAction(card, "act:/delete-mode toggle session-2") - if !ok { - t.Fatal("expected toggle action for selected session") + confirmCard := e.handleCardNav("act:/delete-mode confirm", msg.SessionKey) + if confirmCard == nil { + t.Fatal("expected confirmation card") } - if btn.Type != "primary" { - t.Fatalf("selected toggle type = %q, want primary", btn.Type) - } - if !strings.Contains(card.RenderText(), "1 selected") { - t.Fatalf("card text = %q, want selected count", card.RenderText()) + if !strings.Contains(confirmCard.RenderText(), "Two") { + t.Fatalf("confirmation text = %q, want selected session", confirmCard.RenderText()) } } @@ -1259,6 +1256,70 @@ func TestDeleteMode_SubmitBlocksActiveSession(t *testing.T) { } } +func TestDeleteMode_ActiveSessionMarkedWithArrowAndNotSelectable(t *testing.T) { + p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} + agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{ + {ID: "session-1", Summary: "One"}, + {ID: "session-2", Summary: "Two"}, + }}} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"} + e.sessions.GetOrCreateActive(msg.SessionKey).AgentSessionID = "session-1" + + e.cmdDelete(p, msg, nil) + if len(p.repliedCards) != 1 { + t.Fatalf("replied cards = %d, want 1", len(p.repliedCards)) + } + card := p.repliedCards[0] + if _, ok := findCardAction(card, "act:/delete-mode toggle session-1"); ok { + t.Fatal("active session should not be toggle-selectable") + } + if _, ok := findCardAction(card, "act:/delete-mode noop session-1"); !ok { + t.Fatal("expected noop action for active session") + } + if got := countCardActionValues(card, "act:/delete-mode toggle "); got != 1 { + t.Fatalf("toggle action count = %d, want 1", got) + } + if !strings.Contains(card.RenderText(), "▶ **1.**") { + t.Fatalf("card text = %q, want arrow marker for active session", card.RenderText()) + } +} + +func TestDeleteMode_FormSubmitShowsConfirmThenDeletes(t *testing.T) { + p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} + agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{ + {ID: "session-1", Summary: "One"}, + {ID: "session-2", Summary: "Two"}, + {ID: "session-3", Summary: "Three"}, + }}} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"} + + e.cmdDelete(p, msg, nil) + confirmCard := e.handleCardNav("act:/delete-mode form-submit session-1,session-3", msg.SessionKey) + if confirmCard == nil { + t.Fatal("expected confirm card after form-submit") + } + if len(agent.deleted) != 0 { + t.Fatalf("deleted = %v, want none before confirm", agent.deleted) + } + confirmText := confirmCard.RenderText() + if !strings.Contains(confirmText, "One") || !strings.Contains(confirmText, "Three") { + t.Fatalf("confirm text = %q, want selected sessions", confirmText) + } + + resultCard := e.handleCardNav("act:/delete-mode submit", msg.SessionKey) + if resultCard == nil { + t.Fatal("expected result card after submit") + } + if got, want := strings.Join(agent.deleted, ","), "session-1,session-3"; got != want { + t.Fatalf("deleted = %q, want %q", got, want) + } + if !strings.Contains(resultCard.RenderText(), "Session deleted: One") { + t.Fatalf("result text = %q, want delete result", resultCard.RenderText()) + } +} + func TestExecuteCardActionStop_PreservesQuietStateWithoutCleanupReinsert(t *testing.T) { e := newTestEngine() e.interactiveMu.Lock() diff --git a/platform/feishu/card.go b/platform/feishu/card.go index 78fbcea..95a3d8a 100644 --- a/platform/feishu/card.go +++ b/platform/feishu/card.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log/slog" + "regexp" + "strings" "github.com/chenhg5/cc-connect/core" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" @@ -89,6 +91,9 @@ func renderCardMap(card *core.Card) map[string]any { "template": color, } } + if transformed, ok := renderDeleteModeCheckerCard(card, result); ok { + return transformed + } var elements []map[string]any for _, elem := range card.Elements { @@ -228,6 +233,219 @@ func renderCardMap(card *core.Card) map[string]any { 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 + } + + rows := make([]deleteModeCheckerRow, 0) + formRowElements := make([]map[string]any, 0) + notes := make([]string, 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, "☑"), + } + rows = append(rows, row) + 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.Text) + 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 == "" { + continue + } + if isDeleteModeSelectedCountNote(n) { + continue + } + elements = append(elements, map[string]any{ + "tag": "note", + "elements": []map[string]any{plainText(n)}, + }) + } + 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 + } +} + +func isDeleteModeSelectedCountNote(text string) bool { + t := strings.TrimSpace(text) + return regexpDeleteModeSelectedCountEn.MatchString(t) || + regexpDeleteModeSelectedCountZh.MatchString(t) || + regexpDeleteModeSelectedCountZhTW.MatchString(t) || + regexpDeleteModeSelectedCountJa.MatchString(t) || + regexpDeleteModeSelectedCountEs.MatchString(t) +} + +var ( + regexpDeleteModeSelectedCountEn = regexp.MustCompile(`^\d+\s+selected$`) + regexpDeleteModeSelectedCountZh = regexp.MustCompile(`^已选\s+\d+\s+项$`) + regexpDeleteModeSelectedCountZhTW = regexp.MustCompile(`^已選\s+\d+\s+項$`) + regexpDeleteModeSelectedCountJa = regexp.MustCompile(`^\d+\s+件を選択中$`) + regexpDeleteModeSelectedCountEs = regexp.MustCompile(`^\d+\s+seleccionadas$`) +) + // renderCard converts a core.Card into the Feishu Interactive Card JSON string. func renderCard(card *core.Card) string { b, err := json.Marshal(renderCardMap(card)) diff --git a/platform/feishu/card_test.go b/platform/feishu/card_test.go index ab59bb6..028fcca 100644 --- a/platform/feishu/card_test.go +++ b/platform/feishu/card_test.go @@ -2,6 +2,7 @@ package feishu import ( "encoding/json" + "strings" "testing" "github.com/chenhg5/cc-connect/core" @@ -168,3 +169,61 @@ func TestRenderCardMap_DefaultActionsStayActionRow(t *testing.T) { } } } + +func TestRenderCardMap_DeleteModeUsesCheckerForm(t *testing.T) { + card := core.NewCard(). + Title("删除会话", "carmine"). + ListItemBtn("☑ **1.** One · **10** msgs · 03-13 20:00", "已选择", "primary", "act:/delete-mode toggle session-1"). + ListItemBtn("▶ **2.** Active · **30** msgs · 03-13 20:01", "当前会话", "primary", "act:/delete-mode noop session-2"). + ListItemBtn("◻ **3.** Three · **20** msgs · 03-13 20:02", "选择", "default", "act:/delete-mode toggle session-3"). + Note("2 selected"). + Buttons( + core.DangerBtn("删除已选", "act:/delete-mode confirm"), + core.DefaultBtn("取消", "act:/delete-mode cancel"), + ). + Buttons(core.DefaultBtn("下一页 →", "act:/delete-mode page 2")). + Build() + + got := decodeRenderedCard(t, card) + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal rendered card failed: %v", err) + } + s := string(raw) + if !strings.Contains(s, `"tag":"form"`) || !strings.Contains(s, `"tag":"checker"`) { + t.Fatalf("expected form+checker rendering, got %s", s) + } + if got := strings.Count(s, `"tag":"checker"`); got != 2 { + t.Fatalf("checker count = %d, want 2, got %s", got, s) + } + if !strings.Contains(s, deleteModeCheckerName("session-1")) { + t.Fatalf("selectable session checker missing, got %s", s) + } + if strings.Contains(s, deleteModeCheckerName("session-2")) { + t.Fatalf("active session should not render checker, got %s", s) + } + if !strings.Contains(s, deleteModeCheckerName("session-3")) { + t.Fatalf("second selectable session checker missing, got %s", s) + } + activeIdx := strings.Index(s, `▶ **2.** Active`) + firstIdx := strings.Index(s, deleteModeCheckerName("session-1")) + thirdIdx := strings.Index(s, deleteModeCheckerName("session-3")) + if activeIdx < 0 || firstIdx < 0 || thirdIdx < 0 { + t.Fatalf("missing expected order markers in rendered card: %s", s) + } + if !(firstIdx < activeIdx && activeIdx < thirdIdx) { + t.Fatalf("row order changed unexpectedly, got %s", s) + } + if !strings.Contains(s, `"name":"delete_mode_form"`) { + t.Fatalf("expected form name for feishu validation, got %s", s) + } + if !strings.Contains(s, `"name":"delete_mode_submit"`) || !strings.Contains(s, `"name":"delete_mode_cancel"`) { + t.Fatalf("expected button names inside form, got %s", s) + } + if !strings.Contains(s, `"form_action_type":"submit"`) || !strings.Contains(s, `act:/delete-mode form-submit`) { + t.Fatalf("expected form submit action, got %s", s) + } + if strings.Contains(s, `act:/delete-mode toggle`) { + t.Fatalf("expected no toggle buttons in rendered card, got %s", s) + } +} diff --git a/platform/feishu/delete_mode_form.go b/platform/feishu/delete_mode_form.go new file mode 100644 index 0000000..d81da91 --- /dev/null +++ b/platform/feishu/delete_mode_form.go @@ -0,0 +1,67 @@ +package feishu + +import ( + "encoding/hex" + "sort" + "strings" +) + +const deleteModeCheckerNamePrefix = "delete_sel_" + +func deleteModeCheckerName(sessionID string) string { + return deleteModeCheckerNamePrefix + hex.EncodeToString([]byte(sessionID)) +} + +func parseDeleteModeCheckerName(name string) (string, bool) { + if !strings.HasPrefix(name, deleteModeCheckerNamePrefix) { + return "", false + } + raw := strings.TrimPrefix(name, deleteModeCheckerNamePrefix) + if raw == "" { + return "", false + } + b, err := hex.DecodeString(raw) + if err != nil { + return "", false + } + return string(b), true +} + +func collectDeleteModeSelectedFromFormValue(formValue map[string]any) []string { + if len(formValue) == 0 { + return nil + } + ids := make([]string, 0, len(formValue)) + seen := make(map[string]struct{}, len(formValue)) + for key, val := range formValue { + sessionID, ok := parseDeleteModeCheckerName(key) + if !ok || !isTruthyFormValue(val) { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + seen[sessionID] = struct{}{} + ids = append(ids, sessionID) + } + sort.Strings(ids) + return ids +} + +func isTruthyFormValue(v any) bool { + switch x := v.(type) { + case bool: + return x + case string: + s := strings.ToLower(strings.TrimSpace(x)) + return s == "true" || s == "1" || s == "yes" || s == "on" + case float64: + return x != 0 + case int: + return x != 0 + case int64: + return x != 0 + default: + return false + } +} diff --git a/platform/feishu/feishu.go b/platform/feishu/feishu.go index be20c3e..f887ebd 100644 --- a/platform/feishu/feishu.go +++ b/platform/feishu/feishu.go @@ -206,6 +206,20 @@ func (p *Platform) onCardAction(event *callback.CardActionTriggerEvent) (*callba if actionVal == "" && event.Event.Action.Option != "" { actionVal = event.Event.Action.Option } + if actionVal == "" { + switch event.Event.Action.Name { + case "delete_mode_submit": + actionVal = "act:/delete-mode form-submit" + case "delete_mode_cancel": + actionVal = "act:/delete-mode cancel" + } + } + if actionVal == "act:/delete-mode form-submit" { + ids := collectDeleteModeSelectedFromFormValue(event.Event.Action.FormValue) + if len(ids) > 0 { + actionVal += " " + strings.Join(ids, ",") + } + } userID := "" if event.Event.Operator != nil { @@ -235,6 +249,18 @@ func (p *Platform) onCardAction(event *callback.CardActionTriggerEvent) (*callba }, nil } } + if strings.HasPrefix(actionVal, "act:") { + if strings.HasPrefix(actionVal, "act:/delete-mode toggle ") { + return &callback.CardActionTriggerResponse{ + Toast: &callback.Toast{ + Type: "info", + Content: "已记录选择(Selection recorded)", + }, + }, nil + } + slog.Debug(p.tag()+": card action produced no card update", "action", actionVal) + return nil, nil + } slog.Warn(p.tag()+": card nav returned nil, ignoring", "action", actionVal) return nil, nil } diff --git a/platform/feishu/platform_test.go b/platform/feishu/platform_test.go index 5121749..4097cec 100644 --- a/platform/feishu/platform_test.go +++ b/platform/feishu/platform_test.go @@ -1,6 +1,8 @@ package feishu import ( + "bytes" + "log/slog" "strconv" "strings" "sync" @@ -152,6 +154,176 @@ func TestInteractivePlatform_CardActionPassesCardSenderToHandler(t *testing.T) { } } +func TestInteractivePlatform_CardActionActWithoutCardResponseDoesNotWarn(t *testing.T) { + platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + ip, ok := platformAny.(*interactivePlatform) + if !ok { + t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) + } + ip.cardNavHandler = func(action string, sessionKey string) *core.Card { + return nil + } + + var buf bytes.Buffer + orig := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(orig) }) + + resp, err := ip.onCardAction(&callback.CardActionTriggerEvent{ + Event: &callback.CardActionTriggerRequest{ + Operator: &callback.Operator{OpenID: "ou_test_user"}, + Action: &callback.CallBackAction{Value: map[string]any{"action": "act:/delete-mode toggle session-1"}}, + Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, + }, + }) + if err != nil { + t.Fatalf("onCardAction() error = %v", err) + } + if resp == nil || resp.Toast == nil { + t.Fatalf("expected toast response for silent toggle, got %#v", resp) + } + if resp.Card != nil { + t.Fatalf("expected no card update on toggle, got %#v", resp.Card) + } + + logs := buf.String() + if strings.Contains(logs, "level=WARN") && strings.Contains(logs, "card nav returned nil, ignoring") { + t.Fatalf("unexpected warning logs: %s", logs) + } +} + +func TestInteractivePlatform_CardActionFormSubmitPassesSelectedIDs(t *testing.T) { + platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + ip, ok := platformAny.(*interactivePlatform) + if !ok { + t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) + } + + actionCh := make(chan string, 1) + ip.cardNavHandler = func(action string, sessionKey string) *core.Card { + actionCh <- action + return core.NewCard().Markdown("ok").Build() + } + + _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ + Event: &callback.CardActionTriggerRequest{ + Operator: &callback.Operator{OpenID: "ou_test_user"}, + Action: &callback.CallBackAction{ + Value: map[string]any{"action": "act:/delete-mode form-submit"}, + FormValue: map[string]any{ + deleteModeCheckerName("session-2"): true, + deleteModeCheckerName("session-1"): true, + deleteModeCheckerName("session-3"): false, + }, + }, + Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, + }, + }) + if err != nil { + t.Fatalf("onCardAction() error = %v", err) + } + + select { + case got := <-actionCh: + want := "act:/delete-mode form-submit session-1,session-2" + if got != want { + t.Fatalf("action = %q, want %q", got, want) + } + case <-time.After(2 * time.Second): + t.Fatal("expected card nav handler invocation") + } +} + +func TestInteractivePlatform_CardActionFormSubmitUsesActionNameFallback(t *testing.T) { + platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + ip, ok := platformAny.(*interactivePlatform) + if !ok { + t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) + } + + actionCh := make(chan string, 1) + ip.cardNavHandler = func(action string, sessionKey string) *core.Card { + actionCh <- action + return core.NewCard().Markdown("ok").Build() + } + + _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ + Event: &callback.CardActionTriggerRequest{ + Operator: &callback.Operator{OpenID: "ou_test_user"}, + Action: &callback.CallBackAction{ + Name: "delete_mode_submit", + FormValue: map[string]any{ + deleteModeCheckerName("session-2"): true, + deleteModeCheckerName("session-1"): true, + }, + }, + Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, + }, + }) + if err != nil { + t.Fatalf("onCardAction() error = %v", err) + } + + select { + case got := <-actionCh: + want := "act:/delete-mode form-submit session-1,session-2" + if got != want { + t.Fatalf("action = %q, want %q", got, want) + } + case <-time.After(2 * time.Second): + t.Fatal("expected card nav handler invocation") + } +} + +func TestInteractivePlatform_CardActionFormCancelUsesActionNameFallback(t *testing.T) { + platformAny, err := New(map[string]any{"app_id": "cli_xxx", "app_secret": "secret", "enable_feishu_card": true}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + ip, ok := platformAny.(*interactivePlatform) + if !ok { + t.Fatalf("platform type = %T, want *interactivePlatform", platformAny) + } + + actionCh := make(chan string, 1) + ip.cardNavHandler = func(action string, sessionKey string) *core.Card { + actionCh <- action + return core.NewCard().Markdown("ok").Build() + } + + _, err = ip.onCardAction(&callback.CardActionTriggerEvent{ + Event: &callback.CardActionTriggerRequest{ + Operator: &callback.Operator{OpenID: "ou_test_user"}, + Action: &callback.CallBackAction{ + Name: "delete_mode_cancel", + }, + Context: &callback.Context{OpenChatID: "oc_test_chat", OpenMessageID: "om_test_message"}, + }, + }) + if err != nil { + t.Fatalf("onCardAction() error = %v", err) + } + + select { + case got := <-actionCh: + want := "act:/delete-mode cancel" + if got != want { + t.Fatalf("action = %q, want %q", got, want) + } + case <-time.After(2 * time.Second): + t.Fatal("expected card nav handler invocation") + } +} + func TestNewLark_PlatformNameAndDomain(t *testing.T) { p, err := newPlatform("lark", lark.LarkBaseUrl, map[string]any{ "app_id": "cli_xxx", "app_secret": "secret",