mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Compare commits
1 Commits
feat/runti
...
feat/feed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ccd4e636 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user