Compare commits

...

10 Commits

Author SHA1 Message Date
zhangheng.023
a7ccd4e636 feat: align im feed shortcut commands with latest oapi 2026-06-12 15:55:33 +08:00
shifengjuan-dev
e53f9d999e feat(im): add --chat-modes filter to chat search (#1317)
Add a server-side --chat-modes filter to the im +chat-search shortcut so
users can restrict results to regular groups and/or topic groups.

Change-Id: Ia59c2c05fb2e8e45bd741c8531ca0e3ca69de2f3
2026-06-11 16:54:27 +08:00
shifengjuan-dev
ae35b35693 docs(im): document chat.user_setting batch_query/batch_update (#1339)
Add the chat.user_setting resource 

Change-Id: Ifdd163bfa1cdbfcb56cbf12a3f52e40b61d85e2d
2026-06-11 16:52:05 +08:00
fangshuyu-768
c2e617fc96 docs(skills): expand cite user guidance and fix typos (#1394) 2026-06-11 16:40:39 +08:00
liuxinyanglxy
3f77eded9d feat: per-resource subscription identity + Match hook (#1185)
Framework support for resource-scoped event subscriptions, so one
EventKey can fan out into independent per-resource subscription scopes:

- KeyDefinition gains SubscriptionKey / NormalizeParams / Match hooks
- ComputeSubscriptionID derives a dedup identity from (EventKey, sub-key
  params); plumbed through bus Hub, consume loop, and the
  Hello / PreShutdownCheck / ConsumerInfo protocol messages
- add a synchronous Match filter stage before Process
- change PreConsume cleanup to func() error and surface cleanup
  (unsubscribe) failures as WARN with an idempotency note
- adapt minutes/vc/whiteboard PreConsume to the new cleanup signature
- render SubscriptionID / SubscriptionKey in event status & schema output

No domain wires these hooks yet; covered by unit tests using bus/protocol
doubles. (Mail, the original exerciser, is intentionally not included.)

Change-Id: Ifc743f1aa0bc4dff0c8a1e35da24883694fe7699
2026-06-11 16:22:04 +08:00
shifengjuan-dev
e64610f6d2 docs(im): document chat.managers and chat.moderation API resources (#1294)
Add SKILL.md entries for the group manager and group moderation
(speaking-permission) API-meta resources:
- chat.managers.add_managers / delete_managers (指定/删除群管理员)
- chat.moderation.get / update (查询/更新群发言权限)
2026-06-11 15:12:21 +08:00
raistlin042
dfa26c38f6 feat: exclude .git directory from apps +html-publish package (#1396)
* feat: exclude .git from html-publish package walk

* docs: note .git auto-exclusion in html-publish reference

* test: update html-publish e2e for .git exclusion

* docs: simplify .git skip comment in html-publish walker
2026-06-11 14:58:58 +08:00
evandance
154ecdb90f feat(wiki): emit typed error envelopes across the wiki domain (#1350)
Emit structured validation, API, network, file, and internal error envelopes for Wiki shortcuts so users and agents can recover from failed wiki workflows using stable type, subtype, param, and code fields.

Add Wiki domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-11 14:02:29 +08:00
syh-cpdsss
483043c88b fix: parsing empty whiteboard (#1391)
Change-Id: I10082f89c36ed77e77e1d016be263e0f7369b7b3
2026-06-11 11:27:38 +08:00
linchao5102
6d8dc402ac fix: support git credential dry-run (#1390)
* fix: support git credential dry-run

* test: cover git credential dry-run output
2026-06-11 01:49:06 +08:00
78 changed files with 2077 additions and 1106 deletions

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
}
}
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 2,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB column header: %s", out)
}
if !strings.Contains(out, "alice") {
t.Errorf("missing alice suffix in SUB column: %s", out)
}
if !strings.Contains(out, "bob") {
t.Errorf("missing bob suffix in SUB column: %s", out)
}
}
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
}
}
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
var buf bytes.Buffer
writeStatusText(&buf, []appStatus{
{
AppID: "cli_RUNNINGXXXXXXXXX",
State: stateRunning,
PID: 1234,
UptimeSec: 60,
Active: 1,
Consumers: []protocol.ConsumerInfo{
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
},
},
})
out := buf.String()
if !strings.Contains(out, "SUB") {
t.Errorf("missing SUB header: %s", out)
}
if !strings.Contains(out, "-") {
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
}
}
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
var buf bytes.Buffer
if err := writeStatusJSON(&buf, []appStatus{

View File

@@ -134,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if len(def.Params) > 0 {
fmt.Fprintf(out, "\nParameters:\n")
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
for _, p := range def.Params {
required := "no"
if p.Required {
required = "yes"
}
subKey := "no"
if p.SubscriptionKey {
subKey = "yes"
}
defaultVal := p.Default
if defaultVal == "" {
defaultVal = "-"
@@ -148,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
if desc == "" {
desc = "-"
}
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
}
w.Flush()

View File

@@ -96,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
{Name: "folders", Description: "filter only"},
},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, false); err != nil {
t.Fatalf("runSchema: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "SUB-KEY") {
t.Errorf("missing SUB-KEY column header in:\n%s", out)
}
// Find the mailbox row and verify "yes" is present
var mailboxRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
mailboxRow = ln
break
}
}
if !strings.Contains(mailboxRow, "yes") {
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
}
// Find the folders row and verify "no" is present
var foldersRow string
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
foldersRow = ln
break
}
}
if !strings.Contains(foldersRow, "no") {
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
}
}
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
const syntheticKey = "test.evt_json"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
eventlib.RegisterKey(eventlib.KeyDefinition{
Key: syntheticKey,
EventType: syntheticKey,
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
})
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, syntheticKey, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
if !strings.Contains(stdout.String(), `"subscription_key"`) {
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `true`) {
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
}
}
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
const syntheticKey = "t.custom.overlay"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
if len(s.Consumers) > 0 {
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
rows := make([][]string, 0, len(s.Consumers))
for _, c := range s.Consumers {
subDisplay := "-"
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
}
rows = append(rows, []string{
fmt.Sprintf("pid=%d", c.PID),
c.EventKey,
subDisplay,
fmt.Sprintf("%d", c.Received),
fmt.Sprintf("%d", c.Dropped),
})

View File

@@ -13,8 +13,8 @@ import (
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
return nil, err
}
return func() {
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -13,8 +13,8 @@ import (
const cleanupTimeout = 5 * time.Second
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
@@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
return nil, err
}
return func() {
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -22,8 +22,8 @@ const cleanupTimeout = 5 * time.Second
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
@@ -44,10 +44,13 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
return nil, err
}
return func() {
return func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
return err
}
return nil
}, nil
}
}

View File

