Compare commits

..

1 Commits

Author SHA1 Message Date
zhangheng.023
a7ccd4e636 feat: align im feed shortcut commands with latest oapi 2026-06-12 15:55:33 +08:00
16 changed files with 106 additions and 1200 deletions

View File

@@ -6,21 +6,14 @@ package cmd
import (
"errors"
"io"
"os"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/envvars"
"github.com/spf13/pflag"
)
// BootstrapInvocationContext extracts global invocation options before
// the real command tree is built, so provider-backed config resolution sees
// the correct profile from the start.
//
// Profile resolution: --profile flag > CliRuntimeAppID env > "" (defers to
// MultiAppConfig.CurrentApp). The env value flows through FindApp which
// matches by Name first, then by AppId — so callers can pass either form.
func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) {
var globals GlobalOptions
@@ -33,9 +26,5 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
profile := globals.Profile
if profile == "" {
profile = strings.TrimSpace(os.Getenv(envvars.CliRuntimeAppID))
}
return cmdutil.InvocationContext{Profile: profile}, nil
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
}

View File

@@ -3,11 +3,7 @@
package cmd
import (
"testing"
"github.com/larksuite/cli/internal/envvars"
)
import "testing"
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -74,25 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}
func TestBootstrapInvocationContext_RuntimeAppIDEnv(t *testing.T) {
t.Setenv(envvars.CliRuntimeAppID, " env-app ")
inv, err := BootstrapInvocationContext([]string{"auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "env-app" {
t.Fatalf("profile = %q, want %q (env value, trimmed)", inv.Profile, "env-app")
}
}
func TestBootstrapInvocationContext_ProfileFlagBeatsRuntimeAppIDEnv(t *testing.T) {
t.Setenv(envvars.CliRuntimeAppID, "env-app")
inv, err := BootstrapInvocationContext([]string{"--profile", "flag-app", "auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "flag-app" {
t.Fatalf("profile = %q, want %q (--profile wins over env)", inv.Profile, "flag-app")
}
}

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"github.com/charmbracelet/huh"
@@ -56,11 +55,6 @@ type BindOptions struct {
// configBindRun; downstream branches read this instead of rechecking
// IOStreams.IsTerminal. Do not set from outside — it is overwritten.
IsTUI bool
// All binds every account exposed by the agent source as a multi-app
// config. Hidden flag, currently openclaw source only, mutually
// exclusive with --app-id.
All bool
}
// NewCmdConfigBind creates the config bind subcommand.
@@ -113,8 +107,6 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().BoolVar(&opts.All, "all", false, "bind every account from the agent source as a multi-app config (currently openclaw source only)")
_ = cmd.Flags().MarkHidden("all")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -132,10 +124,6 @@ func configBindRun(opts *BindOptions) error {
// opts.IsTUI instead of re-checking IOStreams.IsTerminal.
opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal
if opts.All {
return configBindAllRun(opts)
}
source, err := finalizeSource(opts)
if err != nil {
return err
@@ -644,15 +632,6 @@ func validateBindFlags(opts *BindOptions) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
}
}
if opts.All {
if opts.AppID != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all is mutually exclusive with --app-id").WithParam("--all")
}
src := strings.TrimSpace(strings.ToLower(opts.Source))
if src != "" && src != "openclaw" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all only supports --source openclaw").WithParam("--all")
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
@@ -698,173 +677,3 @@ func tuiSelectIdentity(opts *BindOptions) (string, error) {
}
return value, nil
}
// configBindAllRun binds every account exposed by the agent source as a
// multi-app config. Flag-mode only; bot-only identity by default;
// silently overwrites prior bindings.
func configBindAllRun(opts *BindOptions) error {
// --all is flag-mode only: resolve the source through the same flag/env
// reconciliation as a single bind (including the explicit-vs-detected
// mismatch guard), but never prompt.
opts.IsTUI = false
source, err := finalizeSource(opts)
if err != nil {
return err
}
if source != "openclaw" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--all only supports --source openclaw").WithParam("--all")
}
core.SetCurrentWorkspace(core.Workspace(source))
targetConfigPath := core.GetConfigPath()
previousConfigBytes, _ := vfs.ReadFile(targetConfigPath)
binder, err := newBinder(source, opts)
if err != nil {
return err
}
candidates, err := binder.ListCandidates()
if err != nil {
return err
}
if len(candidates) == 0 {
return errs.NewConfigError(errs.SubtypeNotConfigured,
"no Feishu accounts found in openclaw.json").
WithHint("configure channels.feishu.accounts first")
}
if opts.Identity == "" {
opts.Identity = "bot-only"
}
if err := warnIdentityEscalation(opts, previousConfigBytes); err != nil {
return err
}
noticeUserDefaultRisk(opts)
prior := priorLang(previousConfigBytes)
oc, ok := binder.(*openclawBinder)
if !ok {
return errs.NewInternalError(errs.SubtypeSDKError, "internal: --all requires openclawBinder, got %T", binder)
}
apps, err := oc.BuildAll()
if err != nil {
return err
}
for i := range apps {
applyPreferences(&apps[i], opts, prior)
}
sortAppsDefaultFirst(apps)
apps = dedupAndOrderApps(apps)
return commitMultiAppBinding(opts, apps, previousConfigBytes, source, targetConfigPath)
}
// sortAppsDefaultFirst orders the bound apps deterministically: the "default"
// alias first, then named accounts alphabetically. Account enumeration
// upstream follows Go map order, and CurrentApp is taken from the first
// entry — without a stable order the selected default app would vary
// between runs of the same bind.
func sortAppsDefaultFirst(apps []core.AppConfig) {
sort.SliceStable(apps, func(i, j int) bool {
di, dj := strings.EqualFold(apps[i].Name, "default"), strings.EqualFold(apps[j].Name, "default")
if di != dj {
return di
}
return apps[i].Name < apps[j].Name
})
}
// dedupAndOrderApps collapses entries that share an AppId. Per OpenClaw
// semantics, "default" is an implicit fallback alias that inherits the
// top-level appId; when an explicit named account exposes the same appId,
// "default" is redundant and discarded. Insertion order is preserved.
func dedupAndOrderApps(apps []core.AppConfig) []core.AppConfig {
byID := make(map[string]int, len(apps))
out := make([]core.AppConfig, 0, len(apps))
for _, a := range apps {
if idx, seen := byID[a.AppId]; seen {
if strings.EqualFold(out[idx].Name, "default") && !strings.EqualFold(a.Name, "default") {
out[idx] = a
}
continue
}
byID[a.AppId] = len(out)
out = append(out, a)
}
return out
}
// commitMultiAppBinding writes a MultiAppConfig containing every bound
// account. CurrentApp defaults to the first entry so existing single-app
// callers keep their behaviour; callers select another profile with
// --profile or LARKSUITE_CLI_RUNTIME_APP_ID.
func commitMultiAppBinding(opts *BindOptions, apps []core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{
CurrentApp: apps[0].ProfileName(),
Apps: apps,
}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
}
data, err := json.MarshalIndent(multi, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
}
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
}
replaced := previousConfigBytes != nil
if replaced {
cleanupKeychainFromDataMulti(opts.Factory.Keychain, previousConfigBytes, apps)
}
appIDs := make([]string, len(apps))
for i := range apps {
appIDs[i] = apps[i].AppId
}
uiMsg := getBindMsg(opts.UILang)
display := sourceDisplayName(source)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+
fmt.Sprintf("bound %d Feishu apps: %s", len(appIDs), strings.Join(appIDs, ", ")))
envelope := map[string]interface{}{
"ok": true,
"workspace": source,
"app_ids": appIDs,
"config_path": configPath,
"replaced": replaced,
"identity": opts.Identity,
"all": true,
}
resultJSON, _ := json.Marshal(envelope)
fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON))
return nil
}
// cleanupKeychainFromDataMulti is cleanupKeychainFromData with a keep set,
// used when re-binding multiple apps in one pass.
func cleanupKeychainFromDataMulti(kc keychain.KeychainAccess, data []byte, keep []core.AppConfig) {
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return
}
keepIDs := make(map[string]struct{}, len(keep))
for i := range keep {
ref := keep[i].AppSecret.Ref
if ref != nil && ref.Source == "keychain" {
keepIDs[ref.ID] = struct{}{}
}
}
for _, app := range multi.Apps {
if app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" {
if _, ok := keepIDs[app.AppSecret.Ref.ID]; ok {
continue
}
}
core.RemoveSecretStore(app.AppSecret, kc)
}
}

