feat: fetch official skills index (#1301)

lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.

Changes:

- Add official skills index JSON parsing in `internal/skillscheck/sync.go`

- Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go`

- Add reason-chain details when both discovery layers fall back to `fallbackFullInstall`

- Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go`

- Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go`

Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
This commit is contained in:
zhangheng023
2026-06-06 18:29:04 +08:00
committed by GitHub
parent 5788a6c384
commit 7c50b3d9e3
21 changed files with 530 additions and 2035 deletions

View File

@@ -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...)

View File

@@ -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 {

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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" {

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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":"<tok>","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
}

View File

@@ -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"},

View File

@@ -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"},

View File

@@ -25,8 +25,5 @@ func Shortcuts() []common.Shortcut {
ImFeedShortcutCreate,
ImFeedShortcutRemove,
ImFeedShortcutList,
ImFeedGroupList,
ImFeedGroupListItem,
ImFeedGroupQueryItem,
}
}

View File

@@ -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

View File

@@ -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 +<verb> [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 <resource> <method> [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 <resource> <method> [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` |

View File

@@ -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, 150 (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, 11000 (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

View File

@@ -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, 150 (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, 11000 (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

View File

@@ -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)

View File

@@ -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 '<json>'` and `--data '<json>'` 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.<method>` 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<object>` | Live feed groups for the requested IDs |
| `deleted_groups` | `array<object>` | 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<object>` | 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`