@@ -262,19 +262,23 @@ func (b *Bus) handleConn(conn net.Conn) {
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
subID := hello.SubscriptionID
if subID == "" {
subID = hello.EventKey
}
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
bc.SetLogger(b.logger)
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
firstForKey := b.hub.RegisterAndIsFirst(bc)
bc.SetCheckLastForKey(func(eventKey string) bool {
return b.hub.AcquireCleanupLock(eventKey)
bc.SetCheckLastForKey(func(scope string) bool {
return b.hub.AcquireCleanupLock(scope)
})
bc.SetOnClose(func(c *Conn) {
b.hub.UnregisterAndIsLast(c)
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
b.hub.ReleaseCleanupLock(c.EventKey())
b.hub.ReleaseCleanupLock(c.SubscriptionID())
b.mu.Lock()
delete(b.conns, c)
remaining := len(b.conns)

View File

@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
server, client := net.Pipe()
pipes = append(pipes, server, client)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
bc.SetLogger(logger)
hub.RegisterAndIsFirst(bc)

View File

@@ -29,9 +29,10 @@ type Conn struct {
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
eventKey string
eventTypes []string
subID string
pid int
onClose func(*Conn)
checkLastForKey func(eventKey string) bool
checkLastForKey func(scope string) bool
logger *log.Logger
closed chan struct{}
closeOnce sync.Once
@@ -41,7 +42,7 @@ type Conn struct {
}
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
if reader == nil {
reader = bufio.NewReader(conn)
}
@@ -52,10 +53,20 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
eventKey: eventKey,
eventTypes: eventTypes,
pid: pid,
subID: subID,
closed: make(chan struct{}),
}
}
// SubscriptionID returns the subscription identity. Falls back to EventKey
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
func (c *Conn) SubscriptionID() string {
if c.subID == "" {
return c.eventKey
}
return c.subID
}
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
@@ -132,13 +143,19 @@ func (c *Conn) ReaderLoop() {
}
func (c *Conn) handleControlMessage(msg interface{}) {
switch m := msg.(type) {
switch msg.(type) {
case *protocol.Bye:
c.shutdown()
case *protocol.PreShutdownCheck:
// Use the connection's own authoritative subscription identity rather
// than recomputing from the incoming message: a stale or mismatched
// PreShutdownCheck must not ask about the wrong scope (which would
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
// already falls back to EventKey when its stored subID is empty.
scope := c.SubscriptionID()
lastForKey := true
if c.checkLastForKey != nil {
lastForKey = c.checkLastForKey(m.EventKey)
lastForKey = c.checkLastForKey(scope)
}
ack := protocol.NewPreShutdownAck(lastForKey)
if err := c.writeFrame(ack); err != nil && c.logger != nil {

View File

@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
defer server.Close()
defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
go bc.SenderLoop()
bc.SendCh() <- &protocol.Event{
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
defer client.Close()
det := &serializingDetector{Conn: server}
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
go func() { _, _ = io.Copy(io.Discard, client) }()
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
defer client.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
for i := 0; i < sendChCap; i++ {
if !bc.TrySend(i) {
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
server, client := net.Pipe()
defer server.Close()
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
done := make(chan struct{})
go func() {
@@ -142,3 +142,23 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
t.Fatal("ReaderLoop did not exit on EOF")
}
}
func TestConn_SubscriptionID(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
if got := conn.SubscriptionID(); got != "mail.x:abc" {
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
}
}
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
c1, c2 := net.Pipe()
defer c1.Close()
defer c2.Close()
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
if got := conn.SubscriptionID(); got != "mail.x" {
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
}
}

View File

@@ -63,3 +63,134 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
}
}
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
// subscription_id registers under EventKey (today's behavior preserved).
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Legacy client: no subscription_id field (empty string).
hello := &protocol.Hello{
PID: 9999,
EventKey: "im.message",
EventTypes: []string{"im.message.receive_v1"},
SubscriptionID: "", // legacy: empty, should fallback to EventKey
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
// Read the HelloAck from client side to let handleHello complete.
clientReader := bufio.NewReader(client)
ackLine, err := clientReader.ReadString('\n')
if err != nil {
t.Fatalf("failed to read HelloAck: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s")
}
// Assertions: registered under EventKey (not a qualified subscription ID).
if got := hub.ConnCount(); got != 1 {
t.Errorf("hub.ConnCount = %d, want 1", got)
}
if got := hub.EventKeyCount("im.message"); got != 1 {
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
}
if got := hub.SubCount("im.message"); got != 1 {
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
}
if got := hub.SubCount("im.message:something"); got != 0 {
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
}
if ackLine == "" {
t.Fatal("HelloAck was empty")
}
}
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
// non-empty subscription_id registers under that ID, not EventKey.
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
logger := log.New(io.Discard, "", 0)
hub := NewHub()
b := &Bus{
hub: hub,
logger: logger,
conns: make(map[*Conn]struct{}),
idleTimer: time.NewTimer(30 * time.Second),
shutdownCh: make(chan struct{}, 1),
}
server, client := net.Pipe()
defer server.Close()
defer client.Close()
// Modern client: subscription_id explicitly set.
subscriptionID := "mail.message:alice@example.com"
hello := &protocol.Hello{
PID: 8888,
EventKey: "mail.message",
EventTypes: []string{"mail.message.receive_v1"},
SubscriptionID: subscriptionID, // modern: per-resource subscription
}
br := bufio.NewReader(server)
done := make(chan struct{})
go func() {
b.handleHello(server, br, hello)
close(done)
}()
// Read the HelloAck from client side to let handleHello complete.
clientReader := bufio.NewReader(client)
ackLine, err := clientReader.ReadString('\n')
if err != nil {
t.Fatalf("failed to read HelloAck: %v", err)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("handleHello did not return within 3s")
}
// Assertions: registered under the subscription_id, not bare EventKey.
if got := hub.ConnCount(); got != 1 {
t.Errorf("hub.ConnCount = %d, want 1", got)
}
if got := hub.EventKeyCount("mail.message"); got != 1 {
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
}
if got := hub.SubCount(subscriptionID); got != 1 {
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
}
if got := hub.SubCount("mail.message"); got != 0 {
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
}
if ackLine == "" {
t.Fatal("HelloAck was empty")
}
}

View File

@@ -16,6 +16,9 @@ import (
// Subscriber is the interface a connection must satisfy for Hub registration.
type Subscriber interface {
EventKey() string
// SubscriptionID identifies the per-resource subscription for dedup purposes.
// When no resource qualifier is needed it equals EventKey.
SubscriptionID() string
EventTypes() []string
SendCh() chan interface{}
PID() int
@@ -34,8 +37,11 @@ type Subscriber interface {
type Hub struct {
mu sync.RWMutex
subscribers map[Subscriber]struct{}
keyCounts map[string]int
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
// subCounts is keyed by SubscriptionID (not EventKey) so that different
// per-resource subscriptions sharing the same EventKey are deduped independently.
subCounts map[string]int
// cleanupInProgress[subscriptionID] holds a channel closed on release;
// presence means a cleanup lock is held for that subscription.
cleanupInProgress map[string]chan struct{}
logger atomic.Pointer[log.Logger]
}
@@ -43,7 +49,7 @@ type Hub struct {
func NewHub() *Hub {
return &Hub{
subscribers: make(map[Subscriber]struct{}),
keyCounts: make(map[string]int),
subCounts: make(map[string]int),
cleanupInProgress: make(map[string]chan struct{}),
}
}
@@ -51,7 +57,7 @@ func NewHub() *Hub {
// SetLogger attaches a logger (nil tolerated).
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
h.mu.Lock()
defer h.mu.Unlock()
@@ -59,34 +65,35 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
return false
}
delete(h.subscribers, s)
h.keyCounts[s.EventKey()]--
isLast := h.keyCounts[s.EventKey()] == 0
sid := s.SubscriptionID()
h.subCounts[sid]--
isLast := h.subCounts[sid] == 0
if isLast {
delete(h.keyCounts, s.EventKey())
delete(h.subCounts, sid)
}
return isLast
}
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.keyCounts[eventKey] != 1 {
if h.subCounts[subscriptionID] != 1 {
return false
}
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
return false
}
h.cleanupInProgress[eventKey] = make(chan struct{})
h.cleanupInProgress[subscriptionID] = make(chan struct{})
return true
}
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
func (h *Hub) ReleaseCleanupLock(eventKey string) {
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
h.mu.Lock()
ch := h.cleanupInProgress[eventKey]
delete(h.cleanupInProgress, eventKey)
ch := h.cleanupInProgress[subscriptionID]
delete(h.cleanupInProgress, subscriptionID)
h.mu.Unlock()
if ch != nil {
close(ch)
@@ -94,23 +101,24 @@ func (h *Hub) ReleaseCleanupLock(eventKey string) {
}
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
// subscriber for its EventKey. If a cleanup is in progress for
// s.EventKey() (another conn holds the cleanup lock), this waits until
// subscriber for its SubscriptionID. If a cleanup is in progress for
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
// cleanup releases before registering — closing the PreShutdownCheck ×
// Hello TOCTOU race. The wait releases h.mu before blocking on the
// channel, so concurrent operations on other keys aren't stalled.
// channel, so concurrent operations on other subscriptions aren't stalled.
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
sid := s.SubscriptionID()
for {
h.mu.Lock()
ch, locked := h.cleanupInProgress[s.EventKey()]
ch, locked := h.cleanupInProgress[sid]
if locked {
h.mu.Unlock()
<-ch // wait for release, then re-check (defensive against races)
continue
}
isFirst := h.keyCounts[s.EventKey()] == 0
isFirst := h.subCounts[sid] == 0
h.subscribers[s] = struct{}{}
h.keyCounts[s.EventKey()]++
h.subCounts[sid]++
h.mu.Unlock()
return isFirst
}
@@ -176,11 +184,25 @@ func (h *Hub) ConnCount() int {
return len(h.subscribers)
}
// EventKeyCount returns the number of subscribers registered for eventKey.
// EventKeyCount returns total subscribers for the given EventKey, aggregating
// across all SubscriptionIDs. For per-subscription counts use SubCount.
func (h *Hub) EventKeyCount(eventKey string) int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.keyCounts[eventKey]
count := 0
for s := range h.subscribers {
if s.EventKey() == eventKey {
count++
}
}
return count
}
// SubCount returns the count of subscribers for the given SubscriptionID.
func (h *Hub) SubCount(subscriptionID string) int {
h.mu.RLock()
defer h.mu.RUnlock()
return h.subCounts[subscriptionID]
}
// BroadcastSourceStatus fans out a source-level status change to every
@@ -205,10 +227,11 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
for s := range h.subscribers {
result = append(result, protocol.ConsumerInfo{
PID: s.PID(),
EventKey: s.EventKey(),
Received: s.Received(),
Dropped: s.DroppedCount(),
PID: s.PID(),
EventKey: s.EventKey(),
SubscriptionID: s.SubscriptionID(),
Received: s.Received(),
Dropped: s.DroppedCount(),
})
}
return result

View File

@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 10)
h.RegisterAndIsFirst(c)
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
server, client := testNetPipe(t)
defer server.Close()
defer client.Close()
c := NewConn(server, nil, "k", []string{"t"}, 1)
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
c.sendCh = make(chan interface{}, 1)
h.RegisterAndIsFirst(c)

View File

@@ -111,6 +111,7 @@ type alwaysFailSubscriber struct {
}
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *alwaysFailSubscriber) PID() int { return 0 }
@@ -153,6 +154,7 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
}
func (s *raceSubscriber) EventKey() string { return s.eventKey }
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
func (s *raceSubscriber) PID() int { return s.pid }

View File

@@ -5,6 +5,7 @@ package bus
import (
"encoding/json"
"net"
"sync"
"sync/atomic"
"testing"
@@ -235,7 +236,10 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
}
}
func (c *testConn) EventKey() string { return c.eventKey }
func (c *testConn) EventKey() string { return c.eventKey }
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
func (c *testConn) SubscriptionID() string { return c.eventKey }
func (c *testConn) EventTypes() []string { return c.eventTypes }
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
func (c *testConn) PID() int { return c.pid }
@@ -275,3 +279,79 @@ func (c *testConn) TrySend(msg interface{}) bool {
return false
}
}
func TestHub_SubscriptionID_Isolation(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
if !h.RegisterAndIsFirst(s1) {
t.Error("s1 should be first for its subscription")
}
if !h.RegisterAndIsFirst(s2) {
t.Error("s2 should ALSO be first (different SubscriptionID)")
}
if !h.UnregisterAndIsLast(s1) {
t.Error("s1 should be last for mail.x:alice")
}
if !h.UnregisterAndIsLast(s2) {
t.Error("s2 should be last for mail.x:bob")
}
}
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
if !h.RegisterAndIsFirst(s1) {
t.Error("s1 first")
}
if h.RegisterAndIsFirst(s2) {
t.Error("s2 same SubscriptionID should NOT be first")
}
}
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
c2, _ := net.Pipe()
defer c1.Close()
defer c2.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
h.RegisterAndIsFirst(s1)
h.RegisterAndIsFirst(s2)
if got := h.EventKeyCount("mail.x"); got != 2 {
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
}
if got := h.SubCount("mail.x:alice"); got != 1 {
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
}
if got := h.SubCount("mail.x:bob"); got != 1 {
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
}
}
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
h := NewHub()
c1, _ := net.Pipe()
defer c1.Close()
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
h.RegisterAndIsFirst(s1)
consumers := h.Consumers()
if len(consumers) != 1 {
t.Fatalf("got %d consumers, want 1", len(consumers))
}
if consumers[0].SubscriptionID != "mail.x:alice" {
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
}
}

View File

@@ -61,6 +61,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
}
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
// SubscriptionID we send to bus reflects canonical values.
if keyDef.NormalizeParams != nil {
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
}
}
// Compute subscription identity from normalized params + SubscriptionKey flags.
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
if opts.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
@@ -81,13 +97,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
}
defer conn.Close()
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
if err != nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus handshake failed: %s", err).WithCause(err)
}
var cleanup func()
var cleanup func() error
if ack.FirstForKey && keyDef.PreConsume != nil {
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
@@ -113,14 +129,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if cleanup != nil {
switch {
case r != nil:
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
cleanup()
fmt.Fprintf(errOut,
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
opts.EventKey)
if cleanupErr := cleanup(); cleanupErr != nil {
fmt.Fprintf(errOut,
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
}
case lastForKey:
if !opts.Quiet {
fmt.Fprintf(errOut, "[event] running cleanup...\n")
}
cleanup()
if !opts.Quiet {
if cleanupErr := cleanup(); cleanupErr != nil {
fmt.Fprintf(errOut,
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
cleanupErr)
} else if !opts.Quiet {
fmt.Fprintf(errOut, "[event] cleanup done.\n")
}
}
@@ -144,7 +168,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
writeReadyMarker(errOut, opts)
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
}
func truncateDuration(d time.Duration) time.Duration {

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"net"
"strings"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
"github.com/larksuite/cli/internal/event/transport"
)
// fakeRT is a minimal event.APIClient mock.
type fakeRT struct {
err error
}
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
return nil, f.err
}
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
// bus is contacted, yet the production error-wrapping is exercised — if Run()
// ever stops wrapping, this test fails.
const key = "test.evt_normalize_fail"
event.RegisterKey(event.KeyDefinition{
Key: key,
EventType: key,
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
return errors.New("simulated normalize failure")
},
})
defer event.UnregisterKeyForTest(key)
err := Run(context.Background(), transport.New(), "app", "", "", Options{
EventKey: key,
Runtime: &fakeRT{},
Quiet: true,
})
if err == nil {
t.Fatal("expected Run to fail when NormalizeParams errors")
}
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
t.Errorf("error not wrapped with EventKey prefix: %v", err)
}
if !strings.Contains(err.Error(), "simulated normalize failure") {
t.Errorf("underlying error not propagated: %v", err)
}
}
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
// Server-side: read Hello, decode, assert SubscriptionID, send ack
done := make(chan string, 1)
go func() {
br := bufio.NewReader(b)
line, err := protocol.ReadFrame(br)
if err != nil {
done <- "READ_ERR:" + err.Error()
return
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
done <- "DECODE_ERR:" + err.Error()
return
}
if hello, ok := msg.(*protocol.Hello); ok {
done <- hello.SubscriptionID
// send ack so client can return
ack := protocol.NewHelloAck("v1", true)
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
} else {
done <- "WRONG_TYPE"
}
}()
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
if err != nil {
t.Fatalf("doHello error: %v", err)
}
if ack == nil {
t.Fatal("got nil ack")
}
got := <-done
if got != "mail.x:alice" {
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"sort"
"github.com/larksuite/cli/internal/event"
)
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
//
// Stability contract: same EventKey + same normalized param values -> same ID
// across CLI versions; changing the encoding requires a wire-format bump.
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
type kv struct {
Name string `json:"name"`
Value string `json:"value"`
}
var subParams []kv
for _, p := range def.Params {
if !p.SubscriptionKey {
continue
}
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
}
if len(subParams) == 0 {
return def.Key
}
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
sum := sha256.Sum256(raw)
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package consume
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/event"
)
func TestComputeSubscriptionID(t *testing.T) {
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
def := &event.KeyDefinition{Key: "test.evt"}
marked := make(map[string]bool, len(subKeyNames))
for _, n := range subKeyNames {
marked[n] = true
}
for _, n := range []string{"alpha", "beta", "gamma"} {
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
}
return def
}
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
def := makeDef()
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
if got != "test.evt" {
t.Errorf("got %q, want %q", got, "test.evt")
}
})
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
def := makeDef("alpha")
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
if id1 != id2 {
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
}
})
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
def := makeDef("alpha")
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
if id1 == id2 {
t.Errorf("different values produced same ID: %q", id1)
}
})
}
func TestComputeSubscriptionID_Stability(t *testing.T) {
// Param order in the ParamDef list must not affect the result (sorted by name internally).
def1 := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{
{Name: "b", SubscriptionKey: true},
{Name: "a", SubscriptionKey: true},
},
}
def2 := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{
{Name: "a", SubscriptionKey: true},
{Name: "b", SubscriptionKey: true},
},
}
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
if id1 != id2 {
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
}
}
func TestComputeSubscriptionID_Format(t *testing.T) {
def := &event.KeyDefinition{
Key: "mail.user_mailbox.event.message_received_v1",
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
}
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
prefix := "mail.user_mailbox.event.message_received_v1:"
if !strings.HasPrefix(id, prefix) {
t.Fatalf("missing prefix: %q", id)
}
suffix := strings.TrimPrefix(id, prefix)
if len(suffix) != 16 {
t.Errorf("fingerprint length = %d, want 16", len(suffix))
}
for _, c := range suffix {
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
if !isValid {
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
break
}
}
}
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
def := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
}
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
id := ComputeSubscriptionID(def, map[string]string{"value": val})
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
}
}
}
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
def := &event.KeyDefinition{
Key: "test.evt",
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
}
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
if id1 != id2 {
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
}
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
if id1 == id3 {
t.Errorf("empty and nonempty produced same ID: %q", id1)
}
}

View File

@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
// buffered with the ack in one TCP segment aren't dropped.
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
return nil, nil, err
}

View File

@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
start := time.Now()
done := make(chan error, 1)
go func() {
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
done <- err
}()

View File

@@ -22,7 +22,7 @@ import (
)
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
close(stopReader)
<-readerDone
conn.SetReadDeadline(time.Time{})
*lastForKey = checkLastForKey(conn, opts.EventKey)
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
conn.Close()
case <-allDone:
// bus-side close; can't query, assume last
@@ -199,13 +199,19 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
// Synchronous Match filter runs before any work (Process / sink write).
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
return false, nil
}
var result json.RawMessage
if keyDef.Process != nil {
raw := &event.RawEvent{
EventType: evt.EventType,
Payload: evt.Payload,
}
var err error
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
if err != nil {

View File

@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
if err != nil {
t.Fatalf("consumeLoop: %v", err)
}
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 2 {
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
t.Fatalf("consumeLoop: %v", err)
}
if got := emitted.Load(); got != 1 {
@@ -196,12 +196,96 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
var lastForKey bool
var emitted atomic.Int64
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
if err == nil {
t.Fatal("consumeLoop should fail immediately on bad jq expression")
}
}
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
type captureSink struct {
written []json.RawMessage
}
func (s *captureSink) Write(data json.RawMessage) error {
s.written = append(s.written, data)
return nil
}
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
calledProcess := false
keyDef := &event.KeyDefinition{
Key: "test.evt",
Match: func(raw *event.RawEvent, params map[string]string) bool {
return false
},
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
calledProcess = true
return json.RawMessage(`{}`), nil
},
}
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
Options{}, sink, nil)
if err != nil {
t.Fatal(err)
}
if wrote {
t.Error("Match returned false but event was written")
}
if calledProcess {
t.Error("Process was called even though Match returned false")
}
if len(sink.written) != 0 {
t.Errorf("sink received %d events, want 0", len(sink.written))
}
}
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
Options{}, sink, nil)
if err != nil || !wrote {
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
}
if len(sink.written) != 1 {
t.Errorf("sink received %d events, want 1", len(sink.written))
}
}
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
// Record the actual call sequence — a bare call-count check would still
// pass if Process ran before Match.
var order []string
keyDef := &event.KeyDefinition{
Key: "test.evt",
Match: func(raw *event.RawEvent, params map[string]string) bool {
order = append(order, "match")
return true
},
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
order = append(order, "process")
return raw.Payload, nil
},
}
sink := &captureSink{}
wrote, err := processAndOutput(context.Background(), keyDef,
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
Options{}, sink, nil)
if err != nil {
t.Fatal(err)
}
if !wrote {
t.Error("expected wrote=true")
}
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
t.Errorf("call order = %v, want [match process]", order)
}
}
func TestIsTerminalSinkError(t *testing.T) {
for _, tc := range []struct {
name string

View File

@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
func checkLastForKey(conn net.Conn, eventKey string) bool {
msg := protocol.NewPreShutdownCheck(eventKey)
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
return true
}

View File

@@ -4,6 +4,8 @@
package consume
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net"
@@ -38,7 +40,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
}
}()
got := checkLastForKey(client, "im.msg")
got := checkLastForKey(client, "im.msg", "")
if got != false {
t.Errorf("checkLastForKey = %v, want false", got)
}
@@ -62,7 +64,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
_ = protocol.Encode(server, ack)
}()
got := checkLastForKey(client, "im.msg")
got := checkLastForKey(client, "im.msg", "")
if got != true {
t.Errorf("checkLastForKey = %v, want true", got)
}
@@ -83,7 +85,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
}()
start := time.Now()
got := checkLastForKey(client, "im.msg")
got := checkLastForKey(client, "im.msg", "")
elapsed := time.Since(start)
if got != true {
@@ -93,3 +95,39 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
}
}
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
a, b := net.Pipe()
defer a.Close()
defer b.Close()
done := make(chan string, 1)
go func() {
br := bufio.NewReader(b)
line, err := protocol.ReadFrame(br)
if err != nil {
done <- "READ_ERR"
return
}
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
if err != nil {
done <- "DECODE_ERR"
return
}
check, ok := msg.(*protocol.PreShutdownCheck)
if !ok {
done <- "WRONG_TYPE"
return
}
done <- check.SubscriptionID
// Reply with ack so client returns
ack := protocol.NewPreShutdownAck(true)
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
}()
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
got := <-done
if got != "mail.x:alice" {
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
}
}

