mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
Merge pull request #106 from q107580018/codex/fix-please-oh-need
feat(feishu): improve /delete card multi-select flow
This commit is contained in:
9 files changed
+685
-35
No files matched your search
@@ -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 ./...
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user