Merge pull request #106 from q107580018/codex/fix-please-oh-need

feat(feishu): improve /delete card multi-select flow
This commit is contained in:
cg33 authored and GitHub committed 2026-03-14 08:06:57 +08:00
commit dc5cc3d0df
9 files changed
+685 -35

No files matched your search

+5
View File
@@ -26,6 +26,11 @@ jobs:
GOPROXY: https://proxy.golang.org,direct
GOSUMDB: sum.golang.org
- name: Lint GitHub workflows
run: |
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.8
"$(go env GOPATH)/bin/actionlint" -color
- name: Build
run: go build ./...
+10 -8
View File
@@ -29,14 +29,16 @@ jobs:
let comment;
if (isFirstIssue) {
comment = `👋 Hi @${author}! Thanks for opening your first issue here!
We'll review it as soon as possible. While waiting, please make sure:
- You've provided enough details about the issue
- Any error messages are included in full
- For feature requests, describe your use case
Thanks for your feedback!`;
comment = [
`👋 Hi @${author}! Thanks for opening your first issue here!`,
``,
`We'll review it as soon as possible. While waiting, please make sure:`,
`- You've provided enough details about the issue`,
`- Any error messages are included in full`,
`- For feature requests, describe your use case`,
``,
`Thanks for your feedback!`,
].join('\n');
} else {
comment = `👋 Thanks @${author} for opening this issue! We'll take a look soon.`;
}
+51 -11
View File
@@ -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
@@ -4353,6 +4353,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)
}
@@ -4645,18 +4650,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"
}
@@ -4664,10 +4681,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)
}
@@ -4780,11 +4797,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)
+77 -16
View File
@@ -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()
+218
View File
@@ -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))
+59
View File
@@ -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)
}
}
+67
View File
@@ -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
}
}
+26
View File
@@ -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
}
+172
View File
@@ -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",