View File

@@ -77,3 +77,88 @@ func TestDecodeUnknownType(t *testing.T) {
t.Error("expected error for unknown type")
}
}
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
msg := &Hello{
Type: MsgTypeHello,
PID: 12345,
EventKey: "mail.user_mailbox.event.message_received_v1",
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
Version: "v1",
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
line := buf.Bytes()
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
t.Errorf("subscription_id not serialized: %s", string(line))
}
decoded, err := Decode(bytes.TrimRight(line, "\n"))
if err != nil {
t.Fatal(err)
}
hello, ok := decoded.(*Hello)
if !ok {
t.Fatalf("expected *Hello, got %T", decoded)
}
if hello.SubscriptionID != msg.SubscriptionID {
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
}
}
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
msg := &Hello{
Type: MsgTypeHello,
PID: 1,
EventKey: "k",
EventTypes: []string{"k"},
Version: "v1",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
}
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
hello := decoded.(*Hello)
if hello.SubscriptionID != "" {
t.Errorf("got %q, want empty", hello.SubscriptionID)
}
}
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
msg := &PreShutdownCheck{
Type: MsgTypePreShutdownCheck,
EventKey: "mail.x",
SubscriptionID: "mail.x:abc",
}
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
if err != nil {
t.Fatal(err)
}
got := decoded.(*PreShutdownCheck)
if got.SubscriptionID != msg.SubscriptionID {
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
}
}
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
})
buf := &bytes.Buffer{}
if err := Encode(buf, msg); err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
}
}

View File

@@ -34,11 +34,12 @@ type SourceStatus struct {
}
type Hello struct {
Type string `json:"type"`
PID int `json:"pid"`
EventKey string `json:"event_key"`
EventTypes []string `json:"event_types"`
Version string `json:"version"`
Type string `json:"type"`
PID int `json:"pid"`
EventKey string `json:"event_key"`
EventTypes []string `json:"event_types"`
Version string `json:"version"`
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
}
type HelloAck struct {
@@ -61,10 +62,11 @@ type Bye struct {
Type string `json:"type"`
}
// PreShutdownCheck atomically reserves the cleanup lock for EventKey.
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID).
type PreShutdownCheck struct {
Type string `json:"type"`
EventKey string `json:"event_key"`
Type string `json:"type"`
EventKey string `json:"event_key"`
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
}
type PreShutdownAck struct {
@@ -77,10 +79,11 @@ type StatusQuery struct {
}
type ConsumerInfo struct {
PID int `json:"pid"`
EventKey string `json:"event_key"`
Received int64 `json:"received"`
Dropped int64 `json:"dropped"`
PID int `json:"pid"`
EventKey string `json:"event_key"`
SubscriptionID string `json:"subscription_id,omitempty"`
Received int64 `json:"received"`
Dropped int64 `json:"dropped"`
}
type StatusResponse struct {
@@ -95,13 +98,14 @@ type Shutdown struct {
Type string `json:"type"`
}
func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello {
return &Hello{
Type: MsgTypeHello,
PID: pid,
EventKey: eventKey,
EventTypes: eventTypes,
Version: version,
Type: MsgTypeHello,
PID: pid,
EventKey: eventKey,
EventTypes: eventTypes,
Version: version,
SubscriptionID: subscriptionID,
}
}
@@ -124,8 +128,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra
}
}
func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck {
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID}
}
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {

View File

@@ -17,7 +17,7 @@ import (
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
func TestConstructors_PinTypeField(t *testing.T) {
if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello {
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
}
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
@@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) {
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
t.Errorf("NewEvent mismatch: %+v", got)
}
if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
}
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
@@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
}
}
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{})
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
roundtrip(t, NewStatusQuery(), &StatusQuery{})
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})

View File

@@ -55,6 +55,23 @@ type ParamDef struct {
Default string `json:"default,omitempty"`
Description string `json:"description"`
Values []ParamValue `json:"values,omitempty"`
// SubscriptionKey marks this param as part of the subscription identity.
// Two consumers of the same EventKey but different values for any
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
// (EventKey, SubscriptionID).
//
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
// subscription record by (app, user, event_type) and overwrites it rather
// than reference-counting, so for a non-per-resource API the cleanup of one
// resource's last consumer unsubscribes the shared record and silently cuts
// off every other resource sharing that event_type.
//
// Default false = the param is a filter / formatting / metadata param
// and does not affect subscription identity.
SubscriptionKey bool `json:"subscription_key,omitempty"`
}
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
@@ -83,10 +100,44 @@ type KeyDefinition struct {
Schema SchemaDef `json:"schema"`
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
// PreConsume, Match, and Process. Mutates the params map in place.
// May call OAPI; runs once per consumer at startup.
//
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
// trim whitespace. On error, consume fails (no retry); caller gets the
// wrapped error.
//
// Default nil = no normalization, params pass through unchanged.
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
// Process required when Schema.Custom is Processed output; must be nil when Native is used.
//
// Convention: returning (nil, nil) signals "drop this event" — the
// consumer loop will skip writing it to sink and not advance the
// emitted counter. Useful for async filtering (e.g. fetch metadata,
// drop if folder doesn't match). For sync filters that don't need
// OAPI, use Match instead.
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"`
// Match is a synchronous payload filter run on every received event
// BEFORE Process. Return false to drop the event without further work.
//
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
// calls in Match". For filters that need a metadata fetch first, use
// Process and return nil to drop.
//
// Default nil = accept all events.
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
// is first for that scope. Returns a cleanup function that the framework
// invokes when this consumer is the last for its scope.
//
// The cleanup's error return is honored: on nil the framework prints
// "[event] cleanup done."; on non-nil it prints a WARN with an
// idempotency note.
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
Scopes []string `json:"scopes,omitempty"`

View File

@@ -33,6 +33,7 @@ var migratedCommonHelperPaths = []string{
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -34,6 +34,7 @@ var migratedEnvelopePaths = []string{
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/wiki/",
"shortcuts/im/",
}

View File

@@ -960,6 +960,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
"shortcuts/slides/slides_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.go",
"shortcuts/wiki/wiki_node_get.go",
}
for _, path := range paths {
for _, helper := range helpers {
@@ -1076,6 +1077,23 @@ func boom() {
}
}
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
src := `package migrated
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
f := c.FlagErrorf
_ = f
c.WrapInputStatError(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
if len(v) != 2 {
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact

View File

@@ -11,6 +11,7 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -61,7 +62,15 @@ var AppsGitCredentialInit = common.Shortcut{
return common.NewDryRunAPI().
GET(gitCredentialIssuePath).
Desc("Issue a Miaoda Git repository PAT").
Set("mode", "api-plus-local-setup").
Set("action", "initialize_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("local_effects", []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
}).
Params(gitCredentialIssueParams(appID))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -124,6 +133,21 @@ var AppsGitCredentialRemove = common.Shortcut{
}
return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
Desc("Preview local Git credential cleanup (no API call; would clean up local-only state).").
Set("mode", "local-cleanup-only").
Set("action", "remove_local_git_credential").
Set("app_id", appID).
Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)).
Set("effects", []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",
"delete the local metadata record after cleanup succeeds",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil)
@@ -171,6 +195,17 @@ var AppsGitCredentialList = common.Shortcut{
Scopes: []string{},
AuthTypes: []string{"user"},
HasFormat: true,
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Preview local Git credential listing (no API call, read-only local state).").
Set("mode", "local-read-only").
Set("action", "list_local_git_credentials").
Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)).
Set("reads", []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now)
if err != nil {

View File

@@ -45,6 +45,11 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
Params map[string]interface{} `json:"params"`
Body interface{} `json:"body"`
} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
LocalEffects []string `json:"local_effects"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
@@ -65,6 +70,107 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
if call.Body != nil {
t.Fatalf("body = %#v, want nil", call.Body)
}
if payload.Mode != "api-plus-local-setup" {
t.Fatalf("mode = %q", payload.Mode)
}
if payload.Action != "initialize_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
}
assertStringSliceEqual(t, payload.LocalEffects, []string{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
})
}
func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGitCredentialList,
[]string{"+git-credential-list", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
StorageRoot string `json:"storage_root"`
Reads []string `json:"reads"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." {
t.Fatalf("description = %q", payload.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
}
if payload.Mode != "local-read-only" {
t.Fatalf("mode = %q", payload.Mode)
}
if payload.Action != "list_local_git_credentials" {
t.Fatalf("action = %q", payload.Action)
}
if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) {
t.Fatalf("storage_root = %q", payload.StorageRoot)
}
assertStringSliceEqual(t, payload.Reads, []string{
"scan app-scoped git credential metadata under the CLI config directory",
"derive per-app repository URLs and local credential status from local metadata",
})
}
func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGitCredentialRemove,
[]string{"+git-credential-remove", "--app-id", "app_xxx", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
Description string `json:"description"`
API []interface{} `json:"api"`
Mode string `json:"mode"`
Action string `json:"action"`
AppID string `json:"app_id"`
MetadataFile string `json:"metadata_file"`
Effects []string `json:"effects"`
}
if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." {
t.Fatalf("description = %q", payload.Description)
}
if len(payload.API) != 0 {
t.Fatalf("api len = %d, want 0", len(payload.API))
}
if payload.Mode != "local-cleanup-only" {
t.Fatalf("mode = %q", payload.Mode)
}
if payload.Action != "remove_local_git_credential" {
t.Fatalf("action = %q", payload.Action)
}
if payload.AppID != "app_xxx" {
t.Fatalf("app_id = %q", payload.AppID)
}
if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) {
t.Fatalf("metadata_file = %q", payload.MetadataFile)
}
assertStringSliceEqual(t, payload.Effects, []string{
"read app-scoped git credential metadata",
"remove the saved PAT from the local system credential store",
"remove the app-scoped Git helper from global git config when present",
"delete the local metadata record after cleanup succeeds",
})
}
func TestAppsGitCredentialInitRequiresAppID(t *testing.T) {
@@ -579,6 +685,18 @@ func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) {
}
}
func assertStringSliceEqual(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("slice len = %d, want %d; got %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got)
}
}
}
func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) {
plain := errors.New("git config failed")
wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain)

View File

@@ -56,6 +56,15 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis
if walkErr != nil {
return walkErr
}
// Skip a stray git repo: a directory named .git skips the whole subtree,
// and a .git file (the gitdir pointer used by submodules/worktrees) is
// skipped too.
if d.Name() == ".git" {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}

View File

@@ -113,6 +113,102 @@ func TestIsUnsafeRelPath(t *testing.T) {
}
}
func TestWalkHTMLPublishCandidates_ExcludesGitDir(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html></html>",
".git/config": "[core]\n",
".git/HEAD": "ref: refs/heads/main\n",
".git/objects/ab/cdef123": "binary",
".github/workflows/ci.yml": "on: push\n",
".gitignore": "node_modules\n",
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
rels := make(map[string]bool)
for _, c := range got {
rels[c.RelPath] = true
}
// .git 子树整体排除
for _, banned := range []string{".git/config", ".git/HEAD", ".git/objects/ab/cdef123"} {
if rels[banned] {
t.Errorf("%q should be excluded from candidates", banned)
}
}
// 相邻名不受影响
for _, kept := range []string{"index.html", ".github/workflows/ci.yml", ".gitignore"} {
if !rels[kept] {
t.Errorf("%q should be kept in candidates, got %+v", kept, got)
}
}
}
func TestWalkHTMLPublishCandidates_ExcludesNestedGitDir(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"index.html": "<html></html>",
"sub/.git/config": "[core]\n",
"sub/page.html": "<html></html>",
}
for rel, content := range files {
full := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
rels := make(map[string]bool)
for _, c := range got {
rels[c.RelPath] = true
}
if rels["sub/.git/config"] {
t.Errorf("nested sub/.git/config should be excluded, got %+v", got)
}
if !rels["sub/page.html"] || !rels["index.html"] {
t.Errorf("non-git files should be kept, got %+v", got)
}
}
func TestWalkHTMLPublishCandidates_ExcludesGitFile(t *testing.T) {
// submodule / worktree 场景:.git 是个 gitdir 指针文件,不是目录。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got, err := walkHTMLPublishCandidates(newTestFIO(), dir)
if err != nil {
t.Fatalf("err=%v", err)
}
for _, c := range got {
if c.RelPath == ".git" {
t.Errorf(".git pointer file should be excluded, got %+v", got)
}
}
}
func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) {
// Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用,
// 且 fio.Open 对 symlink 行为不一致。real.html 仍然被收link.html 不在结果里。

View File

@@ -317,6 +317,17 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatSearch invalid chat-modes value", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "ok",
"chat-modes": "group,bogus",
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid --chat-modes value") {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
t.Run("ImChatUpdate requires fields", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -693,6 +704,39 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImChatSearch dry run maps chat-modes to wire values", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"chat-modes": "group,topic",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"chat_modes":["default","thread"]`) {
t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got)
}
})
t.Run("ImChatSearch dry run maps single chat-mode topic", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"chat-modes": "topic",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"chat_modes":["thread"]`) {
t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got)
}
})
t.Run("ImChatSearch dry run dedupes chat-modes", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"chat-modes": "group, group",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"chat_modes":["default"]`) {
t.Fatalf("ImChatSearch.DryRun() chat_modes dedupe = %s", got)
}
})
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",

View File