View File

@@ -1907,270 +1907,3 @@ func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
}
}
// ── --all flag tests (hidden multi-account bind) ──
func TestConfigBindCmd_AllFlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *BindOptions
cmd := NewCmdConfigBind(f, func(opts *BindOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--source", "openclaw", "--all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.All {
t.Error("expected All=true")
}
// --all is hidden but still parseable.
flag := cmd.Flags().Lookup("all")
if flag == nil {
t.Fatal("--all flag must be registered")
}
if !flag.Hidden {
t.Error("--all must be hidden")
}
}
// requireAllValidationParam asserts err is a typed validation error of
// subtype invalid_argument carrying the named param, locking the contract
// beyond the prose message.
func requireAllValidationParam(t *testing.T, err error, wantParam string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype)
}
if ve.Param != wantParam {
t.Fatalf("param = %q, want %q", ve.Param, wantParam)
}
}
func TestConfigBindRun_AllRejectsAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "openclaw",
AppID: "cli_x",
All: true,
})
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--all is mutually exclusive with --app-id") {
t.Errorf("unexpected error: %v", err)
}
requireAllValidationParam(t, err, "--all")
}
func TestDedupAndOrderApps_NamedWinsOverDefault(t *testing.T) {
// OpenClaw semantics: "default" is an implicit fallback alias; when a
// named account exposes the same appId, default is dropped.
apps := []core.AppConfig{
{Name: "acct-a", AppId: "cli_main"},
{Name: "acct-b", AppId: "cli_product"},
{Name: "default", AppId: "cli_main"},
}
got := dedupAndOrderApps(apps)
if len(got) != 2 {
t.Fatalf("len=%d, want 2", len(got))
}
if got[0].Name != "acct-a" || got[0].AppId != "cli_main" {
t.Errorf("apps[0]=%+v, want acct-a cli_main", got[0])
}
if got[1].Name != "acct-b" {
t.Errorf("apps[1]=%+v, want acct-b", got[1])
}
}
func TestDedupAndOrderApps_DefaultFirstThenNamedOverwrites(t *testing.T) {
// default arrives before its named alias — named still wins.
apps := []core.AppConfig{
{Name: "default", AppId: "cli_main"},
{Name: "acct-a", AppId: "cli_main"},
}
got := dedupAndOrderApps(apps)
if len(got) != 1 {
t.Fatalf("len=%d, want 1", len(got))
}
if got[0].Name != "acct-a" {
t.Errorf("apps[0]=%+v, want acct-a", got[0])
}
}
func TestDedupAndOrderApps_NoDuplicatesPreservesOrder(t *testing.T) {
apps := []core.AppConfig{
{Name: "acct-a", AppId: "cli_main"},
{Name: "acct-b", AppId: "cli_product"},
{Name: "acct-c", AppId: "cli_extra"},
}
got := dedupAndOrderApps(apps)
if len(got) != 3 {
t.Fatalf("len=%d, want 3", len(got))
}
want := []string{"acct-a", "acct-b", "acct-c"}
for i := range got {
if got[i].Name != want[i] {
t.Errorf("apps[%d]=%q, want %q", i, got[i].Name, want[i])
}
}
}
func TestDedupAndOrderApps_DefaultAlreadyFirst(t *testing.T) {
apps := []core.AppConfig{
{Name: "default", AppId: "cli_main"},
{Name: "acct-b", AppId: "cli_product"},
}
got := dedupAndOrderApps(apps)
if len(got) != 2 || got[0].Name != "default" {
t.Fatalf("got %+v, want default first", got)
}
}
func TestConfigBindRun_AllRejectsNonOpenClawSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
All: true,
})
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--all only supports --source openclaw") {
t.Errorf("unexpected error: %v", err)
}
requireAllValidationParam(t, err, "--all")
}
func TestConfigBindRun_AllRejectsSourceEnvMismatch(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
t.Setenv("HERMES_HOME", t.TempDir()) // env says hermes, flag says openclaw
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", All: true})
if err == nil {
t.Fatal("expected source/env mismatch error")
}
if !strings.Contains(err.Error(), "does not match detected Agent environment") {
t.Errorf("unexpected error: %v", err)
}
requireAllValidationParam(t, err, "--source")
}
func TestConfigBindRun_AllBindsEveryAccount(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
clearAgentEnv(t)
openclawHome := t.TempDir()
t.Setenv("OPENCLAW_HOME", openclawHome)
openclawDir := filepath.Join(openclawHome, ".openclaw")
if err := os.MkdirAll(openclawDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Top-level credentials surface as a "default" candidate aliasing the
// "work" account; dedup must collapse them and keep the named entry.
cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu","accounts":{"work":{"appId":"cli_main","appSecret":"sec_main"},"personal":{"appId":"cli_personal","appSecret":"sec_personal"}}}}}`
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil {
t.Fatalf("write: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, All: true})
if err != nil {
t.Fatalf("expected success, got %v", err)
}
configPath := filepath.Join(configDir, "openclaw", "config.json")
assertEnvelope(t, stdout.Bytes(), map[string]any{
"ok": true,
"workspace": "openclaw",
"app_ids": []any{"cli_main", "cli_personal"},
"config_path": configPath,
"replaced": false,
"identity": "bot-only",
"all": true,
})
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read written config: %v", err)
}
var multi core.MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
t.Fatalf("written config is not a MultiAppConfig: %v", err)
}
if len(multi.Apps) != 2 {
t.Fatalf("apps = %d, want 2", len(multi.Apps))
}
// The account aliased by top-level "default" stays first and becomes
// the initial CurrentApp.
if multi.CurrentApp != "work" {
t.Errorf("currentApp = %q, want %q", multi.CurrentApp, "work")
}
if multi.Apps[0].Name != "work" || multi.Apps[1].Name != "personal" {
t.Errorf("app order = [%q, %q], want [work, personal]", multi.Apps[0].Name, multi.Apps[1].Name)
}
}
func TestConfigBindRun_AllEscalationRequiresForce(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
clearAgentEnv(t)
openclawHome := t.TempDir()
t.Setenv("OPENCLAW_HOME", openclawHome)
openclawDir := filepath.Join(openclawHome, ".openclaw")
if err := os.MkdirAll(openclawDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu"}}}`
if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil {
t.Fatalf("write: %v", err)
}
// Pre-existing binding locked to bot-only.
ocDir := filepath.Join(configDir, "openclaw")
if err := os.MkdirAll(ocDir, 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
locked := `{"apps":[{"appId":"cli_old","strictMode":"bot"}]}`
if err := os.WriteFile(filepath.Join(ocDir, "config.json"), []byte(locked), 0600); err != nil {
t.Fatalf("write: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, All: true, Identity: "user-default"})
if err == nil {
t.Fatal("expected confirmation-required error without --force")
}
var ce *errs.ConfirmationRequiredError
if !errors.As(err, &ce) {
t.Fatalf("expected *errs.ConfirmationRequiredError, got %T (%v)", err, err)
}
// Same escalation with --force proceeds.
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, All: true, Identity: "user-default", Force: true}); err != nil {
t.Fatalf("expected --force to allow escalation, got %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
if envelope["replaced"] != true || envelope["identity"] != "user-default" {
t.Errorf("envelope = %v, want replaced=true identity=user-default", envelope)
}
}

View File

@@ -172,54 +172,40 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
}
var selected *binding.CandidateApp
for i := range b.rawApps {
if b.rawApps[i].AppID == appID {
return b.buildFromCandidate(&b.rawApps[i])
selected = &b.rawApps[i]
break
}
}
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
}
// BuildAll builds every candidate without appID lookup so duplicate-appID
// entries (e.g. OpenClaw's "default" alias of a named account) keep their own
// Label. --all callers must use this; Build(appID) collapses duplicates.
func (b *openclawBinder) BuildAll() ([]core.AppConfig, error) {
if b.cfg == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: BuildAll called before ListCandidates")
if selected == nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
}
out := make([]core.AppConfig, 0, len(b.rawApps))
for i := range b.rawApps {
cfg, err := b.buildFromCandidate(&b.rawApps[i])
if err != nil {
return nil, err
}
out = append(out, *cfg)
}
return out, nil
}
func (b *openclawBinder) buildFromCandidate(c *binding.CandidateApp) (*core.AppConfig, error) {
if c.AppSecret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", c.AppID, b.path).
if selected.AppSecret.IsZero() {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
WithHint("configure channels.feishu.appSecret in openclaw.json")
}
secret, err := binding.ResolveSecretInput(c.AppSecret, b.cfg.Secrets, os.Getenv)
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", c.AppID, err).
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
WithHint("check appSecret configuration in %s", b.path).
WithCause(err)
}
stored, err := core.ForStorage(c.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
WithHint("use file: reference in config to bypass keychain").
WithCause(err)
}
return &core.AppConfig{
Name: c.Label,
AppId: c.AppID,
AppId: selected.AppID,
AppSecret: stored,
Brand: core.LarkBrand(normalizeBrand(c.Brand)),
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
}, nil
}

View File

@@ -68,8 +68,6 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return nil, core.NotConfiguredError()
}
// p.profile already incorporates --profile / CliRuntimeAppID precedence
// (resolved in cmd/bootstrap.go before Factory construction).
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
if err != nil {
return nil, err

View File

@@ -4,11 +4,8 @@
package envvars
const (
CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
// CliRuntimeAppID pins a profile by appId when --profile is unset.
// Selector only; credentials still come from config.json / keychain.
CliRuntimeAppID = "LARKSUITE_CLI_RUNTIME_APP_ID"
CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
CliBrand = "LARKSUITE_CLI_BRAND"
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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