diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index 54e6cfe6..5ab102be 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -49,12 +49,21 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s u.DetectOverride = func() selfupdate.DetectResult { return result } u.NpmInstallOverride = npmFn u.VerifyOverride = func(string) error { return nil } + u.SkillsIndexFetchOverride = successfulSkillsIndexFetch() u.SkillsCommandOverride = successfulSkillsCommand() return u } t.Cleanup(func() { newUpdater = origNew }) } +func successfulSkillsIndexFetch() func() *selfupdate.NpmResult { + return func() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`) + return r + } +} + func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult { return func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} @@ -478,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing. u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return errors.New("bad binary") } u.RestoreAvailableOverride = func() bool { return false } + u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult { + t.Fatal("skills sync should not run when binary verification fails") + return nil + } u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { t.Fatal("skills sync should not run when binary verification fails") return nil @@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } + u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Err = fmt.Errorf("index unavailable") + return r + } u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") @@ -862,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) { } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } + u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult { + r := &selfupdate.NpmResult{} + r.Err = fmt.Errorf("index unavailable") + return r + } u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") @@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ + SkillsIndexFetchOverride: successfulSkillsIndexFetch(), SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true return successfulSkillsCommand()(args...) @@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ + SkillsIndexFetchOverride: successfulSkillsIndexFetch(), DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{ Method: selfupdate.InstallManual, @@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ + SkillsIndexFetchOverride: successfulSkillsIndexFetch(), DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{ Method: selfupdate.InstallNpm, NpmAvailable: true, @@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true} }, + SkillsIndexFetchOverride: func() *selfupdate.NpmResult { + skillsCalled = true + return successfulSkillsIndexFetch()() + }, SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true return successfulSkillsCommand()(args...) @@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ + SkillsIndexFetchOverride: func() *selfupdate.NpmResult { + skillsCalled = true + return successfulSkillsIndexFetch()() + }, SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { skillsCalled = true return successfulSkillsCommand()(args...) diff --git a/internal/selfupdate/updater.go b/internal/selfupdate/updater.go index aa39bfaf..9304f7c3 100644 --- a/internal/selfupdate/updater.go +++ b/internal/selfupdate/updater.go @@ -10,10 +10,13 @@ import ( "bytes" "context" "fmt" + "io" + "net/http" "os/exec" "strings" "time" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/vfs" ) @@ -37,9 +40,15 @@ const ( ) const ( - npmInstallTimeout = 10 * time.Minute - skillsUpdateTimeout = 2 * time.Minute - verifyTimeout = 10 * time.Second + npmInstallTimeout = 10 * time.Minute + skillsUpdateTimeout = 2 * time.Minute + skillsIndexMaxBodySize = 1 << 20 + verifyTimeout = 10 * time.Second +) + +var ( + skillsIndexFetchTimeout = 10 * time.Second + officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json" ) // DetectResult holds installation detection results. @@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string { type Updater struct { DetectOverride func() DetectResult NpmInstallOverride func(version string) *NpmResult + SkillsIndexFetchOverride func() *NpmResult SkillsCommandOverride func(args ...string) *NpmResult VerifyOverride func(expectedVersion string) error RestoreAvailableOverride func() bool @@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult { return r } +func (u *Updater) ListOfficialSkillsIndex() *NpmResult { + if u.SkillsIndexFetchOverride != nil { + return u.SkillsIndexFetchOverride() + } + + r := &NpmResult{} + ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil) + if err != nil { + r.Err = err + return r + } + + client := transport.NewHTTPClient(0) + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted()) + } + return nil + } + resp, err := client.Do(req) + if err != nil { + r.Err = err + return r + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode) + return r + } + + limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1) + if _, err := io.Copy(&r.Stdout, limited); err != nil { + r.Err = err + return r + } + if r.Stdout.Len() > skillsIndexMaxBodySize { + r.Stdout.Reset() + r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize) + return r + } + return r +} + func (u *Updater) ListOfficialSkills() *NpmResult { r := u.runSkillsListOfficial("https://open.feishu.cn") if r.Err != nil { diff --git a/internal/selfupdate/updater_test.go b/internal/selfupdate/updater_test.go index b01a1584..65426eeb 100644 --- a/internal/selfupdate/updater_test.go +++ b/internal/selfupdate/updater_test.go @@ -4,12 +4,18 @@ package selfupdate import ( + "context" + "errors" "fmt" + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" + "time" "github.com/larksuite/cli/internal/vfs" ) @@ -232,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) { } } +func TestListOfficialSkillsIndexSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`) + })) + defer server.Close() + + oldURL := officialSkillsIndexURL + officialSkillsIndexURL = server.URL + t.Cleanup(func() { officialSkillsIndexURL = oldURL }) + + result := New().ListOfficialSkillsIndex() + if result.Err != nil { + t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err) + } + if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") { + t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got) + } +} + +func TestListOfficialSkillsIndexHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + oldURL := officialSkillsIndexURL + officialSkillsIndexURL = server.URL + t.Cleanup(func() { officialSkillsIndexURL = oldURL }) + + result := New().ListOfficialSkillsIndex() + if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") { + t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err) + } +} + +func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1)) + })) + defer server.Close() + + oldURL := officialSkillsIndexURL + officialSkillsIndexURL = server.URL + t.Cleanup(func() { officialSkillsIndexURL = oldURL }) + + result := New().ListOfficialSkillsIndex() + if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") { + t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err) + } + if result.Stdout.Len() != 0 { + t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len()) + } +} + +func TestListOfficialSkillsIndexTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`) + })) + defer server.Close() + + oldURL := officialSkillsIndexURL + oldTimeout := skillsIndexFetchTimeout + officialSkillsIndexURL = server.URL + skillsIndexFetchTimeout = 50 * time.Millisecond + t.Cleanup(func() { + officialSkillsIndexURL = oldURL + skillsIndexFetchTimeout = oldTimeout + }) + + result := New().ListOfficialSkillsIndex() + var netErr net.Error + if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) { + t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err) + } +} + +func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound) + })) + defer server.Close() + + oldURL := officialSkillsIndexURL + officialSkillsIndexURL = server.URL + t.Cleanup(func() { officialSkillsIndexURL = oldURL }) + + result := New().ListOfficialSkillsIndex() + if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") { + t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err) + } +} + +func TestListOfficialSkillsIndexUsesOverride(t *testing.T) { + result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult { + r := &NpmResult{} + r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`) + return r + }}).ListOfficialSkillsIndex() + if result.Err != nil { + t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err) + } + if !strings.Contains(result.Stdout.String(), "override-skill") { + t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String()) + } +} + func TestListOfficialSkillsFallsBack(t *testing.T) { called := []string{} updater := &Updater{ diff --git a/internal/skillscheck/sync.go b/internal/skillscheck/sync.go index 48e4de9b..2f8adb3d 100644 --- a/internal/skillscheck/sync.go +++ b/internal/skillscheck/sync.go @@ -80,6 +80,30 @@ func ParseGlobalSkillsJSON(text string) []string { return sortedKeys(seen) } +func ParseOfficialSkillsIndexJSON(text string) ([]string, error) { + type officialSkill struct { + Name string `json:"name"` + } + type officialIndex struct { + Skills []officialSkill `json:"skills"` + } + + var index officialIndex + if err := json.Unmarshal([]byte(text), &index); err != nil { + return nil, err + } + + seen := map[string]bool{} + for _, skill := range index.Skills { + candidate := strings.TrimSpace(skill.Name) + if skillNamePattern.MatchString(candidate) { + seen[candidate] = true + } + } + + return sortedKeys(seen), nil +} + // parseGlobalSkillsList parses the output of "npx -y skills ls -g" func parseGlobalSkillsList(lines []string) []string { seen := map[string]bool{} @@ -160,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string { if len(parts) > 0 { candidate := parts[0] - // Check if it's a valid official skill name - if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) { + if skillNamePattern.MatchString(candidate) { seen[candidate] = true } } @@ -223,6 +246,7 @@ func PlanSync(input SyncInput) SyncPlan { } type SkillsRunner interface { + ListOfficialSkillsIndex() *selfupdate.NpmResult ListOfficialSkills() *selfupdate.NpmResult ListGlobalSkillsJSON() *selfupdate.NpmResult ListGlobalSkills() *selfupdate.NpmResult @@ -258,14 +282,9 @@ func SyncSkills(opts SyncOptions) *SyncResult { } // --- Step 1: List official skills --- - officialResult := opts.Runner.ListOfficialSkills() - if officialResult == nil || officialResult.Err != nil { - return fallbackFullInstall(opts, resultDetail(officialResult), nil) - } - official := ParseSkillsList(officialResult.Stdout.String()) - - if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" { - return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil) + official, reason, ok := listOfficialSkills(opts.Runner) + if !ok { + return fallbackFullInstall(opts, reason, nil) } // --- Step 2: List local (installed) skills --- @@ -327,6 +346,40 @@ func SyncSkills(opts SyncOptions) *SyncResult { return result } +func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) { + reasons := []string{} + + indexResult := runner.ListOfficialSkillsIndex() + if indexResult == nil || indexResult.Err != nil { + reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult)) + } else { + official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String()) + if err != nil { + reasons = append(reasons, "official skills index JSON invalid: "+err.Error()) + } else if len(official) > 0 { + return official, "", true + } else { + reasons = append(reasons, "official skills index contains no skills") + } + } + + officialResult := runner.ListOfficialSkills() + if officialResult == nil || officialResult.Err != nil { + reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult)) + return nil, strings.Join(reasons, "; "), false + } + official := ParseSkillsList(officialResult.Stdout.String()) + if len(official) > 0 { + return official, "", true + } + if strings.TrimSpace(officialResult.Stdout.String()) != "" { + reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout") + } else { + reasons = append(reasons, "official skills list returned no skills") + } + return nil, strings.Join(reasons, "; "), false +} + func listLocalSkills(runner SkillsRunner) ([]string, bool) { jsonResult := runner.ListGlobalSkillsJSON() if jsonResult != nil && jsonResult.Err == nil { diff --git a/internal/skillscheck/sync_test.go b/internal/skillscheck/sync_test.go index e0e9ee24..fb8f117f 100644 --- a/internal/skillscheck/sync_test.go +++ b/internal/skillscheck/sync_test.go @@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0 } } +func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) { + input := `Available Skills +│ lark-calendar +│ official-shared +│ bad/name +` + got := ParseSkillsList(input) + want := []string{"lark-calendar", "official-shared"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want) + } +} + func TestParseGlobalSkillsList(t *testing.T) { input := `Global Skills @@ -110,6 +123,43 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) { } } +func TestParseOfficialSkillsIndexJSON(t *testing.T) { + input := `{ + "skills": [ + {"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]}, + {"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]}, + {"name":" lark-base ","description":"Base","files":[]}, + {"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]}, + {"name":"custom-skill","description":"not official","files":["SKILL.md"]}, + {"name":"bad skill","description":"invalid","files":["SKILL.md"]}, + {"name":"","description":"empty","files":["SKILL.md"]} + ] +}` + got, err := ParseOfficialSkillsIndexJSON(input) + if err != nil { + t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err) + } + want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want) + } +} + +func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) { + for _, input := range []string{ + `not json`, + `[{"name":"lark-calendar"}]`, + `{"name":"lark-calendar"}`, + `{"skills":[]}`, + `{"skills":[{"name":"bad skill"}]}`, + } { + got, err := ParseOfficialSkillsIndexJSON(input) + if err == nil && len(got) != 0 { + t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got) + } + } +} + func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) { previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}} got := PlanSync(SyncInput{ @@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) { } type fakeSkillsRunner struct { + officialIndexOut string officialOut string globalJSONOut string globalOut string + officialIndexErr error officialErr error globalJSONErr error globalErr error @@ -166,6 +218,8 @@ type fakeSkillsRunner struct { installAllErr error installed [][]string installedAll int + listedIndex int + listedOfficial int listedGlobalJSON int listedGlobalText int } @@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string { return b.String() } +func officialSkillsIndexOutput(names ...string) string { + var b strings.Builder + b.WriteString(`{"skills":[`) + for i, name := range names { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name) + } + b.WriteString(`]}`) + return b.String() +} + func globalSkillsOutput(names ...string) string { var b strings.Builder b.WriteString("Global Skills\n\n") @@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string { return b.String() } +func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult { + f.listedIndex++ + r := &selfupdate.NpmResult{} + r.Stdout.WriteString(f.officialIndexOut) + r.Err = f.officialIndexErr + return r +} + func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult { + f.listedOfficial++ r := &selfupdate.NpmResult{} r.Stdout.WriteString(f.officialOut) r.Err = f.officialErr @@ -255,9 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) { } runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"), - globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"), - globalOut: globalSkillsOutput("lark-mail"), + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"), + globalOut: globalSkillsOutput("lark-mail"), } result := SyncSkills(SyncOptions{ Version: "1.0.33", @@ -289,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) { } } +func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"), + officialOut: officialSkillsOutput("lark-should-not-be-used"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar"), + globalOut: globalSkillsOutput("lark-mail"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"}) + assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"}) + if runner.listedIndex != 1 { + t.Fatalf("listedIndex = %d, want 1", runner.listedIndex) + } + if runner.listedOfficial != 0 { + t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial) + } +} + +func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexErr: fmt.Errorf("index unavailable"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"}) + if runner.listedIndex != 1 || runner.listedOfficial != 1 { + t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial) + } + if runner.installedAll != 0 { + t.Fatalf("installedAll = %d, want 0", runner.installedAll) + } +} + +func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexOut: `{"skills":[]}`, + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar"), + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Err != nil { + t.Fatalf("SyncSkills() err = %v, want nil", result.Err) + } + assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"}) + if runner.listedIndex != 1 || runner.listedOfficial != 1 { + t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial) + } +} + +func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexErr: fmt.Errorf("index unavailable"), + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1", runner.installedAll) + } + if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") { + t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail) + } +} + +func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + runner := &fakeSkillsRunner{ + officialIndexOut: `{"skills":[]}`, + installAllErr: nil, + } + + result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) + if result.Action != "fallback_synced" { + t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) + } + if runner.installedAll != 1 { + t.Fatalf("installedAll = %d, want 1", runner.installedAll) + } + if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") { + t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail) + } +} + func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialErr: fmt.Errorf("list failed"), - installAllErr: nil, + officialIndexErr: fmt.Errorf("index unavailable"), + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -322,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialErr: fmt.Errorf("list failed"), - installAllErr: fmt.Errorf("full install failed"), + officialIndexErr: fmt.Errorf("index unavailable"), + officialErr: fmt.Errorf("list failed"), + installAllErr: fmt.Errorf("full install failed"), } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -342,9 +527,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONErr: fmt.Errorf("json list failed"), - globalOut: globalSkillsOutput("lark-calendar"), + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONErr: fmt.Errorf("json list failed"), + globalOut: globalSkillsOutput("lark-calendar"), } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -367,9 +553,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"), - globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"), + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"), + globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"), } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -391,9 +578,10 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONOut: `[]`, - globalOut: "Some unrecognized output format\n", + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: `[]`, + globalOut: "Some unrecognized output format\n", } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -420,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) { } runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalOut: globalSkillsOutput(), - installAllErr: nil, + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput(), + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -445,11 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), - globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), - installErr: fmt.Errorf("incremental boom"), - installAllErr: nil, + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -477,11 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), - globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), - installErr: fmt.Errorf("incremental boom"), - installAllErr: fmt.Errorf("full install boom"), + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: fmt.Errorf("full install boom"), } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -510,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: "Some unrecognized output format\n", - installAllErr: nil, + officialIndexErr: fmt.Errorf("index unavailable"), + officialOut: "Some unrecognized output format\n", + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -527,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: "Some unrecognized output format\n", - installAllErr: fmt.Errorf("full install failed"), + officialIndexErr: fmt.Errorf("index unavailable"), + officialOut: "Some unrecognized output format\n", + installAllErr: fmt.Errorf("full install failed"), } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -551,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: "Some unrecognized output format\n", - installAllErr: nil, + officialIndexErr: fmt.Errorf("index unavailable"), + officialOut: "Some unrecognized output format\n", + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -576,11 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), - globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), - installErr: fmt.Errorf("incremental boom"), - installAllErr: nil, + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -601,11 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), - globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), - installErr: fmt.Errorf("incremental boom"), - installAllErr: nil, + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + installErr: fmt.Errorf("incremental boom"), + installAllErr: nil, } result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -625,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) runner := &fakeSkillsRunner{ - officialErr: fmt.Errorf("list failed"), - installAllErr: nil, + officialIndexErr: fmt.Errorf("index unavailable"), + officialErr: fmt.Errorf("list failed"), + installAllErr: nil, } result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) @@ -643,9 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) { } runner2 := &fakeSkillsRunner{ - officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), - globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), - globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), + officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), + officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), + globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), + globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), } result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now}) if result2.Action != "synced" { diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 18797c0d..eff32d0d 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -609,9 +609,6 @@ func TestShortcuts(t *testing.T) { "+feed-shortcut-create", "+feed-shortcut-remove", "+feed-shortcut-list", - "+feed-group-list", - "+feed-group-list-item", - "+feed-group-query-item", } if !reflect.DeepEqual(commands, want) { t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want) diff --git a/shortcuts/im/im_feed_group_item_test.go b/shortcuts/im/im_feed_group_item_test.go deleted file mode 100644 index 8c7f5885..00000000 --- a/shortcuts/im/im_feed_group_item_test.go +++ /dev/null @@ -1,516 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package im - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "strconv" - "strings" - "testing" - "time" - - "github.com/larksuite/cli/shortcuts/common" - "github.com/spf13/cobra" -) - -// recordedFGRequest captures one outbound request for assertion. -type recordedFGRequest struct { - method string - path string - query map[string][]string - body map[string]interface{} -} - -// fgResponder maps a URL path suffix to a JSON response body. -type fgResponder func(path string, page int) (int, interface{}) - -// newFGCmd builds a cobra command carrying the shortcut's flags, applying the -// provided overrides. -func newFGCmd(t *testing.T, sc common.Shortcut, flags map[string]string) *cobra.Command { - t.Helper() - cmd := &cobra.Command{Use: sc.Command} - for _, fl := range sc.Flags { - switch fl.Type { - case "bool": - cmd.Flags().Bool(fl.Name, fl.Default == "true", fl.Desc) - case "int": - def := 0 - if fl.Default != "" { - n, _ := strconv.Atoi(fl.Default) - def = n - } - cmd.Flags().Int(fl.Name, def, fl.Desc) - default: - cmd.Flags().String(fl.Name, fl.Default, fl.Desc) - } - } - if err := cmd.ParseFlags(nil); err != nil { - t.Fatalf("ParseFlags() error = %v", err) - } - for name, val := range flags { - if err := cmd.Flags().Set(name, val); err != nil { - t.Fatalf("set flag %s=%s: %v", name, val, err) - } - } - return cmd -} - -// newFGRuntime wires a user-identity runtime with the shortcut's flags and an -// httpmock transport that records requests and replies via the responder. -func newFGRuntime(t *testing.T, sc common.Shortcut, flags map[string]string, recorded *[]recordedFGRequest, responder fgResponder) *common.RuntimeContext { - t.Helper() - pageByPath := map[string]int{} - rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { - rec := recordedFGRequest{ - method: req.Method, - path: req.URL.Path, - query: req.URL.Query(), - } - if req.Body != nil { - data, _ := io.ReadAll(req.Body) - if len(data) > 0 { - _ = json.Unmarshal(data, &rec.body) - } - } - if recorded != nil { - *recorded = append(*recorded, rec) - } - pageByPath[req.URL.Path]++ - status, body := 200, interface{}(map[string]interface{}{"code": 0, "data": map[string]interface{}{}}) - if responder != nil { - status, body = responder(req.URL.Path, pageByPath[req.URL.Path]) - } - return shortcutJSONResponse(status, body), nil - }) - - runtime := newUserShortcutRuntime(t, rt) - runtime.Cmd = newFGCmd(t, sc, flags) - runtime.Format = "json" - return runtime -} - -func wrapData(d map[string]interface{}) map[string]interface{} { - return map[string]interface{}{"code": 0, "data": d} -} - -func findFGRequest(reqs []recordedFGRequest, pathSuffix string) *recordedFGRequest { - for i := range reqs { - if strings.HasSuffix(reqs[i].path, pathSuffix) { - return &reqs[i] - } - } - return nil -} - -func firstQueryValue(q map[string][]string, key string) string { - if v := q[key]; len(v) > 0 { - return v[0] - } - return "" -} - -// dryRunJSON marshals a DryRunAPI to its wire shape so tests can assert against -// the public JSON (calls/extra are unexported on the struct). -func dryRunJSON(t *testing.T, d *common.DryRunAPI) map[string]interface{} { - t.Helper() - b, err := json.Marshal(d) - if err != nil { - t.Fatalf("marshal dry-run: %v", err) - } - var m map[string]interface{} - if err := json.Unmarshal(b, &m); err != nil { - t.Fatalf("unmarshal dry-run: %v", err) - } - return m -} - -func dryRunCalls(t *testing.T, d *common.DryRunAPI) []map[string]interface{} { - t.Helper() - m := dryRunJSON(t, d) - raw, _ := m["api"].([]interface{}) - calls := make([]map[string]interface{}, 0, len(raw)) - for _, c := range raw { - cm, _ := c.(map[string]interface{}) - calls = append(calls, cm) - } - return calls -} - -func countFGRequests(reqs []recordedFGRequest, pathSuffix string) int { - n := 0 - for i := range reqs { - if strings.HasSuffix(reqs[i].path, pathSuffix) { - n++ - } - } - return n -} - -// ── list-item: happy path with enrichment of items + deleted_items ── - -func TestFeedGroupListItemEnrichesBothLists(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs, - func(path string, _ int) (int, interface{}) { - switch { - case strings.HasSuffix(path, "/list_item"): - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}}, - "deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}}, - "page_token": "", - "has_more": false, - }) - case strings.HasSuffix(path, "/chats/batch_query"): - return 200, wrapData(map[string]interface{}{"items": []interface{}{ - map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"}, - map[string]interface{}{"chat_id": "oc_def", "name": "Old Channel"}, - }}) - } - return 200, wrapData(map[string]interface{}{}) - }) - - if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute returned error: %v", err) - } - - list := findFGRequest(reqs, "/list_item") - if list == nil { - t.Fatal("expected list_item request") - } - if list.method != http.MethodGet { - t.Errorf("list_item method = %s, want GET", list.method) - } - if !strings.HasSuffix(list.path, "/open-apis/im/v1/groups/ofg_x/list_item") { - t.Errorf("list_item path = %s", list.path) - } - if findFGRequest(reqs, "/chats/batch_query") == nil { - t.Error("expected chats/batch_query enrichment request") - } -} - -// ── list-item: empty items skips enrichment ── - -func TestFeedGroupListItemEmptySkipsEnrichment(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs, - func(path string, _ int) (int, interface{}) { - if strings.HasSuffix(path, "/list_item") { - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{}, "deleted_items": []interface{}{}, - "page_token": "", "has_more": false, - }) - } - return 200, wrapData(map[string]interface{}{}) - }) - if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute error: %v", err) - } - if findFGRequest(reqs, "/chats/batch_query") != nil { - t.Error("did not expect batch_query when there are no items") - } -} - -// ── list-item: page-all merges across 2 pages, empty deleted serializes as [] ── - -func TestFeedGroupListItemPageAllMerges(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-all": "true"}, &reqs, - func(path string, page int) (int, interface{}) { - if strings.HasSuffix(path, "/list_item") { - if page == 1 { - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}}, - "deleted_items": []interface{}{}, - "page_token": "TKN", "has_more": true, - }) - } - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"feed_id": "oc_b", "feed_type": "chat", "update_time": "2"}}, - "deleted_items": []interface{}{}, - "page_token": "", "has_more": false, - }) - } - if strings.HasSuffix(path, "/chats/batch_query") { - return 200, wrapData(map[string]interface{}{"items": []interface{}{ - map[string]interface{}{"chat_id": "oc_a", "name": "A"}, - map[string]interface{}{"chat_id": "oc_b", "name": "B"}, - }}) - } - return 200, wrapData(map[string]interface{}{}) - }) - if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute error: %v", err) - } - if got := countFGRequests(reqs, "/list_item"); got != 2 { - t.Errorf("expected 2 list_item requests, got %d", got) - } - // Second list_item page must carry the continuation token. - var second *recordedFGRequest - n := 0 - for i := range reqs { - if strings.HasSuffix(reqs[i].path, "/list_item") { - n++ - if n == 2 { - second = &reqs[i] - } - } - } - if second == nil || firstQueryValue(second.query, "page_token") != "TKN" { - t.Errorf("second page token = %q, want TKN", firstQueryValue(second.query, "page_token")) - } -} - -// ── list-item: explicit page-token ignores page-all (single page) ── - -func TestFeedGroupListItemPageTokenIgnoresPageAll(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{ - "feed-group-id": "ofg_x", "page-all": "true", "page-token": "SOMETOKEN", - }, &reqs, func(path string, _ int) (int, interface{}) { - if strings.HasSuffix(path, "/list_item") { - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{}, "deleted_items": []interface{}{}, - "page_token": "NEXT", "has_more": true, - }) - } - return 200, wrapData(map[string]interface{}{}) - }) - if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute error: %v", err) - } - if got := countFGRequests(reqs, "/list_item"); got != 1 { - t.Errorf("expected 1 list_item request (page-token wins), got %d", got) - } - req := findFGRequest(reqs, "/list_item") - if got := firstQueryValue(req.query, "page_token"); got != "SOMETOKEN" { - t.Errorf("page_token query = %q, want SOMETOKEN", got) - } -} - -// ── query-item: builds correct body and enriches ── - -func TestFeedGroupQueryItemBuildsBody(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{ - "feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b", - }, &reqs, func(path string, _ int) (int, interface{}) { - switch { - case strings.HasSuffix(path, "/batch_query_item"): - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}}, - "deleted_items": []interface{}{}, - }) - case strings.HasSuffix(path, "/chats/batch_query"): - return 200, wrapData(map[string]interface{}{"items": []interface{}{ - map[string]interface{}{"chat_id": "oc_a", "name": "Team A"}, - }}) - } - return 200, wrapData(map[string]interface{}{}) - }) - if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute error: %v", err) - } - req := findFGRequest(reqs, "/batch_query_item") - if req == nil { - t.Fatal("expected batch_query_item request") - } - if req.method != http.MethodPost { - t.Errorf("method = %s, want POST", req.method) - } - if !strings.HasSuffix(req.path, "/open-apis/im/v1/groups/ofg_x/batch_query_item") { - t.Errorf("path = %s", req.path) - } - items, ok := req.body["items"].([]interface{}) - if !ok || len(items) != 2 { - t.Fatalf("body items = %#v, want 2 entries", req.body["items"]) - } - first, _ := items[0].(map[string]interface{}) - if first["feed_id"] != "oc_a" || first["feed_type"] != "chat" { - t.Errorf("first item = %#v", first) - } -} - -// ── table output: renders feed_id / chat_name / update_time + summary lines ── - -func TestFeedGroupListItemTableOutput(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, nil, - func(path string, _ int) (int, interface{}) { - switch { - case strings.HasSuffix(path, "/list_item"): - return 200, wrapData(map[string]interface{}{ - "items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}}, - "deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}}, - "page_token": "TKN", "has_more": true, - }) - case strings.HasSuffix(path, "/chats/batch_query"): - return 200, wrapData(map[string]interface{}{"items": []interface{}{ - map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"}, - }}) - } - return 200, wrapData(map[string]interface{}{}) - }) - runtime.Format = "pretty" - - if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute error: %v", err) - } - out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer) - if out == nil { - t.Fatal("stdout buffer missing") - } - got := out.String() - for _, want := range []string{"feed_id", "chat_name", "update_time", "oc_abc", "Release Team", "1 item(s)", "more available", "(1 deleted)"} { - if !strings.Contains(got, want) { - t.Errorf("table output missing %q; got:\n%s", want, got) - } - } - // update_time must be rendered human-readable (RFC3339), not as raw Unix millis. - if strings.Contains(got, "1767196800000") { - t.Errorf("table output should not contain raw millis timestamp; got:\n%s", got) - } - wantTime := time.UnixMilli(1767196800000).Local().Format(time.RFC3339) - if !strings.Contains(got, wantTime) { - t.Errorf("table output should contain formatted update_time %q; got:\n%s", wantTime, got) - } -} - -// ── enrichment graceful degradation: unresolved feed_id keeps no chat_name ── - -func TestEnrichFeedGroupItemsGracefulDegradation(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{ - "feed-group-id": "ofg_x", "feed-id": "oc_known", - }, nil, func(path string, _ int) (int, interface{}) { - if strings.HasSuffix(path, "/chats/batch_query") { - // Only oc_known resolves; oc_gone is absent. - return 200, wrapData(map[string]interface{}{"items": []interface{}{ - map[string]interface{}{"chat_id": "oc_known", "name": "Known"}, - }}) - } - return 200, wrapData(map[string]interface{}{}) - }) - data := map[string]any{ - "items": []any{ - map[string]any{"feed_id": "oc_known", "feed_type": "chat"}, - map[string]any{"feed_id": "oc_gone", "feed_type": "chat"}, - }, - "deleted_items": []any{}, - } - enrichFeedGroupItemsChatName(runtime, data) - items := data["items"].([]any) - known := items[0].(map[string]any) - gone := items[1].(map[string]any) - if known["chat_name"] != "Known" { - t.Errorf("oc_known chat_name = %v, want Known", known["chat_name"]) - } - if _, present := gone["chat_name"]; present { - t.Errorf("oc_gone should not have chat_name, got %v", gone["chat_name"]) - } -} - -// ── validation errors ── - -func TestFeedGroupValidationErrors(t *testing.T) { - cases := []struct { - name string - sc common.Shortcut - flags map[string]string - want string - }{ - {"list missing feed-group-id", ImFeedGroupListItem, map[string]string{}, "--feed-group-id is required"}, - {"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "--page-size must be an integer between 1 and 50"}, - {"list bad page-limit", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-limit": "2000"}, "--page-limit must be an integer between 1 and 1000"}, - {"query missing feed-group-id", ImFeedGroupQueryItem, map[string]string{"feed-id": "oc_a"}, "--feed-group-id is required"}, - {"query missing feed-id", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, "--feed-id is required (comma-separated chat IDs)"}, - {"query blank feed-id tokens", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x", "feed-id": ", ,"}, "--feed-id is required (comma-separated chat IDs)"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - runtime := newFGRuntime(t, tc.sc, tc.flags, nil, nil) - err := tc.sc.Validate(context.Background(), runtime) - if err == nil { - t.Fatalf("expected validation error %q, got nil", tc.want) - } - if !strings.Contains(err.Error(), tc.want) { - t.Errorf("error = %q, want contains %q", err.Error(), tc.want) - } - }) - } -} - -// ── dry-run shapes ── - -func TestFeedGroupListItemDryRun(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{ - "feed-group-id": "ofg_x", "page-size": "10", "start-time": "100", - }, nil, nil) - d := ImFeedGroupListItem.DryRun(context.Background(), runtime) - calls := dryRunCalls(t, d) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %d", len(calls)) - } - if calls[0]["method"] != "GET" { - t.Errorf("method = %v, want GET", calls[0]["method"]) - } - if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/list_item") { - t.Errorf("url = %s", url) - } - params, _ := calls[0]["params"].(map[string]interface{}) - if params["page_size"] != "10" { - t.Errorf("params page_size = %v, want 10", params["page_size"]) - } - if params["start_time"] != "100" { - t.Errorf("params start_time = %v, want 100", params["start_time"]) - } - if desc, _ := calls[0]["desc"].(string); !strings.Contains(desc, "im:chat:read") { - t.Errorf("desc = %q, want chat_name enrichment note", desc) - } -} - -func TestFeedGroupListItemDryRunValidationError(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{}, nil, nil) - d := ImFeedGroupListItem.DryRun(context.Background(), runtime) - m := dryRunJSON(t, d) - errMsg, _ := m["error"].(string) - if errMsg == "" { - t.Fatalf("expected error in dry-run output, got %#v", m) - } - if !strings.Contains(errMsg, "--feed-group-id is required") { - t.Errorf("error = %v", errMsg) - } -} - -func TestFeedGroupQueryItemDryRun(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{ - "feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b", - }, nil, nil) - d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime) - calls := dryRunCalls(t, d) - if len(calls) != 1 { - t.Fatalf("expected 1 call, got %d", len(calls)) - } - if calls[0]["method"] != "POST" { - t.Errorf("method = %v, want POST", calls[0]["method"]) - } - if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/batch_query_item") { - t.Errorf("url = %s", url) - } - body, _ := calls[0]["body"].(map[string]interface{}) - items, _ := body["items"].([]interface{}) - if len(items) != 2 { - t.Fatalf("dry-run body items = %#v, want 2", body["items"]) - } -} - -func TestFeedGroupQueryItemDryRunValidationError(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, nil, nil) - d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime) - m := dryRunJSON(t, d) - if errMsg, _ := m["error"].(string); errMsg == "" { - t.Fatalf("expected error in dry-run output, got %#v", m) - } -} diff --git a/shortcuts/im/im_feed_group_items.go b/shortcuts/im/im_feed_group_items.go deleted file mode 100644 index 9f832a7b..00000000 --- a/shortcuts/im/im_feed_group_items.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package im - -import ( - "fmt" - "io" - "strconv" - "time" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/shortcuts/common" -) - -const ( - // feedGroupReadScope is required to read feed-group items. - feedGroupReadScope = "im:feed_group_v1:read" - // chatReadScope is required to resolve chat_name from feed_id via chats/batch_query. - chatReadScope = "im:chat:read" -) - -// enrichFeedGroupItemsChatName resolves a human-readable chat_name for each feed -// card in data["items"] and data["deleted_items"] using chats/batch_query. -// -// The feed_id of a v1 feed card is always a chat ID (oc_xxx), so the chat's name -// is the natural display label. Resolution degrades gracefully: any feed_id that -// cannot be resolved simply keeps no chat_name key, and the function never returns -// an error or alters the exit code. -// -// NOTE: This mutates the item maps in place by adding a "chat_name" key. -func enrichFeedGroupItemsChatName(rt *common.RuntimeContext, data map[string]any) { - if data == nil { - return - } - - items, _ := data["items"].([]any) - deletedItems, _ := data["deleted_items"].([]any) - - // Collect deduped, ordered feed_id strings from both lists. - ids := make([]string, 0, len(items)+len(deletedItems)) - seen := make(map[string]bool) - collect := func(list []any) { - for _, it := range list { - m, _ := it.(map[string]any) - if m == nil { - continue - } - id, _ := m["feed_id"].(string) - if id == "" || seen[id] { - continue - } - seen[id] = true - ids = append(ids, id) - } - } - collect(items) - collect(deletedItems) - - if len(ids) == 0 { - return - } - - contexts := batchQueryChatContexts(rt, ids) - if len(contexts) == 0 { - // We had feed_ids to resolve but got nothing back — most likely the - // chats/batch_query call failed (it degrades silently). Tell the user so - // an empty chat_name column is not mistaken for chats that simply have no name. - fmt.Fprintf(rt.IO().ErrOut, "warning: could not resolve chat names for %d feed(s); chat_name will be empty\n", len(ids)) - return - } - - apply := func(list []any) { - for _, it := range list { - m, _ := it.(map[string]any) - if m == nil { - continue - } - id, _ := m["feed_id"].(string) - if id == "" { - continue - } - if ctx, ok := contexts[id]; ok { - if name, _ := ctx["name"].(string); name != "" { - m["chat_name"] = name - } - } - } - } - apply(items) - apply(deletedItems) -} - -// renderFeedGroupItemsTable prints the active items[] as a table (feed_id / -// chat_name / update_time), followed by a summary line. When hasMore is true a -// pagination hint is appended; when there are deleted items their count is noted. -func renderFeedGroupItemsTable(w io.Writer, data map[string]any, hasMore bool) { - items, _ := data["items"].([]any) - rows := make([]map[string]interface{}, 0, len(items)) - for _, it := range items { - m, _ := it.(map[string]any) - if m == nil { - continue - } - chatName, _ := m["chat_name"].(string) - updateTime, _ := m["update_time"].(string) - feedID, _ := m["feed_id"].(string) - rows = append(rows, map[string]interface{}{ - "feed_id": feedID, - "chat_name": chatName, - "update_time": formatFeedGroupUpdateTime(updateTime), - }) - } - output.PrintTable(w, rows) - - moreHint := "" - if hasMore { - moreHint = " (more available, use --page-token to fetch next page)" - } - fmt.Fprintf(w, "\n%d item(s)%s\n", len(items), moreHint) - - if deleted, _ := data["deleted_items"].([]any); len(deleted) > 0 { - fmt.Fprintf(w, "(%d deleted)\n", len(deleted)) - } -} - -// formatFeedGroupUpdateTime renders a Unix-millisecond timestamp string as a -// human-readable local time for the pretty table. The raw value is returned -// unchanged when it is empty or not a valid millisecond integer, so JSON output -// (which never calls this) keeps the original wire value. -func formatFeedGroupUpdateTime(raw string) string { - if raw == "" { - return raw - } - ms, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return raw - } - return time.UnixMilli(ms).Local().Format(time.RFC3339) -} diff --git a/shortcuts/im/im_feed_group_list.go b/shortcuts/im/im_feed_group_list.go deleted file mode 100644 index c69354d9..00000000 --- a/shortcuts/im/im_feed_group_list.go +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package im - -import ( - "context" - "fmt" - "io" - "strconv" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/shortcuts/common" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" -) - -const feedGroupListPath = "/open-apis/im/v1/groups" - -// ImFeedGroupList provides the +feed-group-list shortcut: it lists the caller's -// feed groups (tags) with auto-pagination that correctly merges BOTH the live -// (groups) and soft-deleted (deleted_groups) lists across pages. -// -// The raw `feed.groups list --page-all` goes through the generic paginator, -// which follows only one array field and silently drops the other list's later -// pages; this shortcut paginates the dual-list response itself. -var ImFeedGroupList = common.Shortcut{ - Service: "im", - Command: "+feed-group-list", - Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination", - Risk: "read", - UserScopes: []string{feedGroupReadScope}, - AuthTypes: []string{"user"}, - HasFormat: true, - Flags: []common.Flag{ - {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, - {Name: "page-token", Desc: "pagination token for next page"}, - {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"}, - {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"}, - {Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"}, - {Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - return validateFeedGroupListPageOptions(runtime) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - if err := validateFeedGroupListPageOptions(runtime); err != nil { - return common.NewDryRunAPI().Set("error", err.Error()) - } - return common.NewDryRunAPI(). - GET(feedGroupListPath). - Params(feedGroupListGroupsDryRunParams(runtime)) - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - // When --page-token is explicitly provided, the user wants a specific - // page — no auto-pagination regardless of --page-all. - if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") { - return executeFeedGroupListGroupsAllPages(runtime) - } - - data, err := runtime.DoAPIJSON("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil) - if err != nil { - return err - } - - hasMore, _ := data["has_more"].(bool) - runtime.OutFormat(data, nil, func(w io.Writer) { - renderFeedGroupsTable(w, data, hasMore) - }) - return nil - }, -} - -func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error { - if n := rt.Int("page-size"); n < 1 || n > 50 { - return output.ErrValidation("--page-size must be an integer between 1 and 50") - } - if n := rt.Int("page-limit"); n < 1 || n > 1000 { - return output.ErrValidation("--page-limit must be an integer between 1 and 1000") - } - if v := rt.Str("start-time"); v != "" { - if _, err := strconv.ParseInt(v, 10, 64); err != nil { - return output.ErrValidation("--start-time must be Unix milliseconds (a decimal integer string)") - } - } - if v := rt.Str("end-time"); v != "" { - if _, err := strconv.ParseInt(v, 10, 64); err != nil { - return output.ErrValidation("--end-time must be Unix milliseconds (a decimal integer string)") - } - } - return nil -} - -// feedGroupListGroupsQuery builds the query parameters. page_token is always -// sent (empty string = first page) because the groups endpoint rejects requests -// that omit it (HTTP 400 "Missing required parameter: page_token"). -func feedGroupListGroupsQuery(rt *common.RuntimeContext) larkcore.QueryParams { - params := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, - "page_token": []string{rt.Str("page-token")}, - } - if start := rt.Str("start-time"); start != "" { - params["start_time"] = []string{start} - } - if end := rt.Str("end-time"); end != "" { - params["end_time"] = []string{end} - } - return params -} - -// feedGroupListGroupsDryRunParams mirrors feedGroupListGroupsQuery for dry-run display. -func feedGroupListGroupsDryRunParams(rt *common.RuntimeContext) map[string]any { - params := map[string]any{ - "page_size": strconv.Itoa(rt.Int("page-size")), - "page_token": rt.Str("page-token"), - } - if start := rt.Str("start-time"); start != "" { - params["start_time"] = start - } - if end := rt.Str("end-time"); end != "" { - params["end_time"] = end - } - return params -} - -// executeFeedGroupListGroupsAllPages fetches all pages and merges both the live -// (groups) and soft-deleted (deleted_groups) lists into a single response. It -// merges each array independently so neither list loses its later pages. -func executeFeedGroupListGroupsAllPages(rt *common.RuntimeContext) error { - maxPages := rt.Int("page-limit") - if maxPages < 1 { - maxPages = 20 - } - if maxPages > 1000 { - maxPages = 1000 - } - - // Use make([]any, 0) so empty arrays serialize as [] not null. - allGroups := make([]any, 0) - allDeletedGroups := make([]any, 0) - var lastHasMore bool - var lastPageToken string - prevPageToken := "__START__" - - for page := 0; page < maxPages; page++ { - // page_token is always sent (empty on the first page) — the groups - // endpoint rejects requests that omit it. - params := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, - "page_token": []string{""}, - } - if page > 0 { - params["page_token"] = []string{lastPageToken} - } - if start := rt.Str("start-time"); start != "" { - params["start_time"] = []string{start} - } - if end := rt.Str("end-time"); end != "" { - params["end_time"] = []string{end} - } - - data, err := rt.DoAPIJSON("GET", feedGroupListPath, params, nil) - if err != nil { - return err - } - - if v, ok := data["groups"].([]any); ok { - allGroups = append(allGroups, v...) - } - if v, ok := data["deleted_groups"].([]any); ok { - allDeletedGroups = append(allDeletedGroups, v...) - } - - lastHasMore, _ = data["has_more"].(bool) - lastPageToken, _ = data["page_token"].(string) - - fmt.Fprintf(rt.IO().ErrOut, "page %d: %d groups, %d deleted\n", - page+1, len(allGroups), len(allDeletedGroups)) - - if !lastHasMore || lastPageToken == "" { - break - } - if lastPageToken == prevPageToken { - fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n") - break - } - prevPageToken = lastPageToken - } - - merged := map[string]any{ - "groups": allGroups, - "deleted_groups": allDeletedGroups, - "has_more": lastHasMore, - "page_token": lastPageToken, - } - - rt.OutFormat(merged, nil, func(w io.Writer) { - renderFeedGroupsTable(w, merged, lastHasMore) - }) - return nil -} - -// renderFeedGroupsTable prints the active groups[] as a table (group_id / name / -// type), followed by a summary line. When hasMore is true a pagination hint is -// appended; when there are deleted groups their count is noted. -func renderFeedGroupsTable(w io.Writer, data map[string]any, hasMore bool) { - groups, _ := data["groups"].([]any) - rows := make([]map[string]interface{}, 0, len(groups)) - for _, g := range groups { - m, _ := g.(map[string]any) - if m == nil { - continue - } - id, _ := m["group_id"].(string) - name, _ := m["name"].(string) - typ, _ := m["type"].(string) - rows = append(rows, map[string]interface{}{ - "group_id": id, - "name": name, - "type": typ, - }) - } - output.PrintTable(w, rows) - - moreHint := "" - if hasMore { - moreHint = " (more available, use --page-token to fetch next page)" - } - fmt.Fprintf(w, "\n%d group(s)%s\n", len(groups), moreHint) - - if deleted, _ := data["deleted_groups"].([]any); len(deleted) > 0 { - fmt.Fprintf(w, "(%d deleted)\n", len(deleted)) - } -} diff --git a/shortcuts/im/im_feed_group_list_item.go b/shortcuts/im/im_feed_group_list_item.go deleted file mode 100644 index f4e0d808..00000000 --- a/shortcuts/im/im_feed_group_list_item.go +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package im - -import ( - "context" - "fmt" - "io" - "strconv" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" -) - -// ImFeedGroupListItem provides the +feed-group-list-item shortcut: it lists the -// feed cards inside one feed group and enriches each item with chat_name resolved -// from its feed_id. -var ImFeedGroupListItem = common.Shortcut{ - Service: "im", - Command: "+feed-group-list-item", - Description: "List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination", - Risk: "read", - UserScopes: []string{feedGroupReadScope, chatReadScope}, - AuthTypes: []string{"user"}, - HasFormat: true, - Flags: []common.Flag{ - {Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"}, - {Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"}, - {Name: "page-token", Desc: "pagination token for next page"}, - {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"}, - {Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"}, - {Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"}, - {Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - return validateFeedGroupListOptions(runtime) - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - if err := validateFeedGroupListOptions(runtime); err != nil { - return common.NewDryRunAPI().Set("error", err.Error()) - } - return common.NewDryRunAPI(). - GET(feedGroupListItemPath(runtime)). - Params(feedGroupListDryRunParams(runtime)). - Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read") - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - // When --page-token is explicitly provided, the user wants a specific page — - // no auto-pagination regardless of --page-all. - if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") { - return executeFeedGroupListAllPages(runtime) - } - - data, err := runtime.DoAPIJSON("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil) - if err != nil { - return err - } - enrichFeedGroupItemsChatName(runtime, data) - - hasMore, _ := data["has_more"].(bool) - runtime.OutFormat(data, nil, func(w io.Writer) { - renderFeedGroupItemsTable(w, data, hasMore) - }) - return nil - }, -} - -func validateFeedGroupListOptions(rt *common.RuntimeContext) error { - if rt.Str("feed-group-id") == "" { - return output.ErrValidation("--feed-group-id is required") - } - if n := rt.Int("page-size"); n < 1 || n > 50 { - return output.ErrValidation("--page-size must be an integer between 1 and 50") - } - if n := rt.Int("page-limit"); n < 1 || n > 1000 { - return output.ErrValidation("--page-limit must be an integer between 1 and 1000") - } - if v := rt.Str("start-time"); v != "" { - if _, err := strconv.ParseInt(v, 10, 64); err != nil { - return output.ErrValidation("--start-time must be Unix milliseconds (a decimal integer string)") - } - } - if v := rt.Str("end-time"); v != "" { - if _, err := strconv.ParseInt(v, 10, 64); err != nil { - return output.ErrValidation("--end-time must be Unix milliseconds (a decimal integer string)") - } - } - return nil -} - -// feedGroupListItemPath builds the list_item endpoint path with the feed_group_id -// segment safely encoded. -func feedGroupListItemPath(rt *common.RuntimeContext) string { - return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/list_item" -} - -// feedGroupListQuery builds the query parameters, sending only non-empty values. -func feedGroupListQuery(rt *common.RuntimeContext) larkcore.QueryParams { - params := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, - } - if token := rt.Str("page-token"); token != "" { - params["page_token"] = []string{token} - } - if start := rt.Str("start-time"); start != "" { - params["start_time"] = []string{start} - } - if end := rt.Str("end-time"); end != "" { - params["end_time"] = []string{end} - } - return params -} - -// feedGroupListDryRunParams mirrors feedGroupListQuery for dry-run display. -func feedGroupListDryRunParams(rt *common.RuntimeContext) map[string]any { - params := map[string]any{ - "page_size": strconv.Itoa(rt.Int("page-size")), - } - if token := rt.Str("page-token"); token != "" { - params["page_token"] = token - } - if start := rt.Str("start-time"); start != "" { - params["start_time"] = start - } - if end := rt.Str("end-time"); end != "" { - params["end_time"] = end - } - return params -} - -// executeFeedGroupListAllPages fetches all pages and merges items/deleted_items -// into a single response, then enriches the merged result. -func executeFeedGroupListAllPages(rt *common.RuntimeContext) error { - maxPages := rt.Int("page-limit") - if maxPages < 1 { - maxPages = 20 - } - if maxPages > 1000 { - maxPages = 1000 - } - - // Use make([]any, 0) so empty arrays serialize as [] not null. - allItems := make([]any, 0) - allDeletedItems := make([]any, 0) - var lastHasMore bool - var lastPageToken string - prevPageToken := "__START__" - - for page := 0; page < maxPages; page++ { - params := larkcore.QueryParams{ - "page_size": []string{strconv.Itoa(rt.Int("page-size"))}, - } - if page > 0 { - params["page_token"] = []string{lastPageToken} - } - if start := rt.Str("start-time"); start != "" { - params["start_time"] = []string{start} - } - if end := rt.Str("end-time"); end != "" { - params["end_time"] = []string{end} - } - - data, err := rt.DoAPIJSON("GET", feedGroupListItemPath(rt), params, nil) - if err != nil { - return err - } - - if v, ok := data["items"].([]any); ok { - allItems = append(allItems, v...) - } - if v, ok := data["deleted_items"].([]any); ok { - allDeletedItems = append(allDeletedItems, v...) - } - - lastHasMore, _ = data["has_more"].(bool) - lastPageToken, _ = data["page_token"].(string) - - fmt.Fprintf(rt.IO().ErrOut, "page %d: %d items, %d deleted\n", - page+1, len(allItems), len(allDeletedItems)) - - if !lastHasMore || lastPageToken == "" { - break - } - if lastPageToken == prevPageToken { - fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n") - break - } - prevPageToken = lastPageToken - } - - merged := map[string]any{ - "items": allItems, - "deleted_items": allDeletedItems, - "has_more": lastHasMore, - "page_token": lastPageToken, - } - - enrichFeedGroupItemsChatName(rt, merged) - - rt.OutFormat(merged, nil, func(w io.Writer) { - renderFeedGroupItemsTable(w, merged, lastHasMore) - }) - return nil -} diff --git a/shortcuts/im/im_feed_group_list_test.go b/shortcuts/im/im_feed_group_list_test.go deleted file mode 100644 index b6b5411e..00000000 --- a/shortcuts/im/im_feed_group_list_test.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package im - -import ( - "bytes" - "context" - "encoding/json" - "testing" -) - -func fgGroup(id string) map[string]interface{} { - return map[string]interface{}{"group_id": id, "name": id, "type": "normal"} -} - -// TestFeedGroupListPageAllMergesBothLists is the core regression for the -// +feed-group-list shortcut: a dual-list response (groups + deleted_groups) must -// have BOTH lists merged across pages — including active groups that appear only -// on a later page. This is what the raw `feed.groups list --page-all` gets wrong. -func TestFeedGroupListPageAllMergesBothLists(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-all": "true", "page-size": "5"}, &reqs, - func(_ string, page int) (int, interface{}) { - if page == 1 { - // page 1 fills up with mostly deleted groups; the active groups - // g1/g2 here plus one more (g3) on page 2. - return 200, wrapData(map[string]interface{}{ - "groups": []interface{}{fgGroup("g1"), fgGroup("g2")}, - "deleted_groups": []interface{}{fgGroup("d1"), fgGroup("d2"), fgGroup("d3")}, - "page_token": "TKN", "has_more": true, - }) - } - return 200, wrapData(map[string]interface{}{ - "groups": []interface{}{fgGroup("g3")}, - "deleted_groups": []interface{}{fgGroup("d4")}, - "page_token": "", "has_more": false, - }) - }) - - if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute: %v", err) - } - - if got := countFGRequests(reqs, "/groups"); got != 2 { - t.Fatalf("expected 2 groups requests, got %d", got) - } - if got := firstQueryValue(reqs[1].query, "page_token"); got != "TKN" { - t.Errorf("second page token = %q, want TKN", got) - } - - out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer) - if out == nil { - t.Fatal("stdout buffer missing") - } - var parsed map[string]interface{} - if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { - t.Fatalf("output not JSON: %v\n%s", err, out.String()) - } - data, _ := parsed["data"].(map[string]interface{}) - if got := len(data["groups"].([]interface{})); got != 3 { - t.Errorf("merged groups = %d, want 3 (active list must include later pages)", got) - } - if got := len(data["deleted_groups"].([]interface{})); got != 4 { - t.Errorf("merged deleted_groups = %d, want 4 (secondary list must also merge)", got) - } -} - -// TestFeedGroupListAlwaysSendsPageToken locks the fix for the groups endpoint's -// requirement that page_token be present even on the first page (HTTP 400 -// "Missing required parameter: page_token" otherwise). -func TestFeedGroupListAlwaysSendsPageToken(t *testing.T) { - var reqs []recordedFGRequest - runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-size": "10"}, &reqs, - func(_ string, _ int) (int, interface{}) { - return 200, wrapData(map[string]interface{}{ - "groups": []interface{}{}, "deleted_groups": []interface{}{}, - "page_token": "", "has_more": false, - }) - }) - - if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil { - t.Fatalf("Execute: %v", err) - } - req := findFGRequest(reqs, "/groups") - if req == nil { - t.Fatal("no /groups request recorded") - } - if _, ok := req.query["page_token"]; !ok { - t.Errorf("first request must carry page_token query param (empty = first page); query=%v", req.query) - } -} - -// TestFeedGroupListValidation checks flag validation surfaces clear errors. -func TestFeedGroupListValidation(t *testing.T) { - cases := []struct { - name string - flags map[string]string - want string - }{ - {"page-size too small", map[string]string{"page-size": "0"}, "--page-size"}, - {"page-size too large", map[string]string{"page-size": "51"}, "--page-size"}, - {"page-limit too large", map[string]string{"page-limit": "1001"}, "--page-limit"}, - {"bad start-time", map[string]string{"start-time": "notnum"}, "--start-time"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - runtime := newFGRuntime(t, ImFeedGroupList, tc.flags, nil, nil) - err := ImFeedGroupList.Validate(context.Background(), runtime) - if err == nil { - t.Fatalf("expected validation error containing %q, got nil", tc.want) - } - if !bytes.Contains([]byte(err.Error()), []byte(tc.want)) { - t.Errorf("error = %q, want substring %q", err.Error(), tc.want) - } - }) - } -} diff --git a/shortcuts/im/im_feed_group_query_item.go b/shortcuts/im/im_feed_group_query_item.go deleted file mode 100644 index d35bebe7..00000000 --- a/shortcuts/im/im_feed_group_query_item.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package im - -import ( - "context" - "io" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/shortcuts/common" -) - -// ImFeedGroupQueryItem provides the +feed-group-query-item shortcut: it looks up -// specific feed cards in a feed group by ID and enriches each item with chat_name -// resolved from its feed_id. -var ImFeedGroupQueryItem = common.Shortcut{ - Service: "im", - Command: "+feed-group-query-item", - Description: "Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id", - Risk: "read", - UserScopes: []string{feedGroupReadScope, chatReadScope}, - AuthTypes: []string{"user"}, - HasFormat: true, - Flags: []common.Flag{ - {Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"}, - {Name: "feed-id", Desc: "comma-separated chat IDs (oc_xxx); feed_type is fixed to chat (required)"}, - }, - Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, err := buildFeedGroupQueryItemBody(runtime) - return err - }, - DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body, err := buildFeedGroupQueryItemBody(runtime) - if err != nil { - return common.NewDryRunAPI().Set("error", err.Error()) - } - return common.NewDryRunAPI(). - POST(feedGroupQueryItemPath(runtime)). - Body(body). - Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read") - }, - Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - body, err := buildFeedGroupQueryItemBody(runtime) - if err != nil { - return err - } - - data, err := runtime.DoAPIJSON("POST", feedGroupQueryItemPath(runtime), nil, body) - if err != nil { - return err - } - enrichFeedGroupItemsChatName(runtime, data) - - runtime.OutFormat(data, nil, func(w io.Writer) { - renderFeedGroupItemsTable(w, data, false) - }) - return nil - }, -} - -// feedGroupQueryItemPath builds the batch_query_item endpoint path with the -// feed_group_id segment safely encoded. -func feedGroupQueryItemPath(rt *common.RuntimeContext) string { - return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/batch_query_item" -} - -// buildFeedGroupQueryItemBody validates the flags and constructs the request body -// {"items":[{"feed_id":"","feed_type":"chat"}, ...]}. -func buildFeedGroupQueryItemBody(rt *common.RuntimeContext) (map[string]any, error) { - if rt.Str("feed-group-id") == "" { - return nil, output.ErrValidation("--feed-group-id is required") - } - tokens := common.SplitCSV(rt.Str("feed-id")) - items := make([]any, 0, len(tokens)) - for _, tok := range tokens { - if tok == "" { - continue - } - items = append(items, map[string]any{ - "feed_id": tok, - "feed_type": "chat", - }) - } - if len(items) == 0 { - return nil, output.ErrValidation("--feed-id is required (comma-separated chat IDs)") - } - return map[string]any{"items": items}, nil -} diff --git a/shortcuts/im/im_flag_cancel.go b/shortcuts/im/im_flag_cancel.go index 0c6c9cee..6e3b0776 100644 --- a/shortcuts/im/im_flag_cancel.go +++ b/shortcuts/im/im_flag_cancel.go @@ -15,13 +15,14 @@ import ( // ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark. // When no --flag-type is given, it performs double-cancel: removes both message and feed layers. var ImFlagCancel = common.Shortcut{ - Service: "im", - Command: "+flag-cancel", - Description: "Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer", - Risk: "write", - UserScopes: flagWriteLookupScopes, - AuthTypes: []string{"user"}, - HasFormat: true, + Service: "im", + Command: "+flag-cancel", + Description: "Cancel (remove) a bookmark. When no --flag-type is given, " + + "performs double-cancel: removes both message and feed layers", + Risk: "write", + UserScopes: flagWriteLookupScopes, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "message-id", Desc: "message ID (om_xxx)"}, {Name: "item-type", Desc: "item type override: default|thread|msg_thread"}, diff --git a/shortcuts/im/im_flag_create.go b/shortcuts/im/im_flag_create.go index c45cbae8..52e90ee1 100644 --- a/shortcuts/im/im_flag_create.go +++ b/shortcuts/im/im_flag_create.go @@ -16,7 +16,7 @@ import ( var ImFlagCreate = common.Shortcut{ Service: "im", Command: "+flag-create", - Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode)", + Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)", Risk: "write", UserScopes: flagWriteLookupScopes, AuthTypes: []string{"user"}, diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 1aff3f06..2df2aa48 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -25,8 +25,5 @@ func Shortcuts() []common.Shortcut { ImFeedShortcutCreate, ImFeedShortcutRemove, ImFeedShortcutList, - ImFeedGroupList, - ImFeedGroupListItem, - ImFeedGroupQueryItem, } } diff --git a/skill-template/domains/im.md b/skill-template/domains/im.md index 09dd967d..83c65679 100644 --- a/skill-template/domains/im.md +++ b/skill-template/domains/im.md @@ -6,7 +6,6 @@ - **Reaction**: An emoji reaction on a message. - **Flag**: A bookmark on a message or thread. - **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type). -- **Feed Group**: A tag that groups feed cards in the feed list, identified by `feed_group_id` (ofg_xxx). Members are feed cards, each identified by `feed_id` + `feed_type`. Two types: `normal` (members managed explicitly) and `rule` (members auto-derived from rules). ## Resource Relationships diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 8b93e142..3c0ae0fe 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-im version: 1.0.0 -description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。" +description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)时使用。" metadata: requires: bins: ["lark-cli"] @@ -20,7 +20,6 @@ metadata: - **Reaction**: An emoji reaction on a message. - **Flag**: A bookmark on a message or thread. - **Feed Shortcut**: A chat pinned to the current user's feed sidebar, identified by `feed_card_id` (an `oc_xxx` open_chat_id for CHAT type). -- **Feed Group**: A tag that groups feed cards in the feed list, identified by `feed_group_id` (ofg_xxx). Members are feed cards, each identified by `feed_id` + `feed_type`. Two types: `normal` (members managed explicitly) and `rule` (members auto-derived from rules). ## Resource Relationships @@ -97,15 +96,12 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 | [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query | | [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key | | [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination | -| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode) | -| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer | +| [`+flag-create`](references/lark-im-flag-create.md) | Create a bookmark on a message or thread; user-only; defaults to message-layer flag; feed-layer flag requires explicit --item-type + --flag-type | +| [`+flag-cancel`](references/lark-im-flag-cancel.md) | Cancel (remove) a bookmark. When no --flag-type is given, checks if the message is a thread root message; if so, cancels both message and feed layers | | [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; supports `--page-all` auto-pagination | | [`+feed-shortcut-create`](references/lark-im-feed-shortcut-create.md) | Add chats to the user's feed shortcuts; user-only; oc_xxx chat IDs only; batch up to 10 per call; `--head`/`--tail` controls insertion order; partial failures return an `ok:false` ledger | | [`+feed-shortcut-remove`](references/lark-im-feed-shortcut-remove.md) | Remove chats from the user's feed shortcuts; user-only; batch up to 10 per call; removing an absent shortcut is idempotent success; real per-item failures return an `ok:false` ledger | | [`+feed-shortcut-list`](references/lark-im-feed-shortcut-list.md) | List one page of the user's feed shortcuts; user-only; omit `--page-token` for the first page; default output enriches CHAT entries under `detail`; pass `--no-detail` to skip the extra lookup and `im:chat:read` scope | -| [`+feed-group-list`](references/lark-im-feed-group-list.md) | List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination | -| [`+feed-group-list-item`](references/lark-im-feed-group-list-item.md) | List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination | -| [`+feed-group-query-item`](references/lark-im-feed-group-query-item.md) | Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id | ## API Resources @@ -161,15 +157,6 @@ lark-cli im [flags] # 调用 API - `delete` — 移除 Pin 消息。Identity: supports `user` and `bot`. - `list` — 获取群内 Pin 消息。Identity: supports `user` and `bot`. -### feed.groups - - - `batch_add_item` — Batch add feed cards to a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) - - `batch_query` — Batch query feed groups. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) - - `batch_remove_item` — Batch remove feed cards from a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) - - `create` — Create a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) - - `delete` — Delete a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) - - `update` — Update a feed group. Identity: `user` only (`user_access_token`).[Must-read](references/lark-im-feed-groups.md) - ## 权限表 | 方法 | 所需 scope | @@ -198,9 +185,3 @@ lark-cli im [flags] # 调用 API | `pins.create` | `im:message.pins:write_only` | | `pins.delete` | `im:message.pins:write_only` | | `pins.list` | `im:message.pins:read` | -| `feed.groups.batch_add_item` | `im:feed_group_v1:write` | -| `feed.groups.batch_query` | `im:feed_group_v1:read` | -| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` | -| `feed.groups.create` | `im:feed_group_v1:write` | -| `feed.groups.delete` | `im:feed_group_v1:write` | -| `feed.groups.update` | `im:feed_group_v1:write` | diff --git a/skills/lark-im/references/lark-im-feed-group-list-item.md b/skills/lark-im/references/lark-im-feed-group-list-item.md deleted file mode 100644 index b0fc07ee..00000000 --- a/skills/lark-im/references/lark-im-feed-group-list-item.md +++ /dev/null @@ -1,68 +0,0 @@ -# +feed-group-list-item - -> Shortcut for `lark-cli im +feed-group-list-item`. List the feed cards inside one feed group (tag), enriched with a readable `chat_name`. - -`+feed-group-list-item` is the only CLI surface for the `feed.groups.list_item` read API — there is no raw `feed.groups list_item` command. It resolves a human-readable `chat_name` for every feed card it returns: a v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut issues a follow-up `POST /open-apis/im/v1/chats/batch_query` and injects `chat_name` into each entry of both `items[]` and `deleted_items[]`. - -## Identity - -User-only. Run with `--as user`. - -## Scopes - -Because chat-name resolution always runs, this shortcut needs **two** user scopes unconditionally: - -- `im:feed_group_v1:read` — to read the items -- `im:chat:read` — to resolve names - -`chat_name` resolution always runs, so there is no single-scope, un-enriched path. For the other raw `feed.groups.*` methods, see [lark-im-feed-groups.md](lark-im-feed-groups.md). - -## Usage - -```bash -# First page, enriched with chat names -lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx - -# Auto-paginate through everything within a time window -lark-cli im +feed-group-list-item --as user --feed-group-id ofg_xxx \ - --page-all --start-time 1767196800000 --end-time 1767200000000 -``` - -## Flags - -| Flag | Required | Description | -|---|---|---| -| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter | -| `--page-size` | No | Records per page, 1–50 (default 50) | -| `--page-token` | No | Continuation token for a specific page | -| `--page-all` | No | Auto-paginate and merge all pages | -| `--page-limit` | No | Max pages when `--page-all` is set, 1–1000 (default 20) | -| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) | -| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) | - -When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page). - -## Output - -JSON keeps the raw envelope and adds `chat_name` to each resolvable item: - -```json -{ - "items": [ - { "feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000", "chat_name": "Release Team" } - ], - "deleted_items": [ - { "feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000", "chat_name": "Old Channel" } - ], - "page_token": "", - "has_more": false -} -``` - -A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. - -## See also - -- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance -- [lark-im-feed-group-list.md](lark-im-feed-group-list.md) — list your feed groups -- [lark-im-feed-group-query-item.md](lark-im-feed-group-query-item.md) — look up specific feed cards by ID diff --git a/skills/lark-im/references/lark-im-feed-group-list.md b/skills/lark-im/references/lark-im-feed-group-list.md deleted file mode 100644 index bc5aca82..00000000 --- a/skills/lark-im/references/lark-im-feed-group-list.md +++ /dev/null @@ -1,65 +0,0 @@ -# +feed-group-list - -> Shortcut for `lark-cli im +feed-group-list`. List the caller's feed groups (tags) with auto-pagination that correctly merges both the live and soft-deleted lists. - -`+feed-group-list` is the only CLI surface for listing feed groups — there is no raw `feed.groups list` command. The list response carries two parallel arrays — `groups` (live) and `deleted_groups` (soft-deleted). The shortcut paginates this dual-list response correctly: its `--page-all` merges **both** arrays across pages (a naive single-array pager would silently drop one list's later pages). It adds no enrichment. - -## Identity - -User-only. Run with `--as user`. - -## Scopes - -- `im:feed_group_v1:read` - -## Usage - -```bash -# First page -lark-cli im +feed-group-list --as user - -# Auto-paginate through all your feed groups (both live and deleted) -lark-cli im +feed-group-list --as user --page-all - -# Within an update-time window -lark-cli im +feed-group-list --as user --page-all \ - --start-time 1767196800000 --end-time 1767200000000 -``` - -## Flags - -| Flag | Required | Description | -|---|---|---| -| `--page-size` | No | Records per page, 1–50 (default 50). Caps the combined `groups` + `deleted_groups` count, so a page may hold fewer live groups than the size suggests | -| `--page-token` | No | Continuation token for a specific page | -| `--page-all` | No | Auto-paginate and merge all pages (both lists) | -| `--page-limit` | No | Max pages when `--page-all` is set, 1–1000 (default 20) | -| `--start-time` | No | Update-time window start (Unix milliseconds as a decimal string) | -| `--end-time` | No | Update-time window end (Unix milliseconds as a decimal string) | - -When `--page-token` is set explicitly, it wins over `--page-all` (you get exactly that page). - -## Output - -JSON keeps the raw envelope; with `--page-all` both lists are returned fully merged: - -```json -{ - "groups": [ - { "group_id": "ofg_xxx", "type": "normal", "name": "Releases", "rules": { "rules": [] } } - ], - "deleted_groups": [ - { "group_id": "ofg_yyy", "type": "rule", "name": "Old", "rules": { "rules": [] } } - ], - "page_token": "", - "has_more": false -} -``` - -> `page_size` counts live and deleted groups together, and the per-page count can be smaller still when entries are filtered — so never infer completeness from counts. Pagination is governed solely by `has_more`. - -## See also - -- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance -- [lark-im-feed-group-list-item.md](lark-im-feed-group-list-item.md) — list the feed cards inside one group -- [lark-im-feed-group-query-item.md](lark-im-feed-group-query-item.md) — look up specific feed cards by ID diff --git a/skills/lark-im/references/lark-im-feed-group-query-item.md b/skills/lark-im/references/lark-im-feed-group-query-item.md deleted file mode 100644 index 8dcf99fb..00000000 --- a/skills/lark-im/references/lark-im-feed-group-query-item.md +++ /dev/null @@ -1,44 +0,0 @@ -# +feed-group-query-item - -> Shortcut for `lark-cli im +feed-group-query-item`. Look up specific feed cards inside one feed group (tag) by ID, enriched with a readable `chat_name`. - -`+feed-group-query-item` is the only CLI surface for the `feed.groups.batch_query_item` read API — there is no raw `feed.groups batch_query_item` command. It resolves a human-readable `chat_name` for every feed card it returns: a v1 feed card's `feed_id` is always a chat ID (`oc_xxx`), so the shortcut issues a follow-up `POST /open-apis/im/v1/chats/batch_query` and injects `chat_name` into each entry of both `items[]` and `deleted_items[]`. - -## Identity - -User-only. Run with `--as user`. - -## Scopes - -Because chat-name resolution always runs, this shortcut needs **two** user scopes unconditionally: - -- `im:feed_group_v1:read` — to read the items -- `im:chat:read` — to resolve names - -`chat_name` resolution always runs, so there is no single-scope, un-enriched path. For the other raw `feed.groups.*` methods, see [lark-im-feed-groups.md](lark-im-feed-groups.md). - -## Usage - -```bash -lark-cli im +feed-group-query-item --as user \ - --feed-group-id ofg_xxx --feed-id oc_a,oc_b -``` - -## Flags - -| Flag | Required | Description | -|---|---|---| -| `--feed-group-id` | Yes | Feed group ID (`ofg_xxx`); path parameter | -| `--feed-id` | Yes | Comma-separated chat IDs (`oc_xxx`); `feed_type` is fixed to `chat` | - -## Output - -The command sends `{"items":[{"feed_id":"oc_a","feed_type":"chat"},{"feed_id":"oc_b","feed_type":"chat"}]}`, then enriches the response (`items[]` and `deleted_items[]`) with `chat_name` exactly as `+feed-group-list-item` does. There is no pagination for this method. - -A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. - -## See also - -- [lark-im-feed-groups.md](lark-im-feed-groups.md) — raw `feed.groups.*` APIs, enums, and rule guidance -- [lark-im-feed-group-list.md](lark-im-feed-group-list.md) — list your feed groups -- [lark-im-feed-group-list-item.md](lark-im-feed-group-list-item.md) — list all feed cards in a group (paginated) diff --git a/skills/lark-im/references/lark-im-feed-groups.md b/skills/lark-im/references/lark-im-feed-groups.md deleted file mode 100644 index 818d801a..00000000 --- a/skills/lark-im/references/lark-im-feed-groups.md +++ /dev/null @@ -1,454 +0,0 @@ -# im feed.groups - -> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. - -This reference is the shared annotation target for the IM feed-group (tag) APIs: it documents what each method does, the `--params` / `--data` request and response shapes, and the enum surface used in payloads. The full method list is in [Command Overview](#command-overview) below. - -> **Important:** The six raw commands (`create`, `update`, `delete`, `batch_query`, `batch_add_item`, `batch_remove_item`) take structured input through `--params ''` and `--data ''` rather than typed flags. The three read methods (`list`, `list_item`, `batch_query_item`) are exposed only as typed `+` shortcut wrappers — see [Shortcuts](#shortcuts). All methods are user-only; see [Common Notes](#common-notes). - -> **Picking a read method:** `batch_query` / `+feed-group-query-item` are lightweight ID lookups; `+feed-group-list` / `+feed-group-list-item` paginate the whole set and are much heavier. When you already hold the IDs (`group_id` from `create`, the `feed_id`s you passed to `batch_add_item`), prefer the lightweight lookup. Reserve the list methods for when you actually need to discover IDs you don't have. - -## Command Overview - -| Method | Purpose | -|---|---| -| `feed.groups.create` | Create a new feed group (tag) | -| `feed.groups.update` | Update a feed group's name and/or rules | -| `feed.groups.delete` | Delete one feed group | -| `feed.groups.batch_query` | Look up feed groups by ID list | -| `feed.groups.list` | List the caller's feed groups with optional time-range filter — **CLI: only via `+feed-group-list` shortcut** | -| `feed.groups.batch_add_item` | Add feed cards (chats) into a feed group | -| `feed.groups.batch_remove_item` | Remove feed cards from a feed group | -| `feed.groups.batch_query_item` | Look up feed cards inside a group by ID list — **CLI: only via `+feed-group-query-item` shortcut** | -| `feed.groups.list_item` | List feed cards inside one feed group — **CLI: only via `+feed-group-list-item` shortcut** | - -> HTTP method and path are not duplicated here. For the six raw methods, inspect them with `lark-cli schema im.feed.groups.` when needed; the three shortcut-only read methods (`list`, `list_item`, `batch_query_item`) use typed flags (see their `--help`). - -## Shortcuts - -Three typed `+` shortcuts cover the feed-group read paths. All are user-only. - -| Shortcut | Purpose | Notes | -|---|---|---| -| [`+feed-group-list`](lark-im-feed-group-list.md) | List your feed groups | Its `--page-all` correctly merges the live and soft-deleted lists. No enrichment | -| [`+feed-group-list-item`](lark-im-feed-group-list-item.md) | List the feed cards inside a group | Enriches each card with `chat_name` | -| [`+feed-group-query-item`](lark-im-feed-group-query-item.md) | Look up feed cards in a group by ID | Enriches each card with `chat_name` | - -The two `*-item` shortcuts resolve `chat_name` via a follow-up `chats/batch_query`, so they need `im:chat:read` in addition to `im:feed_group_v1:read`; `+feed-group-list` needs only `im:feed_group_v1:read`. All three are the **only** CLI surface for their methods — `list`, `list_item`, and `batch_query_item` have no raw command; full flags and response shapes live in the shortcut docs linked above. - -## Common Notes - -- `feed_group_id` is the feed-group identifier returned by `create`, typically formatted as `ofg_xxx`. In meta examples it appears as a string; on the wire it is the group's stable ID. -- `feed_id` is the identifier of one feed card inside a group. In v1 only the `chat` feed card type is supported (see `feed_card_type` below), so `feed_id` is currently a chat ID such as `oc_xxx`. -- All `feed.groups.*` methods require `user_access_token`. Run with `--as user`; bot/tenant tokens are rejected. -- Read APIs (`batch_query`, `list`, `batch_query_item`, `list_item`) return **two parallel lists**: a live list (`groups[]` or `items[]`) and a soft-deleted list (`deleted_groups[]` or `deleted_items[]`). Consumers tracking incremental sync should consume both. -- Time-range fields (`start_time`, `end_time`, `update_time`) are Unix timestamps **in milliseconds**, encoded as decimal strings (e.g. `1767196800000`). -- Rule-based feed groups (`type=rule`) auto-populate from the rules declared in `feed_group_creator.rules`. Normal feed groups (`type=normal`) are managed explicitly via `batch_add_item` / `batch_remove_item`. - -> **Choose the simplest group that fits** — it keeps `create` / `update` fast and predictable. Apply these in order: -> 1. **Prefer `type=normal`.** When the target chats are known up front, set membership explicitly with `batch_add_item` / `batch_remove_item`. Use `type=rule` only when membership must be derived automatically. -> 2. **Keep the rule set smallest.** Use the fewest `rules[]` and `condition_items[]` that express the intent (one condition is ideal). This outranks the style rules below — never split a rule or add conditions just to satisfy them (e.g. one `match_any` rule beats two single-condition rules for "A or B"). -> 3. **Within that, make each condition precise.** Prefer positive, specific conditions (`is`, or `contain` with a distinctive keyword) over exclusion (`is_not`, `not_contain`) or broad keywords, which capture more than intended. For a multi-condition rule, prefer `match_all` (narrower) over `match_any` (wider). - -## Inspect Schema - -```bash -lark-cli schema im.feed.groups -lark-cli schema im.feed.groups.create --format pretty -lark-cli schema im.feed.groups.batch_add_item --format pretty -``` - -> `list`, `list_item`, and `batch_query_item` have no raw method schema (they are shortcut-only). Inspect their flags with `lark-cli im +feed-group-list --help` / `+feed-group-list-item --help` / `+feed-group-query-item --help` instead. - -## create - -Create a new feed group. Returns the new `group_id` on success. - -> **Prefer `type=normal`.** Use `type=rule` only when membership must be derived automatically, and keep the rule set small and precise — see the guidance under [Common Notes](#common-notes). - -```bash -# Normal (empty) group -lark-cli im feed.groups create --as user \ - --data '{"feed_group_creator":{"type":"normal","name":"Releases"}}' - -# Rule-based group: auto-add p2p chats with "release" in their name -lark-cli im feed.groups create --as user \ - --data '{ - "feed_group_creator":{ - "type":"rule", - "name":"Auto: release chats", - "rules":{ - "rules":[ - { - "condition":{ - "match_type":"match_all", - "condition_items":[ - {"type":"chat_type","operator":"is","chat_type":"p2p"}, - {"type":"keyword","operator":"contain","keyword":"release"} - ] - }, - "action":"add" - } - ] - } - } - }' -``` - -### Request - -#### `--params` - -| Parameter | Required | Description | -|---|---|---| -| `user_id_type` | No | ID type used when the request body contains `user_id` references inside rules. One of `open_id`, `union_id`, `user_id` | - -#### `--data` - -| Field | Required | Description | -|---|---|---| -| `feed_group_creator.type` | Yes | `normal` (empty group) or `rule` (auto-populated by rules) | -| `feed_group_creator.name` | Yes | Display name, e.g. `"标签名称测试"` | -| `feed_group_creator.rules` | No | Rule object (required when `type=rule`). See `feed_group_rules` section below | - -### Response - -```json -{ - "group_id": "ofg_xxx" -} -``` - -## update - -Update a feed group's name and/or rules. The `update_fields` array tells the server which fields are being updated. - -> **Scope each update to what actually changed.** If you only need to rename, pass `update_fields:[1]` so the rules are left untouched. When you do change rules, the same guidance under [Common Notes](#common-notes) applies to the resulting set. - -```bash -# Rename only -lark-cli im feed.groups update --as user \ - --params '{"feed_group_id":"ofg_xxx"}' \ - --data '{"feed_group_updater":{"name":"测试标签名称","update_fields":[1]}}' - -# Replace rules only (rules array uses the feed_group_rules shape — see that section) -lark-cli im feed.groups update --as user \ - --params '{"feed_group_id":"ofg_xxx"}' \ - --data '{ - "feed_group_updater":{ - "rules":{"rules":[]}, - "update_fields":[2] - } - }' -``` - -### Request - -#### `--params` - -| Parameter | Required | Description | -|---|---|---| -| `feed_group_id` | Yes | Path parameter — the feed group to update | -| `user_id_type` | No | ID type for any `user_id` fields inside `rules` | - -#### `--data` - -| Field | Required | Description | -|---|---|---| -| `feed_group_updater.name` | No | New display name | -| `feed_group_updater.rules` | No | Replacement rule object. Same structure as `create.feed_group_creator.rules` | -| `feed_group_updater.update_fields` | No | Array of integer update markers: `1` = name, `2` = rules. Server applies only the listed fields | - -### Response - -Empty body on success. Inspect the CLI exit code for status. - -## delete - -Delete one feed group. - -```bash -lark-cli im feed.groups delete --as user \ - --params '{"feed_group_id":"ofg_xxx"}' -``` - -### Request - -| Parameter | Required | Description | -|---|---|---| -| `feed_group_id` | Yes | Path parameter — the feed group to delete | - -### Response - -Empty body on success. - -## batch_query - -Look up feed groups by an explicit list of IDs. Returns both live and soft-deleted matches. - -```bash -lark-cli im feed.groups batch_query --as user \ - --params '{"user_id_type":"open_id"}' \ - --data '{"group_ids":["ofg_xxx","ofg_yyy"]}' -``` - -### Request - -#### `--params` - -| Parameter | Required | Description | -|---|---|---| -| `user_id_type` | No | ID type used when the response includes `user_id` references inside `groups[].rules` | - -#### `--data` - -| Field | Required | Description | -|---|---|---| -| `group_ids` | Yes | Array of feed group IDs to look up | - -### Response - -```json -{ - "groups": [ - { - "group_id": "ofg_xxx", - "type": "normal", - "name": "test", - "rules": { "rules": [] } - } - ], - "deleted_groups": [ - { - "group_id": "ofg_yyy", - "type": "rule", - "name": "test", - "rules": { "rules": [] } - } - ] -} -``` - -Each `rules.rules[]` element follows the `feed_group_rules` shape — see that section for the full structure. - -### Top-Level Fields - -| Field | Type | Meaning | -|---|---|---| -| `groups` | `array` | Live feed groups for the requested IDs | -| `deleted_groups` | `array` | Soft-deleted matches, returned for incremental-sync clients | - -Each element carries `group_id`, `type`, `name`, and (when defined) `rules`. - -## list - -Shortcut-only: [`+feed-group-list`](lark-im-feed-group-list.md). Lists the caller's feed groups, optionally filtered by an update-time window. Its `--page-all` correctly merges the live (`groups`) and soft-deleted (`deleted_groups`) lists across pages. There is no raw command — flags and response shape are in the linked shortcut doc. - -## batch_add_item - -Add feed cards (chats) into one feed group. Partial failures are reported in `failed_items`. - -```bash -lark-cli im feed.groups batch_add_item --as user \ - --params '{"feed_group_id":"ofg_xxx"}' \ - --data '{ - "items":[ - {"feed_id":"oc_xxx","feed_type":"chat"}, - {"feed_id":"oc_yyy","feed_type":"chat"} - ] - }' -``` - -### Request - -| Source | Field | Required | Description | -|---|---|---|---| -| `--params` | `feed_group_id` | Yes | Path parameter — the target feed group | -| `--data` | `items[]` | Yes | Array of feed cards to add | -| `--data` | `items[].feed_id` | No | The chat ID to add (e.g. `oc_xxx`) | -| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. | - -> Note: `items[].feed_id` is marked `Required: No` in the meta but every element of `items` must set it — a missing field yields an unusable entry. Always pass `{"feed_id": "oc_xxx", "feed_type": "chat"}` per item. - -### Response - -```json -{ - "failed_items": [ - { - "item": { "feed_id": "oc_xxx", "feed_type": "chat" }, - "error_code": 240001, - "error_message": "feed_id is invalid" - } - ] -} -``` - -| Field | Type | Meaning | -|---|---|---| -| `failed_items` | `array` | Items that failed; absent or empty means all succeeded | -| `failed_items[].item` | `object` | The original `{feed_id, feed_type}` element | -| `failed_items[].error_code` | `integer` | Numeric error code | -| `failed_items[].error_message` | `string` | Human-readable failure reason | - -## batch_remove_item - -Remove feed cards from one feed group. Same request and response shape as `batch_add_item`. - -```bash -lark-cli im feed.groups batch_remove_item --as user \ - --params '{"feed_group_id":"ofg_xxx"}' \ - --data '{ - "items":[ - {"feed_id":"oc_xxx","feed_type":"chat"} - ] - }' -``` - -### Request - -| Source | Field | Required | Description | -|---|---|---|---| -| `--params` | `feed_group_id` | Yes | Path parameter — the target feed group | -| `--data` | `items[]` | Yes | Array of feed cards to remove | -| `--data` | `items[].feed_id` | No | The chat ID to remove | -| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. | - -> Note: same caveat as `batch_add_item` — `items[].feed_id` is `Required: No` per the meta but must be present in practice. - -### Response - -Identical shape to `batch_add_item` — `failed_items[]` lists rows that did not remove cleanly. - -## batch_query_item - -Shortcut-only: [`+feed-group-query-item`](lark-im-feed-group-query-item.md). Looks up feed cards in a group by an explicit ID list and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc. - -## list_item - -Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists the feed cards inside a group (paginated, `--page-all` supported) and enriches each with `chat_name`. There is no raw command — flags and response shape are in the linked shortcut doc. - -## Enums - -The enums below are sourced from the internal datasync IDL (`lark.im.datasync.open.thrift`). All values listed here are exhaustive. - -### `feed_group_type` - -Used in `feed_group_creator.type` and the response `groups[].type`. - -- `normal` — empty group; members managed explicitly via `batch_add_item` / `batch_remove_item`. -- `rule` — auto-populated; `feed_group_creator.rules` must be supplied. - -### `feed_card_type` - -Used in `items[].feed_type` everywhere a feed card appears. Wire type is the open string alias `FeedCardTypeV1`. - -- `chat` — the only value the v1 OAPI service accepts. `feed_id` is therefore a chat ID such as `oc_xxx`. - -The CLI does not pre-validate this field — passing anything other than `chat` reaches the server and is rejected at runtime. Treat `chat` as effectively required. - -### `feed_group_rule_action` - -Used inside `feed_group_rules.rules[].action`. - -- `add` — when the condition matches, add the matching feed into this group. -- `remove` — when the condition matches, remove the matching feed from this group. - -### `feed_group_rule_cond_match_type` - -Used inside `feed_group_rules.rules[].condition.match_type`. - -- `match_all` — every condition item must match. -- `match_any` — at least one condition item must match. - -### `feed_group_rule_cond_item_type` - -Used inside `feed_group_rules.rules[].condition.condition_items[].type`. Determines which sibling field of the item is consulted. - -- `keyword` — match against a keyword; consult the `keyword` field. -- `chatter` — match against a user; consult the `user_id` field (interpreted per the request's `user_id_type`). -- `chat_type` — match against a chat type; consult the `chat_type` field. - -### `feed_group_rule_cond_item_operator` - -Used inside `feed_group_rules.rules[].condition.condition_items[].operator`. Typically paired with the relevant `type`: - -- `contain` — substring match; typically paired with `keyword`. -- `not_contain` — substring non-match; typically paired with `keyword`. -- `is` — equality; typically paired with `chatter` or `chat_type`. -- `is_not` — non-equality; typically paired with `chatter` or `chat_type`. - -### `feed_group_rule_cond_item_chat_type` - -Used inside `feed_group_rules.rules[].condition.condition_items[].chat_type` when `type=chat_type`. - -- `p2p` -- `group` -- `thread_group` -- `helpdesk` -- `bot` -- `mute` -- `flag` -- `cross_tenant` -- `any` - -### `update_fields` - -Used inside `feed_group_updater.update_fields`. Multiple values may be listed. - -- `1` — update name only. -- `2` — update rules only. - -Wire form: integers from the `FeedGroupUpdateField` enum (`1` = name, `2` = rules). The server rejects the lowercase string forms (`"name"`, `"rules"`) with `9499 Invalid parameter value`. Omit the array (or pass an empty array) to make no field updates. - -## feed_group_rules - -The same nested object is used in `feed_group_creator.rules` (create), `feed_group_updater.rules` (update), and in read responses under `groups[].rules`. Shape: - -```json -{ - "rules": [ - { - "condition": { - "match_type": "match_all", - "condition_items": [ - { "type": "chat_type", "operator": "is", "chat_type": "group" }, - { "type": "keyword", "operator": "contain", "keyword": "release" } - ] - }, - "action": "add" - } - ] -} -``` - -Per-`type` required-field legend: - -- `type=keyword` → `keyword` is required; `user_id` and `chat_type` are ignored. -- `type=chatter` → `user_id` is required; the request's `user_id_type` query parameter tells the server how to interpret it. -- `type=chat_type` → `chat_type` is required. - -## Permissions - -| Method | Scope | -|---|---| -| `feed.groups.create` | `im:feed_group_v1:write` | -| `feed.groups.update` | `im:feed_group_v1:write` | -| `feed.groups.delete` | `im:feed_group_v1:write` | -| `feed.groups.batch_query` | `im:feed_group_v1:read` | -| `feed.groups.batch_add_item` | `im:feed_group_v1:write` | -| `feed.groups.batch_remove_item` | `im:feed_group_v1:write` | - -The three read methods are shortcut-only: - -- [`+feed-group-list`](lark-im-feed-group-list.md) — `im:feed_group_v1:read` -- [`+feed-group-list-item`](lark-im-feed-group-list-item.md) / [`+feed-group-query-item`](lark-im-feed-group-query-item.md) — `im:feed_group_v1:read` **plus** `im:chat:read` (they always resolve `chat_name`) - -If a required scope is missing, the CLI surfaces a hint such as `lark-cli auth login --scope "im:feed_group_v1:write"`. - -## References - -- [lark-im](../SKILL.md) — all IM commands -- [lark-shared](../../lark-shared/SKILL.md) — authentication and global parameters -- Design wiki: `https://bytedance.larkoffice.com/wiki/LIdSwrCzaitg3MkH8oScLhBCnFQ` -- IDL source (internal): `lark.im.datasync.open.thrift`