@@ -1411,7 +1411,7 @@ const (
)
const (
feedShortcutBatchLimit = 10
feedShortcutBatchLimit = 30
feedShortcutWriteScope = "im:feed.shortcut:write"
feedShortcutReadScope = "im:feed.shortcut:read"
)
@@ -1423,7 +1423,8 @@ type shortcutItem struct {
}
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
// returns deduped, validated oc_ IDs. The server batch limit is 10.
// returns deduped, validated oc_ IDs. This CLI enforces a local batch limit
// of 30 even though the upstream API currently documents a higher ceiling.
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
raw := rt.StrSlice("chat-id")
if len(raw) == 0 {

View File

@@ -31,6 +31,7 @@ var ImChatSearch = common.Shortcut{
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (max 64 chars)"},
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
@@ -72,6 +73,13 @@ var ImChatSearch = common.Shortcut{
}
}
}
if cm := runtime.Str("chat-modes"); cm != "" {
for _, mode := range common.SplitCSV(cm) {
if mode != "group" && mode != "topic" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-modes value %q: expected one of group, topic", mode).WithParam("--chat-modes")
}
}
}
if mi := runtime.Str("member-ids"); mi != "" {
ids := common.SplitCSV(mi)
if len(ids) > 50 {
@@ -217,6 +225,24 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
if st := runtime.Str("search-types"); st != "" {
filter["search_types"] = common.SplitCSV(st)
}
// chat_modes is a server-side filter. The CLI exposes group/topic; the wire
// expects default/thread. Map and dedupe (the API caps the list at 2, and
// there are only 2 distinct modes) while preserving the user's order.
if cm := runtime.Str("chat-modes"); cm != "" {
seen := map[string]bool{}
var modes []string
for _, mode := range common.SplitCSV(cm) {
wire := map[string]string{"group": "default", "topic": "thread"}[mode]
if wire == "" || seen[wire] {
continue
}
seen[wire] = true
modes = append(modes, wire)
}
if len(modes) > 0 {
filter["chat_modes"] = modes
}
}
if mi := runtime.Str("member-ids"); mi != "" {
filter["member_ids"] = common.SplitCSV(mi)
}

View File

@@ -17,7 +17,7 @@ import (
var ImFeedShortcutCreate = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-create",
Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order",
Description: "Add chats to the user's feed shortcuts; user-only; CHAT only; CLI enforces up to 30 chat IDs per call; --head/--tail controls insertion order",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
@@ -28,7 +28,7 @@ var ImFeedShortcutCreate = common.Shortcut{
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; CLI max 30 per call"},
{Name: "head", Type: "bool",
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
{Name: "tail", Type: "bool",

View File

@@ -5,75 +5,31 @@ package im
import (
"context"
"fmt"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImFeedShortcutList provides the +feed-shortcut-list shortcut for listing
// the user's feed shortcuts. The server-controlled page size covers the full
// list in practice, but pagination is version-locked: when the list changes
// between calls the server rejects the stale token and the caller has to
// restart by omitting --page-token.
//
// The shortcut is a thin one-page wrapper — there is no automatic walking.
// Callers are expected to drive their own loop when they actually need to
// paginate, because the version-lock means each page is a real checkpoint
// that the caller must consciously decide what to do with on failure.
// the current user's feed shortcuts. The latest OAPI contract returns the
// full list directly, so the shortcut intentionally exposes no pagination or
// detail-enrichment behavior.
var ImFeedShortcutList = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-list",
Description: "List one page of the user's feed shortcuts; user-only; first call omits --page-token, subsequent calls pass the previous response's page_token; each entry is auto-enriched with the full per-type info object attached as `detail` (pass --no-detail to skip)",
Risk: "read",
UserScopes: []string{feedShortcutReadScope},
ConditionalUserScopes: []string{chatBatchQueryScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-token",
Desc: "opaque pagination token from the previous response; omit for the first page. If a token is rejected because the list changed, restart by omitting it."},
{Name: "no-detail", Type: "bool",
Desc: "skip fetching the full info object for each shortcut (default: enrichment enabled — CHAT-type entries call im.chats.batch_query, require im:chat:read, and attach the object under the detail field)"},
},
Service: "im",
Command: "+feed-shortcut-list",
Description: "List the current user's feed shortcuts; user-only; returns the full CHAT shortcut list directly with no pagination or detail lookup",
Risk: "read",
UserScopes: []string{feedShortcutReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().
GET("/open-apis/im/v2/feed_shortcuts")
if token := runtime.Str("page-token"); token != "" {
d.Params(map[string]any{"page_token": token})
}
if !runtime.Bool("no-detail") {
d.Desc("conditional enrichment: if CHAT-type entries exist, execution also calls POST /open-apis/im/v1/chats/batch_query and requires scope im:chat:read; pass --no-detail to skip this extra call and extra scope")
}
return d
return common.NewDryRunAPI().GET("/open-apis/im/v2/feed_shortcuts")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts",
feedShortcutListQuery(runtime.Str("page-token")), nil)
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v2/feed_shortcuts", nil, nil)
if err != nil {
return err
}
if !runtime.Bool("no-detail") {
if err := enrichFeedShortcutDetail(runtime, data); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: detail enrichment failed: %v\n", err)
// Mirror the warning into the data payload so stdout-only
// consumers can tell "enrichment skipped" from "nothing to
// enrich" (same convention as mail's data-level _notice).
if data != nil {
data["_notice"] = fmt.Sprintf("detail enrichment skipped: %v", err)
}
}
}
runtime.Out(data, nil)
return nil
},
}
// feedShortcutListQuery omits the page_token key entirely when the token is
// empty, so the server treats the call as a first-page request.
func feedShortcutListQuery(token string) larkcore.QueryParams {
if token == "" {
return larkcore.QueryParams{}
}
return larkcore.QueryParams{"page_token": []string{token}}
}

View File

@@ -15,7 +15,7 @@ import (
var ImFeedShortcutRemove = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-remove",
Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
Description: "Remove chats from the user's feed shortcuts; user-only; CHAT only; CLI enforces up to 30 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
@@ -26,7 +26,7 @@ var ImFeedShortcutRemove = common.Shortcut{
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; CLI max 30 per call"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := collectChatIDs(runtime)

View File

@@ -6,7 +6,6 @@ package im
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -49,11 +48,6 @@ func newFeedShortcutRemoveCmd(t *testing.T) *cobra.Command {
func newFeedShortcutListCmd(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("page-token", "", "")
// Default true (skip enrichment) in tests so non-enrichment-focused tests
// don't trigger the batch_query path; tests that exercise detail
// enrichment flip this off.
cmd.Flags().Bool("no-detail", true, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
@@ -76,11 +70,26 @@ func TestCollectChatIDs(t *testing.T) {
{name: "dedupes", input: []string{"oc_abc", "oc_abc", "oc_def"}, want: []string{"oc_abc", "oc_def"}},
{name: "rejects empty list", input: nil, wantErr: true, errSubstr: "--chat-id is required"},
{name: "rejects bad prefix", input: []string{"om_abc"}, wantErr: true, errSubstr: "must be an open_chat_id"},
{
name: "accepts limit boundary",
input: []string{
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
},
want: []string{
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
},
},
{
name: "rejects over limit",
input: []string{
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5",
"oc_6", "oc_7", "oc_8", "oc_9", "oc_10", "oc_11",
"oc_1", "oc_2", "oc_3", "oc_4", "oc_5", "oc_6", "oc_7", "oc_8", "oc_9", "oc_10",
"oc_11", "oc_12", "oc_13", "oc_14", "oc_15", "oc_16", "oc_17", "oc_18", "oc_19", "oc_20",
"oc_21", "oc_22", "oc_23", "oc_24", "oc_25", "oc_26", "oc_27", "oc_28", "oc_29", "oc_30",
"oc_31",
},
wantErr: true,
errSubstr: "too many --chat-id",
@@ -549,24 +558,15 @@ func TestImFeedShortcutListDryRunRendersGet(t *testing.T) {
t.Fatalf("DryRun output = %s, want %q", got, want)
}
}
if strings.Contains(got, "page_token") {
t.Fatalf("DryRun output = %s, should omit page_token on first-page request", got)
}
func TestImFeedShortcutListHasNoCustomFlags(t *testing.T) {
if len(ImFeedShortcutList.Flags) != 0 {
t.Fatalf("ImFeedShortcutList.Flags = %v, want no shortcut-specific flags", ImFeedShortcutList.Flags)
}
}
func TestImFeedShortcutListDryRunIncludesNonEmptyPageToken(t *testing.T) {
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("page-token", "tok1"); err != nil {
t.Fatalf("Set page-token error = %v", err)
}
rt := &common.RuntimeContext{Cmd: cmd}
got := ImFeedShortcutList.DryRun(context.Background(), rt).Format()
if !strings.Contains(got, "page_token=tok1") {
t.Fatalf("DryRun output = %s, want page_token=tok1", got)
}
}
func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) {
func TestImFeedShortcutListHelpShowsNoLegacyFlags(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "app", AppSecret: "secret", Brand: core.BrandFeishu,
})
@@ -584,71 +584,13 @@ func TestImFeedShortcutListHelpDoesNotTreatDetailAsArgName(t *testing.T) {
t.Fatalf("Help() error = %v", err)
}
got := out.String()
if strings.Contains(got, "--no-detail detail") {
t.Fatalf("help output treats `detail` as a flag arg name:\n%s", got)
}
if !strings.Contains(got, "--no-detail") {
t.Fatalf("help output missing --no-detail:\n%s", got)
}
}
func TestImFeedShortcutListDryRunMentionsDetailScope(t *testing.T) {
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
t.Fatalf("Set no-detail error = %v", err)
}
rt := &common.RuntimeContext{Cmd: cmd}
got := ImFeedShortcutList.DryRun(context.Background(), rt).Format()
for _, want := range []string{
"im:chat:read",
"--no-detail",
"batch_query",
} {
if !strings.Contains(got, want) {
t.Fatalf("DryRun output = %s, want %q", got, want)
for _, banned := range []string{"--no-detail", "--page-token"} {
if strings.Contains(got, banned) {
t.Fatalf("help output should not mention legacy flag %s:\n%s", banned, got)
}
}
}
func TestImFeedShortcutListDoesNotExposeAutoPaginationFlags(t *testing.T) {
// Locks in the design decision: this shortcut is a one-page wrapper.
// If any of these reappear, callers/AI agents will assume auto-walking
// is supported and write code that silently double-fetches.
banned := map[string]bool{"page-all": true, "page-limit": true, "page-size": true}
for _, fl := range ImFeedShortcutList.Flags {
if banned[fl.Name] {
t.Fatalf("ImFeedShortcutList must not expose --%s", fl.Name)
}
}
}
func TestImFeedShortcutListPageTokenIsOptional(t *testing.T) {
// --page-token must NOT be Required: omitting it is the natural first-page
// signal (the server treats "missing" and "" the same). Forcing an empty
// string would just be noise.
for _, fl := range ImFeedShortcutList.Flags {
if fl.Name == "page-token" && fl.Required {
t.Fatalf("--page-token must be optional; omitting it should mean first page")
}
}
}
func TestImFeedShortcutListDetailOnByDefault(t *testing.T) {
// The real flag definition must keep detail enrichment on by default:
// --no-detail is an opt-out bool with a false zero-value default. The
// test-helper command flips it for isolation, so this definition-level
// check is what actually locks the shipped default against a flip.
for _, fl := range ImFeedShortcutList.Flags {
if fl.Name == "no-detail" {
if fl.Default != "" && fl.Default != "false" {
t.Fatalf("--no-detail default = %q, want unset/false (enrichment on by default)", fl.Default)
}
return
}
}
t.Fatalf("--no-detail flag not found on ImFeedShortcutList")
}
func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) {
// --chat-id is mandatory, but must NOT be cobra-Required: cobra would
// intercept a missing flag before Validate runs and emit a plain-text
@@ -663,430 +605,46 @@ func TestFeedShortcutChatIDNotCobraRequired(t *testing.T) {
}
}
func TestFeedShortcutListQueryOmitsEmptyToken(t *testing.T) {
q := feedShortcutListQuery("")
if _, ok := q["page_token"]; ok {
t.Fatalf("feedShortcutListQuery(\"\") = %v, want no page_token key", q)
}
q = feedShortcutListQuery("next")
if v := q["page_token"]; len(v) != 1 || v[0] != "next" {
t.Fatalf("feedShortcutListQuery(\"next\") page_token = %v, want [next]", v)
}
}
func TestImFeedShortcutListExecuteForwardsToken(t *testing.T) {
tests := []struct {
name string
token string
wantSent string // value the server should see in ?page_token=
wantKey bool // whether ?page_token should appear at all
}{
{name: "first page omits param", token: "", wantSent: "", wantKey: false},
{name: "explicit token is forwarded", token: "tok1", wantSent: "tok1", wantKey: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var calls int
var sawKey bool
var gotToken string
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
calls++
_, sawKey = req.URL.Query()["page_token"]
gotToken = req.URL.Query().Get("page_token")
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"shortcuts": []any{map[string]any{"feed_card_id": "oc_a", "type": float64(1)}},
"has_more": false,
"page_token": "end",
},
}), nil
}))
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("page-token", tt.token); err != nil {
t.Fatalf("Set page-token error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if calls != 1 {
t.Fatalf("expected 1 API call, got %d", calls)
}
if sawKey != tt.wantKey {
t.Fatalf("page_token query key present = %v, want %v", sawKey, tt.wantKey)
}
if gotToken != tt.wantSent {
t.Fatalf("page_token sent = %q, want %q", gotToken, tt.wantSent)
}
})
}
}
func TestShortcutTypeFromValue(t *testing.T) {
tests := []struct {
name string
v any
want ShortcutType
}{
{name: "float64 1 → chat", v: float64(1), want: ShortcutTypeChat},
{name: "int 1 → chat", v: 1, want: ShortcutTypeChat},
{name: "float64 0 → unknown", v: float64(0), want: ShortcutTypeUnknown},
{name: "unknown numeric → unknown ShortcutType(99)", v: float64(99), want: ShortcutType(99)},
{name: "string defaults to unknown", v: "1", want: ShortcutTypeUnknown},
{name: "nil defaults to unknown", v: nil, want: ShortcutTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shortcutTypeFromValue(tt.v); got != tt.want {
t.Fatalf("shortcutTypeFromValue(%v) = %v, want %v", tt.v, got, tt.want)
}
})
}
}
func TestResolveChatDetailBatchesAt50(t *testing.T) {
func TestImFeedShortcutListExecuteRequestsFullList(t *testing.T) {
var calls int
var rawQuery string
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
calls++
// Echo each requested chat_id back with a synthetic name so we can
// confirm both that batching happened and that the response was
// parsed correctly.
body, _ := io.ReadAll(req.Body)
var parsed struct {
ChatIDs []string `json:"chat_ids"`
}
_ = json.Unmarshal(body, &parsed)
items := make([]any, 0, len(parsed.ChatIDs))
for _, id := range parsed.ChatIDs {
items = append(items, map[string]any{"chat_id": id, "name": "group-" + id})
}
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{"items": items},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
ids := make([]string, 120) // 50 + 50 + 20 → 3 batches
for i := range ids {
ids[i] = fmt.Sprintf("oc_%d", i)
}
got, err := resolveChatDetail(rt, ids)
if err != nil {
t.Fatalf("resolveChatDetail() error = %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3 (120 ids / 50 batch size)", calls)
}
if len(got) != 120 {
t.Fatalf("resolved size = %d, want 120", len(got))
}
first := got["oc_0"]
last := got["oc_119"]
if first == nil || last == nil {
t.Fatalf("Items missing boundary entries: first=%v last=%v", first, last)
}
if first["name"] != "group-oc_0" || last["name"] != "group-oc_119" {
t.Fatalf("expected name passthrough; got first=%v last=%v", first["name"], last["name"])
}
}
func TestResolveChatDetailIncludesP2PChats(t *testing.T) {
// Unlike the old title-only resolver, the detail resolver keeps p2p chats
// in the result map (their full object carries chat_mode/p2p_target_id);
// only `name` is empty. Locks in that the empty-name skip was removed
// when we switched from `title` (string) to `detail` (full object).
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
rawQuery = req.URL.RawQuery
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{"chat_id": "oc_group", "name": "Engineering", "chat_mode": "group"},
map[string]any{"chat_id": "oc_p2p", "name": "", "chat_mode": "p2p", "p2p_target_id": "ou_x"},
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
},
},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
got, err := resolveChatDetail(rt, []string{"oc_group", "oc_p2p"})
if err != nil {
t.Fatalf("resolveChatDetail() error = %v", err)
}
if got["oc_group"]["name"] != "Engineering" {
t.Fatalf("oc_group name = %v, want Engineering", got["oc_group"]["name"])
}
p2p, ok := got["oc_p2p"]
if !ok {
t.Fatalf("oc_p2p must be in Items even though name is empty (caller decides what to show)")
}
if p2p["chat_mode"] != "p2p" || p2p["p2p_target_id"] != "ou_x" {
t.Fatalf("p2p detail = %v, want chat_mode=p2p with p2p_target_id passthrough", p2p)
}
}
cmd := newFeedShortcutListCmd(t)
setRuntimeField(t, rt, "Cmd", cmd)
func TestResolveChatDetailDropsItemsWithoutChatID(t *testing.T) {
// Defensive: the server should always echo chat_id back, but if it ever
// returns an item missing chat_id we must not write a "" → object entry
// into the map and end up attaching nonsense to entries.
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{"chat_id": "oc_ok", "name": "ok"},
map[string]any{"name": "no chat_id"},
},
},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
got, err := resolveChatDetail(rt, []string{"oc_ok"})
if err != nil {
t.Fatalf("resolveChatDetail() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("resolved size = %d, want 1 (entry without chat_id must be dropped)", len(got))
}
if _, ok := got[""]; ok {
t.Fatalf("got[\"\"] must not exist; got %v", got[""])
}
}
func TestResolveChatDetailPropagatesScopeError(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("resolver should fail scope pre-flight before calling API: %s", req.URL.Path)
return nil, nil
}))
// Token resolves with a known-but-wrong scope set so the missing-scope
// branch (not the unknown-metadata warning branch) fires.
setRuntimeScopes(t, rt, "search:message")
_, err := resolveChatDetail(rt, []string{"oc_abc"})
if err == nil {
t.Fatalf("resolveChatDetail() expected scope error, got nil")
}
if !strings.Contains(err.Error(), chatBatchQueryScope) {
t.Fatalf("resolveChatDetail() error = %v, want mention of %s", err, chatBatchQueryScope)
}
}
func TestEnrichFeedShortcutDetailAttachesAndDedupes(t *testing.T) {
var calls int
var capturedIDs []string
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
calls++
body, _ := io.ReadAll(req.Body)
var parsed struct {
ChatIDs []string `json:"chat_ids"`
}
_ = json.Unmarshal(body, &parsed)
capturedIDs = append(capturedIDs, parsed.ChatIDs...)
items := make([]any, 0, len(parsed.ChatIDs))
for _, id := range parsed.ChatIDs {
items = append(items, map[string]any{
"chat_id": id,
"name": "name-of-" + id,
"chat_mode": "group",
})
}
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{"items": items},
}), nil
}))
setRuntimeScopes(t, rt, chatBatchQueryScope)
data := map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
map[string]any{"feed_card_id": "oc_b", "type": float64(1)},
map[string]any{"feed_card_id": "oc_a", "type": float64(1)}, // duplicate
// Unknown type — must be skipped without aborting the whole call.
map[string]any{"feed_card_id": "doc_xxx", "type": float64(3)},
},
}
if err := enrichFeedShortcutDetail(rt, data); err != nil {
t.Fatalf("enrichFeedShortcutDetail() error = %v", err)
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if calls != 1 {
t.Fatalf("calls = %d, want 1 (single batch covers all CHAT ids)", calls)
t.Fatalf("expected 1 API call, got %d", calls)
}
if len(capturedIDs) != 2 {
t.Fatalf("server saw chat_ids = %v, want 2 dedup'd ids", capturedIDs)
if rawQuery != "" {
t.Fatalf("request query = %q, want empty query string", rawQuery)
}
items := data["shortcuts"].([]any)
for _, ix := range []int{0, 1, 2} { // 2 is the duplicate of 0
detail, ok := items[ix].(map[string]any)["detail"].(map[string]any)
if !ok {
t.Fatalf("item[%d] missing detail field; got %v", ix, items[ix])
}
// The full chat object is passed through verbatim — not just a name.
if detail["chat_mode"] != "group" {
t.Fatalf("item[%d] detail.chat_mode = %v, want group (full object passthrough)", ix, detail["chat_mode"])
}
wantName := "name-of-" + items[ix].(map[string]any)["feed_card_id"].(string)
if detail["name"] != wantName {
t.Fatalf("item[%d] detail.name = %v, want %q", ix, detail["name"], wantName)
}
}
if _, ok := items[3].(map[string]any)["detail"]; ok {
t.Fatalf("item[3] (unknown type) should not have detail set")
}
}
func TestEnrichFeedShortcutDetailNoOpWhenEmpty(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("must not call API for empty list: %s", req.URL.Path)
return nil, nil
}))
if err := enrichFeedShortcutDetail(rt, map[string]any{}); err != nil {
t.Fatalf("enrichFeedShortcutDetail(empty data) error = %v", err)
}
if err := enrichFeedShortcutDetail(rt, map[string]any{"shortcuts": []any{}}); err != nil {
t.Fatalf("enrichFeedShortcutDetail(empty shortcuts) error = %v", err)
}
}
func TestEnrichFeedShortcutDetailSkipsWhenNoSupportedType(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("must not call batch_query when no resolvable types: %s", req.URL.Path)
return nil, nil
}))
data := map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "doc_1", "type": float64(3)}, // DOC, not exposed
map[string]any{"feed_card_id": "app_1", "type": float64(4)}, // OPENAPP, not exposed
map[string]any{"feed_card_id": "biz_1", "type": float64(13)}, // APP_FEED, not exposed
},
}
if err := enrichFeedShortcutDetail(rt, data); err != nil {
t.Fatalf("enrichFeedShortcutDetail() error = %v", err)
}
for i, it := range data["shortcuts"].([]any) {
if _, ok := it.(map[string]any)["detail"]; ok {
t.Fatalf("item[%d] should not have a detail (unknown type)", i)
}
}
}
func TestImFeedShortcutListExecuteEnrichesDetailByDefault(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
},
"has_more": false,
"page_token": "",
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"chat_id": "oc_a",
"name": "Team Alpha",
"chat_mode": "group",
},
},
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope)
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
t.Fatalf("Set no-detail error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
// Verify both the attach-field name and the full-object passthrough,
// so future regressions that drop fields (e.g. only keeping `name`)
// fail loudly here.
for _, want := range []string{
`"detail":`,
`"chat_mode": "group"`,
`"name": "Team Alpha"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("stdout missing %q, got:\n%s", want, out)
}
}
}
func TestImFeedShortcutListExecuteWarnsOnEnrichFailure(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v2/feed_shortcuts"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"shortcuts": []any{
map[string]any{"feed_card_id": "oc_a", "type": float64(1)},
},
"has_more": false,
"page_token": "",
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/batch_query"):
return nil, fmt.Errorf("batch_query network failure")
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, feedShortcutReadScope+" "+chatBatchQueryScope)
cmd := newFeedShortcutListCmd(t)
if err := cmd.Flags().Set("no-detail", "false"); err != nil {
t.Fatalf("Set no-detail error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
// Listing should still succeed even when enrichment can't reach the API —
// failure becomes a stderr warning, not a hard exit.
if err := ImFeedShortcutList.Execute(context.Background(), rt); err != nil {
t.Fatalf("Execute() error = %v", err)
}
stderr := rt.Factory.IOStreams.ErrOut.(interface{ String() string }).String()
if !strings.Contains(stderr, "detail enrichment failed") {
t.Fatalf("stderr = %q, want enrichment warning", stderr)
}
// And the shortcut itself still appears, just without `detail`.
stdout := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
if !strings.Contains(stdout, `"feed_card_id": "oc_a"`) {
t.Fatalf("stdout should still contain the bare shortcut entry; got:\n%s", stdout)
for _, want := range []string{`"feed_card_id": "oc_a"`, `"type": 1`} {
if !strings.Contains(stdout, want) {
t.Fatalf("stdout = %s, want %q", stdout, want)
}
}
if strings.Contains(stdout, `"detail"`) {
t.Fatalf("stdout should NOT contain detail when enrichment failed; got:\n%s", stdout)
}
// The degradation is mirrored as a machine-readable data field so
// stdout-only consumers can tell "skipped" from "nothing to enrich".
if !strings.Contains(stdout, `"_notice": "detail enrichment skipped`) {
t.Fatalf("stdout should carry the _notice degradation marker; got:\n%s", stdout)
for _, banned := range []string{`"detail"`, `"_notice"`, `"page_token"`, `"has_more"`} {
if strings.Contains(stdout, banned) {
t.Fatalf("stdout should not contain legacy field %s; got:\n%s", banned, stdout)
}
}
}

View File

@@ -184,10 +184,7 @@ func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNo
return nil, err
}
var nodes wbNodesResp
rawNodes, ok := data["nodes"]
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: missing data.nodes")
}
rawNodes, _ := data["nodes"]
if rawNodes != nil {
var ok bool
nodes.Data.Nodes, ok = rawNodes.([]interface{})

View File

@@ -848,11 +848,6 @@ func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
token string
data map[string]interface{}
}{
{
name: "missing nodes",
token: "test-token-missing-nodes",
data: map[string]interface{}{},
},
{
name: "nodes not array",
token: "test-token-bad-nodes",
@@ -880,6 +875,32 @@ func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
}
}
// TestFetchWhiteboardNodes_MissingNodesIsEmpty verifies that a response with
// missing nodes field is treated as an empty whiteboard (success), not an error.
// This matches the behavior introduced in commit 4b39b037.
func TestFetchWhiteboardNodes_MissingNodesIsEmpty(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-missing-nodes/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-missing-nodes", "--output_as", "raw"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("expected success for missing nodes (empty whiteboard), got err=%v", err)
}
if !strings.Contains(stdout.String(), "whiteboard is empty") {
t.Fatalf("stdout missing empty whiteboard message: %s", stdout.String())
}
}
func assertInvalidResponse(t *testing.T, err error) {
t.Helper()
if err == nil {

View File

@@ -5,12 +5,11 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -95,7 +94,7 @@ func (s wikiAsyncTaskStatus) StatusLabel() string {
}
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
// translate from runtime.CallAPI responses or test fakes.
// translate from runtime.CallAPITyped responses or test fakes.
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
@@ -103,7 +102,7 @@ type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTas
// "simple_task_result" for delete-node).
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
if task == nil {
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, resultKey)
@@ -167,7 +166,7 @@ func pollWikiAsyncTask(
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
@@ -178,29 +177,18 @@ func pollWikiAsyncTask(
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
label, taskID, nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
// ErrWithHint rebuilds the error and drops the upstream Lark
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
// ExitError by hand so the original API code survives a fully
// failed poll, matching wrapWikiNodeDeleteAPIError.
return lastStatus, false, &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
// The poll error comes from a typed CallAPITyped path; append the resume
// hint in place so the original category / subtype / code / log_id
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
// errors unchanged"), matching wrapWikiNodeDeleteAPIError.
if p, ok := errs.ProblemOf(lastErr); ok {
if strings.TrimSpace(p.Hint) != "" {
hint = p.Hint + "\n" + hint
}
p.Hint = hint
return lastStatus, false, lastErr
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
}
return lastStatus, false, nil

View File

@@ -10,8 +10,8 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
@@ -88,45 +88,54 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
t.Parallel()
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
transportErr := errors.New("transport boom")
_, ready, err := pollWikiAsyncTask(
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
return wikiAsyncTaskStatus{}, errors.New("transport boom")
return wikiAsyncTaskStatus{}, transportErr
},
"lark-cli drive +task_result --task-id task_lost",
)
if ready {
t.Fatalf("ready = true, want false when every poll failed")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
if p.Subtype != errs.SubtypeUnknown {
t.Fatalf("subtype = %q, want unknown for an untyped poll failure", p.Subtype)
}
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
if !errors.Is(err, transportErr) {
t.Fatalf("err does not preserve the transport cause: %v", err)
}
if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") ||
!strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") {
t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint)
}
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
}
}
func TestParseWikiAsyncTaskStatusRejectsNilTask(t *testing.T) {
t.Parallel()
_, err := parseWikiAsyncTaskStatus("task_x", nil, "delete_space_result")
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("expected internal/invalid_response, got %v", err)
}
}
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
upstream := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "permission",
Code: 99991663,
Message: "permission denied",
Hint: "grant the wiki:node:retrieve scope",
},
}
// The upstream poll error is a typed error carrying its own hint, mirroring
// what runtime.CallAPITyped produces for a permission failure.
upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied").
WithHint("grant the wiki:node:retrieve scope")
_, _, err := pollWikiAsyncTask(
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
func(context.Context, string) (wikiAsyncTaskStatus, error) {
@@ -134,23 +143,23 @@ func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
},
"resume-cmd",
)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
}
// The upstream hint must lead so the actionable cause is read first, with
// the resume guidance appended. Type and exit code propagate from upstream.
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
// the resume guidance appended. The original typed error propagates in place.
if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") {
t.Fatalf("hint = %q, want upstream hint prepended", p.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "resume-cmd") {
t.Fatalf("hint = %q, want resume command appended", p.Hint)
}
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype)
}
if exitErr.Detail.Message != "permission denied" {
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
if p.Message != "permission denied" {
t.Fatalf("message = %q, want upstream message preserved", p.Message)
}
}

View File

@@ -9,8 +9,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct {
}
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
nil,
@@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (
}
func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "delete_space"},
@@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec
func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error {
if spec.SpaceID == "" {
return output.ErrValidation("--space-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
}
return validateOptionalResourceName(spec.SpaceID, "--space-id")
}

View File

@@ -6,7 +6,6 @@ package wiki
import (
"bytes"
"context"
"errors"
"reflect"
"strings"
"sync"
@@ -14,11 +13,12 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -266,19 +266,18 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
// Seed an error that carries an upstream Lark Detail.Code so the test
// Seed a typed error that carries an upstream Lark code and hint so the test
// pins that structured fields survive a fully failed poll (not just the
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
// hint): the poll-exhaustion path must propagate the typed error in place.
seeded := errclass.BuildAPIError(
map[string]any{"code": float64(131006), "msg": "poll failed"},
errclass.ClassifyContext{},
)
if p, ok := errs.ProblemOf(seeded); ok {
p.Hint = "retry original"
}
client := &fakeWikiDeleteSpaceClient{
taskErrs: []error{&output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: 131006,
Message: "poll failed",
Hint: "retry original",
},
}},
taskErrs: []error{seeded},
}
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
@@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
}
if exitErr.Detail.Code != 131006 {
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
if p.Code != 131006 {
t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code)
}
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())

View File

@@ -351,6 +351,7 @@ func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") {
t.Fatalf("expected target validation error, got %v", err)
}
requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token")
}
func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
@@ -365,6 +366,7 @@ func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutually exclusive error, got %v", err)
}
requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token")
}
// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -65,7 +65,7 @@ var WikiMemberAdd = common.Shortcut{
common.MaskToken(spec.MemberID), spec.MemberType, spec.MemberRole, common.MaskToken(spaceID))
path := fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/members", validate.EncodePathSegment(spaceID))
data, err := runtime.CallAPI("POST", path, spec.QueryParams(), spec.RequestBody())
data, err := runtime.CallAPITyped("POST", path, spec.QueryParams(), spec.RequestBody())
if err != nil {
return err
}
@@ -131,16 +131,16 @@ func readWikiMemberAddSpec(runtime *common.RuntimeContext) (wikiMemberAddSpec, e
return wikiMemberAddSpec{}, err
}
if spec.MemberID == "" {
return wikiMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
}
// The space-member API rejects opendepartmentid grants under a
// tenant_access_token; surface that as a CLI validation error so callers do
// not waste a network round-trip on a server-side 403. The escape hatch is
// --as user, which is the only identity the API accepts for departments.
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
return wikiMemberAddSpec{}, output.ErrValidation(
return wikiMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--as bot does not support --member-type opendepartmentid; rerun with --as user",
)
).WithParam("--member-type")
}
// --member-type / --member-role enum membership is enforced by the
// framework's validateEnumFlags (runner.go) before Validate runs, so no

View File

@@ -6,7 +6,7 @@ package wiki
import (
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -27,10 +27,10 @@ var wikiMemberRoles = []string{"admin", "member"}
// tenant_access_token; same contract as +node-list / +node-create)
func validateWikiMemberSpaceID(runtime *common.RuntimeContext, spaceID string) error {
if spaceID == "" {
return output.ErrValidation("--space-id is required and cannot be blank")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required and cannot be blank").WithParam("--space-id")
}
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
}
return validateOptionalResourceName(spaceID, "--space-id")
}

View File

@@ -122,7 +122,7 @@ func fetchWikiMembers(runtime *common.RuntimeContext, spaceID string) ([]map[str
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -68,7 +68,7 @@ var WikiMemberRemove = common.Shortcut{
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(spec.MemberID),
)
data, err := runtime.CallAPI("DELETE", path, nil, spec.RequestBody())
data, err := runtime.CallAPITyped("DELETE", path, nil, spec.RequestBody())
if err != nil {
return err
}
@@ -122,7 +122,7 @@ func readWikiMemberRemoveSpec(runtime *common.RuntimeContext) (wikiMemberRemoveS
return wikiMemberRemoveSpec{}, err
}
if spec.MemberID == "" {
return wikiMemberRemoveSpec{}, output.ErrValidation("--member-id is required and cannot be blank")
return wikiMemberRemoveSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
}
// Enum membership for --member-type / --member-role is enforced by the
// framework's validateEnumFlags (runner.go) before Validate runs.

View File

@@ -5,13 +5,12 @@ package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -65,7 +64,7 @@ var WikiMove = common.Shortcut{
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of letting the API return a confusing error.
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`").WithParam("--target-space-id")
}
return validateWikiMoveSpec(spec)
},
@@ -230,7 +229,7 @@ type wikiMoveAPI struct {
}
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
@@ -243,7 +242,7 @@ func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeReco
}
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
@@ -260,7 +259,7 @@ func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec
}
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
@@ -281,7 +280,7 @@ func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string,
}
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
@@ -324,28 +323,42 @@ func validateWikiMoveSpec(spec wikiMoveSpec) error {
if spec.NodeToken != "" {
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token cannot be combined with --obj-type, --obj-token, or --apply").
WithParams(
errs.InvalidParam{Name: "--obj-type", Reason: "cannot be combined with --node-token"},
errs.InvalidParam{Name: "--obj-token", Reason: "cannot be combined with --node-token"},
errs.InvalidParam{Name: "--apply", Reason: "cannot be combined with --node-token"},
)
}
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-parent-token and --target-space-id cannot both be empty for wiki node move").
WithParams(
errs.InvalidParam{Name: "--target-parent-token", Reason: "provide --target-parent-token or --target-space-id"},
errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-parent-token or --target-space-id"},
)
}
return nil
}
if spec.SourceSpaceID != "" {
return output.ErrValidation("--source-space-id can only be used with --node-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--source-space-id can only be used with --node-token").WithParam("--source-space-id")
}
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move").
WithParams(
errs.InvalidParam{Name: "--node-token", Reason: "provide --node-token, or --obj-type and --obj-token"},
errs.InvalidParam{Name: "--obj-type", Reason: "provide --node-token, or --obj-type and --obj-token"},
errs.InvalidParam{Name: "--obj-token", Reason: "provide --node-token, or --obj-type and --obj-token"},
)
}
if spec.ObjType == "" {
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-type is required for docs-to-wiki move").WithParam("--obj-type")
}
if spec.ObjToken == "" {
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--obj-token is required for docs-to-wiki move").WithParam("--obj-token")
}
if spec.TargetSpaceID == "" {
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id is required for docs-to-wiki move").WithParam("--target-space-id")
}
return nil
@@ -426,7 +439,7 @@ func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.Run
case wikiMoveModeDocsToWiki:
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
default:
return nil, output.ErrValidation("unknown wiki move mode")
return nil, errs.NewInternalError(errs.SubtypeUnknown, "unknown wiki move mode")
}
}
@@ -479,11 +492,11 @@ func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec
if targetSpaceID == "" {
targetSpaceID = parentSpaceID
} else if targetSpaceID != parentSpaceID {
return "", "", output.ErrValidation(
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--target-space-id %q does not match target parent node space %q",
spec.TargetSpaceID,
parentSpaceID,
)
).WithParam("--target-space-id")
}
}
@@ -549,7 +562,7 @@ func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *
}
return out, nil
default:
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
}
}
@@ -592,7 +605,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki move task failed: %s", status.PrimaryStatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
@@ -605,14 +618,18 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
taskID,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
// The poll error comes from a typed CallAPITyped path; append the resume
// hint in place so the original category / subtype / code / log_id
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
// errors unchanged").
if p, ok := errs.ProblemOf(lastErr); ok {
if strings.TrimSpace(p.Hint) != "" {
hint = p.Hint + "\n" + hint
}
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
p.Hint = hint
return lastStatus, false, lastErr
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
return lastStatus, false, errs.NewInternalError(errs.SubtypeSDKError, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
}
return lastStatus, false, nil
@@ -620,7 +637,7 @@ func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *commo
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
if task == nil {
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
return wikiMoveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
status := wikiMoveTaskStatus{

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
@@ -15,11 +14,11 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -181,39 +180,52 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec wikiMoveSpec
wantErr string
name string
spec wikiMoveSpec
wantErr string
wantParams []string
}{
{
name: "node move rejects docs flags",
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "cannot be combined",
name: "node move rejects docs flags",
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "cannot be combined",
wantParams: []string{"--obj-type", "--obj-token", "--apply"},
},
{
name: "node move requires target",
spec: wikiMoveSpec{NodeToken: "wik_node"},
wantErr: "cannot both be empty",
name: "node move requires target",
spec: wikiMoveSpec{NodeToken: "wik_node"},
wantErr: "cannot both be empty",
wantParams: []string{"--target-parent-token", "--target-space-id"},
},
{
name: "source space requires node token",
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "can only be used with --node-token",
name: "requires a move mode",
spec: wikiMoveSpec{TargetSpaceID: "space_dst"},
wantErr: "provide --node-token for wiki node move",
wantParams: []string{"--node-token", "--obj-type", "--obj-token"},
},
{
name: "docs to wiki requires obj type",
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "--obj-type is required",
name: "source space requires node token",
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "can only be used with --node-token",
wantParams: []string{"--source-space-id"},
},
{
name: "docs to wiki requires obj token",
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "--obj-token is required",
name: "docs to wiki requires obj type",
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "--obj-type is required",
wantParams: []string{"--obj-type"},
},
{
name: "docs to wiki requires target space",
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
wantErr: "--target-space-id is required",
name: "docs to wiki requires obj token",
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "--obj-token is required",
wantParams: []string{"--obj-token"},
},
{
name: "docs to wiki requires target space",
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
wantErr: "--target-space-id is required",
wantParams: []string{"--target-space-id"},
},
}
@@ -225,6 +237,9 @@ func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
if len(tt.wantParams) > 0 {
requireWikiValidationParams(t, err, tt.wantParams...)
}
})
}
}
@@ -837,7 +852,7 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
taskErrs: []error{errs.NewAPIError(errs.SubtypeServerError, "poll failed").WithHint("retry original")},
}
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
@@ -850,12 +865,12 @@ func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
}
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())

View File

@@ -9,7 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,10 +44,18 @@ var WikiNodeCopy = common.Shortcut{
targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id"))
targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token"))
if targetSpaceID == "" && targetParent == "" {
return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --target-space-id or --target-parent-node-token is required").
WithParams(
errs.InvalidParam{Name: "--target-space-id", Reason: "provide --target-space-id or --target-parent-node-token"},
errs.InvalidParam{Name: "--target-parent-node-token", Reason: "provide --target-space-id or --target-parent-node-token"},
)
}
if targetSpaceID != "" && targetParent != "" {
return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-space-id and --target-parent-node-token are mutually exclusive; provide only one").
WithParams(
errs.InvalidParam{Name: "--target-space-id", Reason: "mutually exclusive with --target-parent-node-token"},
errs.InvalidParam{Name: "--target-parent-node-token", Reason: "mutually exclusive with --target-space-id"},
)
}
if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil {
return err
@@ -72,7 +80,7 @@ var WikiNodeCopy = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n",
common.MaskToken(nodeToken), common.MaskToken(spaceID))
data, err := runtime.CallAPI("POST",
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy",
validate.EncodePathSegment(spaceID),
validate.EncodePathSegment(nodeToken)),

View File

@@ -5,12 +5,12 @@ package wiki
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -170,7 +170,7 @@ type wikiNodeCreateAPI struct {
}
func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
@@ -183,7 +183,7 @@ func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNo
}
func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
nil,
@@ -196,7 +196,7 @@ func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wik
}
func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)),
nil,
@@ -231,22 +231,26 @@ func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity)
}
if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" {
return output.ErrValidation("--origin-node-token is required when --node-type=shortcut")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token is required when --node-type=shortcut").WithParam("--origin-node-token")
}
if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" {
return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--origin-node-token can only be used when --node-type=shortcut").WithParam("--origin-node-token")
}
// Bot identity has no meaningful "personal document library" target, so
// my_library must be rejected explicitly instead of deferring to API-time
// resolution errors.
if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token").WithParam("--space-id")
}
// Bot identity also cannot fall back implicitly, so it requires an explicit
// target or a parent it can resolve from.
if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" {
return output.ErrValidation("bot identity requires --space-id or --parent-node-token")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").
WithParams(
errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"},
errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"},
)
}
return nil
@@ -334,7 +338,7 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
return nil, wrapWikiNodeCreateRetryError(lastErr)
}
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node create returned no node")
}
return &wikiNodeCreateExecution{
@@ -346,45 +350,32 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit
// isWikiNodeLockContention returns true if the error is a Lark API error with
// code 131009 (wiki node lock contention), which is retryable with backoff.
func isWikiNodeLockContention(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return exitErr.Detail.Code == output.LarkErrWikiLockContention
return p.Code == output.LarkErrWikiLockContention
}
// wrapWikiNodeCreateRetryError appends a retry-exhaustion hint to the original
// API error. It builds the ExitError by hand (instead of using ErrWithHint) so
// the original Lark error code survives in the envelope.
// API error in place, preserving its typed category / subtype / code / log_id.
func wrapWikiNodeCreateRetryError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
hint := fmt.Sprintf(
"wiki node create failed after %d retries due to lock contention; try again later or reduce concurrent node creations under the same parent",
wikiNodeCreateMaxRetries,
)
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
if existing := strings.TrimSpace(p.Hint); existing != "" {
hint = existing + "\n" + hint
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
Err: exitErr.Err,
Raw: exitErr.Raw,
}
p.Hint = hint
return err
}
// resolveWikiNodeCreateSpace applies the shortcut's precedence rules:
@@ -397,7 +388,11 @@ func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient
return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken)
}
if identity.IsBot() {
return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token")
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity requires --space-id or --parent-node-token").
WithParams(
errs.InvalidParam{Name: "--space-id", Reason: "provide --space-id or --parent-node-token for bot identity"},
errs.InvalidParam{Name: "--parent-node-token", Reason: "provide --space-id or --parent-node-token for bot identity"},
)
}
return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client)
}
@@ -434,12 +429,12 @@ func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wik
return wikiResolvedSpace{}, err
}
if parentSpaceID != resolved.SpaceID {
return wikiResolvedSpace{}, output.ErrValidation(
return wikiResolvedSpace{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id %q does not match parent node space %q (resolved space: %q)",
spec.SpaceID,
parentSpaceID,
resolved.SpaceID,
)
).WithParam("--space-id")
}
resolved.ParentNode = parent
@@ -483,21 +478,22 @@ func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) {
if node != nil && node.SpaceID != "" {
return node.SpaceID, nil
}
return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node lookup returned no space_id")
}
func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) {
if space != nil && space.SpaceID != "" {
return space.SpaceID, nil
}
return "", output.ErrValidation("personal document library was not found, please specify --space-id")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "personal document library lookup returned no space_id").
WithHint("specify --space-id explicitly to target a space directly")
}
// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns
// the per-user real space_id. Shared by shortcuts that accept the my_library
// alias (e.g. +node-create, +node-list) so the behavior stays consistent.
func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)),
nil, nil,
@@ -517,14 +513,14 @@ func validateOptionalResourceName(value, flagName string) error {
return nil
}
if err := validate.ResourceName(value, flagName); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(flagName).WithCause(err)
}
return nil
}
func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
if node == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response missing node")
}
return &wikiNodeRecord{
SpaceID: common.GetString(node, "space_id"),
@@ -542,7 +538,7 @@ func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) {
func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) {
if space == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space response missing space")
}
return &wikiSpaceRecord{
SpaceID: common.GetString(space, "space_id"),

View File

@@ -16,13 +16,26 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// wikiTestLockContentionErr builds the typed API error that runtime.CallAPITyped
// produces for a wiki write-lock-contention response (code 131009), so the
// retry path's errs.ProblemOf(...).Code check sees the same shape in tests as
// in production.
func wikiTestLockContentionErr() error {
return errclass.BuildAPIError(
map[string]any{"code": float64(output.LarkErrWikiLockContention), "msg": "lock contention"},
errclass.ClassifyContext{},
)
}
type fakeWikiNodeCreateCall struct {
SpaceID string
Spec wikiNodeCreateSpec
@@ -116,6 +129,44 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
return parent.Execute()
}
// requireWikiValidationParams asserts err carries a *errs.ValidationError of
// category validation / subtype invalid_argument and that its named flags
// (single Param or structured Params) cover every wanted flag, locking the
// typed contract and input-recovery fields so callers and agents can re-fill
// the right flags without parsing the prose message.
func requireWikiValidationParams(t *testing.T, err error, wantNames ...string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype)
}
have := make(map[string]bool, len(ve.Params)+1)
if ve.Param != "" {
have[ve.Param] = true
}
for _, p := range ve.Params {
have[p.Name] = true
}
for _, name := range wantNames {
if !have[name] {
t.Fatalf("flag %q not found; Param=%q Params=%+v", name, ve.Param, ve.Params)
}
}
}
func TestRequireWikiSpaceIDTreatsEmptyAsInvalidResponse(t *testing.T) {
t.Parallel()
_, err := requireWikiSpaceID(&wikiSpaceRecord{})
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("expected internal/invalid_response, got %v", err)
}
}
func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) {
t.Parallel()
@@ -151,6 +202,7 @@ func TestValidateWikiNodeCreateSpecRejectsBotWithoutLocation(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") {
t.Fatalf("expected bot location validation error, got %v", err)
}
requireWikiValidationParams(t, err, "--space-id", "--parent-node-token")
}
func TestValidateWikiNodeCreateSpecRejectsBotMyLibrarySpaceID(t *testing.T) {
@@ -236,6 +288,19 @@ func TestResolveWikiNodeCreateSpaceUsesMyLibraryFallback(t *testing.T) {
}
}
func TestResolveWikiNodeCreateSpaceRejectsBotWithoutLocation(t *testing.T) {
t.Parallel()
_, err := resolveWikiNodeCreateSpace(context.Background(), &fakeWikiNodeCreateClient{}, core.AsBot, wikiNodeCreateSpec{
NodeType: wikiNodeTypeOrigin,
ObjType: "docx",
})
if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") {
t.Fatalf("expected bot location validation error, got %v", err)
}
requireWikiValidationParams(t, err, "--space-id", "--parent-node-token")
}
func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) {
t.Parallel()
@@ -785,7 +850,7 @@ func TestWikiNodeURL(t *testing.T) {
func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -831,7 +896,7 @@ func TestRunWikiNodeCreateRetriesOnLockContention(t *testing.T) {
func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -853,25 +918,30 @@ func TestRunWikiNodeCreateRetriesExhausted(t *testing.T) {
if len(client.createInvoked) != 3 {
t.Fatalf("create invoked %d times, want 3", len(client.createInvoked))
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if exitErr.Detail.Code != output.LarkErrWikiLockContention {
t.Fatalf("error code = %d, want %d", exitErr.Detail.Code, output.LarkErrWikiLockContention)
if p.Code != output.LarkErrWikiLockContention {
t.Fatalf("error code = %d, want %d", p.Code, output.LarkErrWikiLockContention)
}
if !strings.Contains(exitErr.Detail.Hint, "failed after 2 retries") {
t.Fatalf("hint = %q, want retry exhaustion message", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "failed after 2 retries") {
t.Fatalf("hint = %q, want retry exhaustion message", p.Hint)
}
if !strings.Contains(exitErr.Detail.Hint, "lock contention") {
t.Fatalf("hint = %q, want original classification hint preserved", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "lock contention") {
t.Fatalf("hint = %q, want original classification hint preserved", p.Hint)
}
}
func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
t.Parallel()
otherErr := output.ErrAPI(output.LarkErrRateLimit, "rate limit", nil) // rate limit, not lock contention
// A typed API error for a different code (rate limit, not lock contention),
// mirroring what runtime.CallAPITyped produces.
otherErr := errclass.BuildAPIError(
map[string]any{"code": float64(output.LarkErrRateLimit), "msg": "rate limit"},
errclass.ClassifyContext{},
)
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -901,7 +971,7 @@ func TestRunWikiNodeCreateNoRetryOnNonContentionError(t *testing.T) {
func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{
@@ -944,7 +1014,7 @@ func TestRunWikiNodeCreateRetriesOnFirstLockThenSucceeds(t *testing.T) {
func TestRunWikiNodeCreateRetryContextCancelled(t *testing.T) {
t.Parallel()
lockErr := output.ErrAPI(output.LarkErrWikiLockContention, "lock contention", nil)
lockErr := wikiTestLockContentionErr()
client := &fakeWikiNodeCreateClient{
spaces: map[string]*wikiSpaceRecord{

View File

@@ -5,14 +5,13 @@ package wiki
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -135,7 +134,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
if objType != "" && objType != "wiki" {
params["obj_type"] = objType
}
data, err := api.runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
data, err := api.runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", params, nil)
if err != nil {
return nil, err
}
@@ -143,7 +142,7 @@ func (api wikiNodeDeleteAPI) ResolveNode(ctx context.Context, token, objType str
}
func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"DELETE",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s",
@@ -160,7 +159,7 @@ func (api wikiNodeDeleteAPI) DeleteNode(ctx context.Context, spaceID string, spe
}
func (api wikiNodeDeleteAPI) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
data, err := api.runtime.CallAPI(
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiAsyncTaskTypeDeleteNode},
@@ -188,7 +187,7 @@ func readWikiNodeDeleteSpec(runtime *common.RuntimeContext) (wikiNodeDeleteSpec,
func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChildren bool) (wikiNodeDeleteSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token is required")
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
}
spec := wikiNodeDeleteSpec{
@@ -200,14 +199,14 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
).WithParam("--node-token")
}
spec.NodeToken = token
spec.SourceKind = "url"
@@ -222,32 +221,32 @@ func parseWikiNodeDeleteSpec(rawToken, rawObjType, rawSpaceID string, includeChi
case spec.ObjType == "":
spec.ObjType = inferred
case spec.ObjType != inferred:
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, inferred,
)
).WithParam("--obj-type")
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
).WithParam("--node-token")
} else {
spec.NodeToken = tokenInput
spec.SourceKind = "raw"
}
if spec.ObjType == "" {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required (one of: %s)",
strings.Join(wikiNodeDeleteObjTypes, ", "),
)
).WithParam("--obj-type")
}
if !isValidWikiDeleteObjType(spec.ObjType) {
return wikiNodeDeleteSpec{}, output.ErrValidation(
return wikiNodeDeleteSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type %q is not valid; pick one of: %s",
spec.ObjType, strings.Join(wikiNodeDeleteObjTypes, ", "),
)
).WithParam("--obj-type")
}
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return wikiNodeDeleteSpec{}, err
@@ -405,12 +404,12 @@ func wrapWikiNodeDeleteAPIError(err error) error {
if err == nil {
return nil
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
var hint string
switch exitErr.Detail.Code {
switch p.Code {
case wikiDeleteNodeErrCodeApprovalRequired:
hint = "this wiki node has delete-approval enabled; ask the user to apply via the Wiki UI (CLI cannot bypass approval)"
case wikiDeleteNodeErrCodeSubtreeTooLarge:
@@ -419,22 +418,11 @@ func wrapWikiNodeDeleteAPIError(err error) error {
if hint == "" {
return err
}
if existing := strings.TrimSpace(exitErr.Detail.Hint); existing != "" {
// Append the hint in place so the typed error keeps its category / subtype /
// code / log_id (per ERROR_CONTRACT.md "propagate typed errors unchanged").
if existing := strings.TrimSpace(p.Hint); existing != "" {
hint = existing + "\n" + hint
}
// ErrWithHint drops the upstream Detail.Code / Detail / Risk fields; build
// the ExitError by hand so the Lark error code stays available to logs and
// downstream pivots.
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
Type: exitErr.Detail.Type,
Code: exitErr.Detail.Code,
Message: exitErr.Detail.Message,
Hint: hint,
ConsoleURL: exitErr.Detail.ConsoleURL,
Risk: exitErr.Detail.Risk,
Detail: exitErr.Detail.Detail,
},
}
p.Hint = hint
return err
}

View File

@@ -9,17 +9,17 @@ import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -357,63 +357,55 @@ func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeApprovalRequired,
Message: "node requires delete approval",
},
}
in := errclass.BuildAPIError(
map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T %v", got, got)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", p.Hint)
}
// Original code/message must be preserved so logs and dashboards still
// pivot on the upstream error code.
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
if p.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", p.Code)
}
if exitErr.Detail.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
if p.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", p.Message)
}
}
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
Message: "subtree too large",
},
}
in := errclass.BuildAPIError(
map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T %v", got, got)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", p.Hint)
}
}
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
}
in := errclass.BuildAPIError(
map[string]any{"code": float64(131005), "msg": "node not found"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
if !reflect.DeepEqual(got, in) {
t.Fatalf("unknown code should pass through; got %#v", got)
if got != in {
t.Fatalf("unknown code should pass through unchanged; got %#v", got)
}
}

View File

@@ -12,7 +12,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -98,7 +98,7 @@ var WikiNodeGet = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Fetching wiki node %s...\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", spec.RequestParams(), nil)
if err != nil {
return err
}
@@ -109,10 +109,10 @@ var WikiNodeGet = common.Shortcut{
}
if spec.SpaceID != "" && node.SpaceID != "" && spec.SpaceID != node.SpaceID {
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--space-id %q does not match the resolved node space %q (node_token=%s)",
spec.SpaceID, node.SpaceID, node.NodeToken,
)
).WithParam("--space-id")
}
if spec.SpaceID != "" && node.SpaceID == "" {
// The cross-check was requested but get_node returned no space_id,
@@ -178,8 +178,8 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
legacy := strings.TrimSpace(legacyToken)
switch {
case canonical != "" && legacy != "" && canonical != legacy:
return "", output.ErrValidation(
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--node-token and --token are both set with different values; pass --node-token only (--token is deprecated)").WithParam("--token")
case canonical != "":
return nodeToken, nil
default:
@@ -193,7 +193,7 @@ func resolveWikiNodeGetRawToken(nodeToken, legacyToken string) (string, error) {
func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetSpec, error) {
tokenInput := strings.TrimSpace(rawToken)
if tokenInput == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--node-token is required")
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
}
spec := wikiNodeGetSpec{
@@ -204,14 +204,14 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
if strings.Contains(tokenInput, "://") {
u, err := url.Parse(tokenInput)
if err != nil || u.Path == "" {
return wikiNodeGetSpec{}, output.ErrValidation("--node-token URL is malformed: %q", tokenInput)
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token URL is malformed: %q", tokenInput).WithParam("--node-token")
}
token, urlObjType, ok := tokenAndObjTypeFromWikiURL(u.Path)
if !ok {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --node-token URL path %q: expected /wiki/, /docx/, /doc/, /sheets/, /base/, /mindnote/, /slides/, or /file/ followed by a token",
u.Path,
)
).WithParam("--node-token")
}
spec.Token = token
if urlObjType == "" {
@@ -223,16 +223,16 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
case spec.ObjType == "" && urlObjType != "":
spec.ObjType = urlObjType
case spec.ObjType != "" && urlObjType != "" && spec.ObjType != urlObjType:
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type %q does not match the obj_type %q implied by the URL path; pass only one",
spec.ObjType, urlObjType,
)
).WithParam("--obj-type")
}
} else if strings.ContainsAny(tokenInput, "/?#") {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--node-token must be a raw token or a full URL; partial paths are not accepted: %q",
tokenInput,
)
).WithParam("--node-token")
} else {
spec.Token = tokenInput
if looksLikeWikiNodeToken(spec.Token) {
@@ -241,10 +241,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
)
).WithParam("--obj-type")
}
} else {
spec.SourceKind = "raw-obj"
@@ -253,10 +253,10 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, output.ErrValidation(
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
)
).WithParam("--obj-type")
}
}
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -53,7 +54,7 @@ var WikiNodeList = common.Shortcut{
// hint instead of deferring to API-time errors. Matches the contract
// used by +node-create and +move.
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
}
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
return err
@@ -150,7 +151,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[strin
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", apiPath, params, nil)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, false, "", err
}

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -60,14 +60,14 @@ var WikiSpaceCreate = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki space %q...\n", spec.Name)
data, err := runtime.CallAPI("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
data, err := runtime.CallAPITyped("POST", wikiSpacesAPIPath, nil, spec.RequestBody())
if err != nil {
return err
}
raw := common.GetMap(data, "space")
if raw == nil {
return output.Errorf(output.ExitAPI, "api_error", "wiki space create returned no space")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki space create returned no space")
}
out := wikiSpaceCreateOutput(raw)
@@ -100,7 +100,7 @@ func readWikiSpaceCreateSpec(runtime *common.RuntimeContext) (wikiSpaceCreateSpe
Description: strings.TrimSpace(runtime.Str("description")),
}
if spec.Name == "" {
return wikiSpaceCreateSpec{}, output.ErrValidation("--name is required and cannot be blank")
return wikiSpaceCreateSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required and cannot be blank").WithParam("--name")
}
return spec, nil
}

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -103,7 +104,7 @@ func fetchWikiSpaces(runtime *common.RuntimeContext) ([]map[string]interface{},
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPI("GET", wikiSpacesAPIPath, params, nil)
data, err := runtime.CallAPITyped("GET", wikiSpacesAPIPath, params, nil)
if err != nil {
return nil, false, "", err
}
@@ -181,10 +182,10 @@ func valueOrDash(v interface{}) string {
// +space-list and +node-list.
func validateWikiListPagination(runtime *common.RuntimeContext, maxPageSize int) error {
if n := runtime.Int("page-size"); n < 1 || n > maxPageSize {
return common.FlagErrorf("--page-size must be between 1 and %d", maxPageSize)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and %d", maxPageSize).WithParam("--page-size")
}
if n := runtime.Int("page-limit"); n < 0 {
return common.FlagErrorf("--page-limit must be a non-negative integer")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
}
return nil
}

View File

@@ -38,7 +38,7 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`,不要把 `.git``node_modules`、源码缓存一起带上
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist``.git` 目录会被自动排除,不会进入压缩包;`node_modules`、源码缓存等仍建议手动精简以控制包体
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`
## 安全规则

View File

@@ -78,6 +78,12 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
```
## 用户名写入规则
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
- 典型场景IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。
## 表格扩展
标准 HTML table 结构不变,扩展点:
- `<colgroup>` / `<col>` 定义列宽,紧跟 `<table>` 之后:`<col span="2" width="100"/>`

View File

@@ -7,7 +7,7 @@
1. **结构优于文字**:能用结构化 block 表达的信息,不用纯文本段落
2. **Front-load 结论**:文档以 `<callout>` 开头概括核心结论;每章节首段点明要旨
3. **视觉节奏**:连续纯文本不超过 3 段;不同主题章节间用 `<hr/>` 分隔
4. **最少惊讶**:同类信息使用同类元素,全篇风格统一
4. **风格一致**:同类信息使用同类元素,全篇风格统一
5. **重要信息画板化**:核心流程、架构、对比、风险、路线图、指标趋势等重要信息优先使用画板表达
## 二、元素选择指南
@@ -24,7 +24,7 @@
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 /思维导图等 | 画板图表 |
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
### 画板意图识别
@@ -69,7 +69,7 @@
## 四、排版规范
- 标题层级 ≤ 4 层,段落单段 ≤ 5 行,列表嵌套 ≤ 2 层Grid ≤ 3 列
- 文档开头用 `<callout>` front-load 结论
- 文档开头用 `<callout>` front-load 结论
## 五、丰富度自检

View File

@@ -134,6 +134,21 @@ lark-cli im <resource> <method> [flags] # 调用 API
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
- `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats.
### chat.user_setting
- `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
- `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
### chat.managers
- `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
- `delete_managers` — 删除群管理员。Identity: supports `user` and `bot`; only the group owner can remove managers; max 50 users or 5 bots per request.
### chat.moderation
- `get` — 获取群成员发言权限。Identity: supports `user` and `bot`; the caller must be in the target chat and belong to the same tenant.
- `update` — 更新群发言权限。Identity: supports `user` and `bot`; only the group owner (or creator bot with `im:chat:operate_as_owner`) can update; the caller must be in the chat.
### messages
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
@@ -186,6 +201,12 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `chat.members.create` | `im:chat.members:write_only` |
| `chat.members.delete` | `im:chat.members:write_only` |
| `chat.members.get` | `im:chat.members:read` |
| `chat.user_setting.batch_query` | `im:chat.user_setting:read` |
| `chat.user_setting.batch_update` | `im:chat.user_setting:write` |
| `chat.managers.add_managers` | `im:chat.managers:write_only` |
| `chat.managers.delete_managers` | `im:chat.managers:write_only` |
| `chat.moderation.get` | `im:chat.moderation:read` |
| `chat.moderation.update` | `im:chat:moderation:write_only` |
| `messages.delete` | `im:message:recall` |
| `messages.forward` | `im:message` |
| `messages.merge_forward` | `im:message` |

View File

@@ -15,6 +15,9 @@ lark-cli im +chat-search --query "project"
# Restrict by search types
lark-cli im +chat-search --query "project" --search-types "private,public_joined"
# Filter by chat mode (group = regular group, topic = topic/thread group)
lark-cli im +chat-search --query "project" --chat-modes "topic"
# Filter by member open_ids (with keyword)
lark-cli im +chat-search --query "project" --member-ids "ou_xxx,ou_yyy"
@@ -43,6 +46,7 @@ lark-cli im +chat-search --query "project" --dry-run
|------|------|------|------|
| `--query <keyword>` | No (at least one of `--query` / `--member-ids` required) | Max 64 characters | Search keyword. Supports matching localized chat names, member names, multilingual search, pinyin, and prefix fuzzy search. If the query contains `-`, it is automatically wrapped in quotes |
| `--search-types <types>` | No | Comma-separated: `private`, `external`, `public_joined`, `public_not_joined` | Restrict the visible chat types returned by search |
| `--chat-modes <modes>` | No | Comma-separated: `group`, `topic` | Filter by chat mode (server-side): `group` = regular group, `topic` = topic/thread group |
| `--member-ids <ids>` | No (at least one of `--query` / `--member-ids` required) | Up to 50, format `ou_xxx` | Filter by member open_ids; can be used alone or combined with `--query` |
| `--is-manager` | No | - | Only show chats you created or manage |
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |

View File

@@ -9,7 +9,7 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-create`. Underlying API
Adds one or more chats to the **current user's** feed shortcuts — equivalent to right-clicking a chat in the Feishu client and pinning it to the feed sidebar.
- Only **CHAT-type** shortcuts are exposed by the OpenAPI gateway right now (`feed_card_id` must be an `oc_xxx` open_chat_id).
- Batch up to **10 chat IDs per call**; pass more by issuing multiple calls.
- The upstream OAPI currently documents up to 50 items per write call, but this CLI intentionally enforces a stricter **30 chat IDs per call** local limit; pass more by issuing multiple calls.
- Currently only supports **user identity** (`--as user`); bot identity is not allowed by the server.
- If you only know a group name, resolve its `oc_xxx` first with `im +chat-search` or `im +chat-list`.
@@ -34,7 +34,7 @@ lark-cli im +feed-shortcut-create --as user --chat-id oc_xxx --dry-run
| Parameter | Default | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **max 10 per call** |
| `--chat-id <oc_xxx>` | required | open_chat_id to add as a feed shortcut; repeatable or comma-separated; **CLI max 30 per call** |
| `--head` | true (implied) | Insert at the top of the shortcut list; mutually exclusive with `--tail` |
| `--tail` | false | Append at the bottom of the shortcut list |
| `--as user` | required | Server only accepts user_access_token for this API |

View File

@@ -6,45 +6,32 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-list`. Underlying API:
## What it does
Lists **one page** of the **current user's** feed shortcuts.
Lists the **current user's full** feed shortcut list.
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
- The latest OAPI contract returns the whole list directly, so this shortcut exposes **no pagination flags**.
- The shortcut also does **not** perform any follow-up `im.chats.batch_query` detail enrichment.
## Commands
```bash
# First page (the only call most users ever need — --page-token omitted)
# List the current user's full shortcut list
lark-cli im +feed-shortcut-list --as user
# Continue from the previous response's page_token
lark-cli im +feed-shortcut-list --as user --page-token <token-from-previous-response>
# Skip detail enrichment when only IDs are needed; avoids the extra im:chat:read lookup
lark-cli im +feed-shortcut-list --as user --no-detail -q '.data.shortcuts[].feed_card_id'
```
> If you need to walk every page, write the loop yourself: read `data.page_token` from each response and pass it back in until `has_more=false`. The shortcut intentionally does not auto-walk because page-token errors require the caller to decide whether to restart from the first page.
## Parameters
| Parameter | Required | Description |
|------|------|------|
| `--page-token <token>` | no | Opaque pagination token from the previous response. **Omit it for the first page.** |
| `--no-detail` | no (default `false`) | Skip fetching each entry's full info object. By default enrichment is enabled: CHAT-type entries call `im.chats.batch_query`, need `im:chat:read`, and attach the object under the `detail` field. Pass `--no-detail` to skip the extra call and scope. |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response Structure
| Field | Type | Description |
|------|------|------|
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). By default (without `--no-detail`), each entry also has a `detail` field with the full per-type info object. |
| `has_more` | boolean | Whether more pages exist |
| `page_token` | string | Opaque token to pass to the next call when continuing pagination |
| `shortcuts` | array | Feed shortcut entries; each has `feed_card_id` (oc_xxx) and `type` (1=CHAT). |
Example (with detail enrichment, CHAT type):
Example:
```json
{
@@ -52,52 +39,18 @@ Example (with detail enrichment, CHAT type):
"shortcuts": [
{
"feed_card_id": "oc_092f0100fe59c35995727db1039777a8",
"type": 1,
"detail": {
"chat_id": "oc_092f0100fe59c35995727db1039777a8",
"chat_mode": "group",
"name": "Engineering",
"avatar": "https://...",
"description": "",
"external": false,
"owner_id": "ou_xxx",
"owner_id_type": "open_id",
"tenant_key": "..."
}
"type": 1
},
{
"feed_card_id": "oc_c82061d126a06635aa3569587b134bb1",
"type": 1,
"detail": {
"chat_id": "oc_c82061d126a06635aa3569587b134bb1",
"chat_mode": "p2p",
"name": "",
"p2p_target_id": "ou_xxx",
"p2p_target_type": "user",
"avatar": "",
"description": "",
"external": false,
"tenant_key": "..."
}
"type": 1
}
],
"has_more": false,
"page_token": "v1.example-opaque-token"
]
}
}
```
## Detail Enrichment
The `detail` payload is dispatched **per `type`**. Today only CHAT is wired in; future shortcut types can attach different object shapes. Callers should `switch` on `type` before parsing `detail`. For CHAT (`type=1`):
- **Source**: `POST /open-apis/im/v1/chats/batch_query` (50 ids per call, server limit).
- **Payload**: the **full chat object** is passed through verbatim — `chat_id`, `chat_mode` (`group` / `p2p` / `topic`), `name`, `avatar`, `description`, `external`, `tenant_key`, plus type-specific fields (`owner_id*` for groups, `p2p_target_*` for p2p).
- **P2P chats** return an empty `name` because the Feishu client renders the partner's display name there. The rest of the object (especially `p2p_target_id`) still flows through, so callers can resolve the partner via `+contact-search` if a display title is needed.
- **Lookup failure** (missing scope, network error) → the list still returns successfully; a warning is printed to stderr, the data payload carries a `_notice` field (`"detail enrichment skipped: ..."`), and affected entries simply lack the `detail` field. Check `_notice` to tell "enrichment skipped" from "nothing to enrich".
## Permissions
- Required scope: `im:feed.shortcut:read`
- Conditional scope (default detail path only): `im:chat:read`; pass `--no-detail` to avoid this extra scope and lookup.
- Only available with user identity (`--as user`).

View File

@@ -9,7 +9,7 @@ This skill maps to shortcut: `lark-cli im +feed-shortcut-remove`. Underlying API
Removes one or more chats from the **current user's** feed shortcuts.
- Only **CHAT-type** shortcuts are supported (`feed_card_id` must be an `oc_xxx`).
- Batch up to **10 chat IDs per call**.
- The upstream OAPI currently documents up to 50 items per write call, but this CLI intentionally enforces a stricter **30 chat IDs per call** local limit.
- Currently only supports **user identity** (`--as user`).
- Removing a chat that is not currently in the shortcut list is idempotent success: the call returns `ok:true`, `failure_count=0`, and no `failed_shortcuts` entry for that chat.
@@ -31,7 +31,7 @@ lark-cli im +feed-shortcut-remove --as user --chat-id oc_xxx --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; max 10 per call |
| `--chat-id <oc_xxx>` | yes | open_chat_id to remove from feed shortcuts; repeatable or comma-separated; CLI max 30 per call |
| `--as user` | yes | Server only accepts user_access_token for this API |
## Response
@@ -45,4 +45,4 @@ The response uses the same batch ledger as [`+feed-shortcut-create`](lark-im-fee
## Note
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md). Use `--no-detail` when you only need the `feed_card_id` values.
- To see what is currently in the shortcut list before removing, run [`+feed-shortcut-list`](lark-im-feed-shortcut-list.md).

View File

@@ -5,6 +5,8 @@ package apps
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
@@ -26,7 +28,8 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) {
"--app-id", "app_xxx",
"--dry-run",
},
DefaultAs: "user",
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -35,4 +38,56 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) {
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.Equal(t, "api-plus-local-setup", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "initialize_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "local_effects.#").Int())
assert.Equal(t, "save the issued PAT in the local system credential store", gjson.Get(result.Stdout, "local_effects.0").String())
assert.Equal(t, "write app-scoped git credential metadata", gjson.Get(result.Stdout, "local_effects.1").String())
assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", gjson.Get(result.Stdout, "local_effects.2").String())
}
func TestAppsGitCredentialListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-list", "--dry-run"},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-read-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "list_local_git_credentials", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.Contains(t, gjson.Get(result.Stdout, "storage_root").String(), filepath.Join("", "spark"))
assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", gjson.Get(result.Stdout, "reads.0").String())
}
func TestAppsGitCredentialRemoveDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx", "--dry-run"},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-cleanup-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "remove_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "app_id").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, "read app-scoped git credential metadata", gjson.Get(result.Stdout, "effects.0").String())
}

View File

@@ -87,9 +87,11 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
})
t.Run("HiddenFilesIncluded", func(t *testing.T) {
// Walker MUST NOT silently filter .git / .DS_Store — that's an explicit
// design decision so users pass clean ./dist trees, not source repos.
t.Run("HiddenFilesIncludedExceptGit", func(t *testing.T) {
// The walker filters the .git directory (and a .git gitdir pointer file)
// so a stray repo under --path doesn't ship its history / remote URL to a
// public share URL. Generic hidden files like .DS_Store are NOT filtered —
// only .git is — so users still see everything else they pointed --path at.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
@@ -112,8 +114,17 @@ func TestAppsHTMLPublishDryRun(t *testing.T) {
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "file_count").Int(),
"walker must include hidden files; got: %s", result.Stdout)
// index.html + .DS_Store kept; .git/HEAD filtered out → 2 files.
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int(),
"walker must keep non-.git hidden files but drop .git; got: %s", result.Stdout)
names := gjson.Get(result.Stdout, "files").Array()
var got []string
for _, n := range names {
got = append(got, n.String())
}
assert.Contains(t, got, "index.html")
assert.Contains(t, got, ".DS_Store")
assert.NotContains(t, got, ".git/HEAD", "walker must exclude .git contents")
})
t.Run("EmptyDir_ManifestEmpty", func(t *testing.T) {

View File

@@ -55,7 +55,7 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
}
})
t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) {
t.Run("list feed shortcuts as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
@@ -85,43 +85,13 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
}
found = true
require.Equal(t, int64(1), item.Get("type").Int(), "type should be 1 (CHAT)")
// detail enrichment is on by default — the chat we just created
// must come back with the chat info object attached.
require.True(t, item.Get("detail").Exists(),
"detail field should be attached when enrichment is enabled")
require.Equal(t, chatID, item.Get("detail.chat_id").String(),
"detail.chat_id should echo feed_card_id")
require.Equal(t, chatName, item.Get("detail.name").String(),
"detail.name should carry the chat's group name")
require.False(t, item.Get("detail").Exists(),
"detail field should not exist in the direct list contract")
break
}
require.True(t, found, "expected chat %s in feed shortcut list", chatID)
})
t.Run("list feed shortcuts with --no-detail skips lookup", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var foundEntry gjson.Result
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
foundEntry = item
break
}
}
require.True(t, foundEntry.Exists(), "expected our chat in the bare list")
require.False(t, foundEntry.Get("detail").Exists(),
"detail field should NOT be present with --no-detail")
})
t.Run("unpin chat from feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
@@ -143,7 +113,6 @@ func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
@@ -277,7 +246,7 @@ func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...strin
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"im", "+feed-shortcut-list", "--no-detail"},
Args: []string{"im", "+feed-shortcut-list"},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr)
@@ -410,7 +379,7 @@ func TestIM_FeedShortcutDryRun(t *testing.T) {
require.NotContains(t, result.Stdout, "is_header", "remove must not send is_header")
})
t.Run("list dry-run mentions detail enrichment path", func(t *testing.T) {
t.Run("list dry-run hits feed_shortcuts endpoint directly", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
@@ -422,24 +391,7 @@ func TestIM_FeedShortcutDryRun(t *testing.T) {
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
// Enrichment is on by default → DryRun adds a desc about the extra
// chats.batch_query call and the conditional scope.
require.Contains(t, result.Stdout, "im:chat:read")
require.Contains(t, result.Stdout, "batch_query")
})
t.Run("list dry-run with --no-detail omits the extra-scope note", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.NotContains(t, result.Stdout, "im:chat:read",
"with --no-detail, dry-run must not mention im:chat:read")
require.NotContains(t, result.Stdout, "im:chat:read")
require.NotContains(t, result.Stdout, "batch_query")
})
}