mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Compare commits
1 Commits
fix/skills
...
codex/opti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b1193be95 |
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
|
||||
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
|
||||
# it bans are still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/|shortcuts/mail/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
@@ -116,14 +116,16 @@ linters:
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on migrated domains ──
|
||||
# These helpers emit legacy output.Err* / bare error shapes or drop
|
||||
# typed metadata such as Param/Cause. Migrated domains must use typed
|
||||
# common replacements or local typed helpers instead.
|
||||
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Migrated domains use
|
||||
# typed errs.* builders or domain-local file-I/O helpers instead; this
|
||||
# prevents reintroduction while unmigrated domains continue to use the
|
||||
# shared helpers until their later migration phase.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy or
|
||||
metadata-poor error shapes. Use typed common replacements, typed
|
||||
errs.NewXxxError builders, or domain-local typed helpers.
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use typed errs.NewXxxError builders or a domain-local
|
||||
file-I/O helper.
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
|
||||
16
cmd/build.go
16
cmd/build.go
@@ -6,7 +6,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/cmd/profile"
|
||||
"github.com/larksuite/cli/cmd/schema"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -53,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
|
||||
// at build time. It is registered by the repo-root package main's init via
|
||||
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
|
||||
// breaking the single-file preview build (see skills_embed.go). nil in builds
|
||||
// that embed no skills; the `skills` commands then return a typed internal error.
|
||||
var embeddedSkillContent fs.FS
|
||||
|
||||
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
|
||||
// repo-root package main's init; a wrapper main can call it before Execute to
|
||||
// supply its own skill content.
|
||||
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
|
||||
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||
// When hide is true the flag stays registered (so existing invocations still
|
||||
// parse) but is omitted from help and shell completion. Typically called as
|
||||
@@ -117,7 +103,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
if cfg.keychain != nil {
|
||||
f.Keychain = cfg.keychain
|
||||
}
|
||||
f.SkillContent = embeddedSkillContent
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
||||
@@ -155,7 +140,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
|
||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "api",
|
||||
Type: "api_error",
|
||||
Code: 230002,
|
||||
Message: "Bot/User can NOT be out of the chat.",
|
||||
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skill implements the `lark-cli skills` command group, which serves
|
||||
// binary-embedded skill content to AI agents. The package is "skill"; the
|
||||
// user-facing verb is "skills".
|
||||
package skill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillcontent"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
|
||||
if f.SkillContent == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"skill content not embedded in this build")
|
||||
}
|
||||
return skillcontent.New(f.SkillContent), nil
|
||||
}
|
||||
|
||||
type readEnvelope struct {
|
||||
Skill string `json:"skill"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Guidance string `json:"guidance,omitempty"`
|
||||
}
|
||||
|
||||
type listEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []skillcontent.SkillInfo `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type listPathEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []skillcontent.DirEntry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skills",
|
||||
Short: "Read embedded skill content (list / read)",
|
||||
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
|
||||
"the CLI binary at build time, so it stays in sync with the CLI version. " +
|
||||
"Machine resources such as assets/ and scripts/ are not embedded.",
|
||||
}
|
||||
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.AddCommand(newListCmd(f), newReadCmd(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [name[/path]]",
|
||||
Short: "List skills, or list one layer under a skill path (like ls)",
|
||||
Example: ` lark-cli skills list # all skills: name, description, version
|
||||
lark-cli skills list lark-doc # one layer under a skill (like ls)
|
||||
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"list takes at most 1 argument: [name[/path]]").
|
||||
WithHint("run 'lark-cli skills list --help'")
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
|
||||
return nil
|
||||
}
|
||||
entries, listed, err := r.ListPath(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
|
||||
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "read <name>[/<path>] [path]",
|
||||
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
|
||||
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
|
||||
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
|
||||
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
|
||||
lark-cli skills read lark-doc --json # JSON envelope`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name, relpath, err := parseReadTarget(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := newReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var content []byte
|
||||
var pathOut string
|
||||
if relpath == "" {
|
||||
content, err = r.ReadSkill(name)
|
||||
pathOut = "SKILL.md"
|
||||
} else {
|
||||
content, pathOut, err = r.ReadReference(name, relpath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isMain := pathOut == "SKILL.md"
|
||||
if asJSON {
|
||||
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
|
||||
if isMain {
|
||||
env.Guidance = readGuidance(name)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
|
||||
if _, err := f.IOStreams.Out.Write(content); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
|
||||
}
|
||||
if isMain {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
|
||||
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
|
||||
func parseReadTarget(args []string) (name, relpath string, err error) {
|
||||
switch len(args) {
|
||||
case 1:
|
||||
name, relpath = skillcontent.SplitArg(args[0])
|
||||
return name, relpath, nil
|
||||
case 2:
|
||||
return args[0], args[1], nil
|
||||
default:
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
|
||||
WithHint("run 'lark-cli skills read --help'")
|
||||
}
|
||||
}
|
||||
|
||||
// readGuidance routes cross-skill "../lark-foo/..." references back through
|
||||
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
|
||||
// relative form must be rewritten.
|
||||
func readGuidance(name string) string {
|
||||
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
|
||||
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
|
||||
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
|
||||
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// calFS is the default single-skill content tree for these tests. The embedded
|
||||
// FS is now injected through the Factory (no package global), so tests pass it
|
||||
// explicitly to run() — nothing is shared, so they are safe under -parallel.
|
||||
func calFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
}
|
||||
}
|
||||
|
||||
// run executes the skills command tree against the given content FS (may be nil
|
||||
// to exercise the not-embedded path) and returns stdout/stderr/err.
|
||||
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
// Isolate CLI config state so tests never read/write the real config dir
|
||||
// (repo convention).
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
|
||||
f.SkillContent = fsys
|
||||
cmd := NewCmdSkill(f)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err = cmd.Execute()
|
||||
return out.String(), errOut.String(), err
|
||||
}
|
||||
|
||||
func TestSkillList(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
// "ok" is an explicit success marker (the list envelope is a typed struct;
|
||||
// no automatic _notice attaches).
|
||||
if !got.OK {
|
||||
t.Error("expected ok=true in list envelope")
|
||||
}
|
||||
if got.Count != 1 || len(got.Skills) != 1 {
|
||||
t.Fatalf("count: got %d", got.Count)
|
||||
}
|
||||
if got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Errorf("name: got %v", got.Skills[0]["name"])
|
||||
}
|
||||
// Top-level list carries version + metadata, not a references list.
|
||||
if _, ok := got.Skills[0]["references"]; ok {
|
||||
t.Error("top-level list must not include references")
|
||||
}
|
||||
if got.Skills[0]["version"] != "1.0.0" {
|
||||
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
|
||||
}
|
||||
if _, ok := got.Skills[0]["metadata"]; !ok {
|
||||
t.Error("expected metadata in list entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListJSONFlagAccepted(t *testing.T) {
|
||||
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
|
||||
// so it stays symmetric with read --json.
|
||||
stdout, _, err := run(t, calFS(), "list", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("list --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Count != 1 {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPath(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("list <name> error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Path string `json:"path"`
|
||||
Entries []struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
} `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if !got.OK || got.Path != "lark-calendar" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// One layer under the skill root: SKILL.md (file) + references (dir).
|
||||
if got.Count != 2 || len(got.Entries) != 2 {
|
||||
t.Fatalf("entries: got %+v", got.Entries)
|
||||
}
|
||||
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
|
||||
t.Errorf("entry[0]: got %+v", got.Entries[0])
|
||||
}
|
||||
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
|
||||
t.Errorf("entry[1]: got %+v", got.Entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "no-such-skill")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Fatalf("expected 'unknown skill' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListPathTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Fatalf("expected 'invalid path' error, got %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillListTooManyArgs(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "list", "a", "b")
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
|
||||
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
|
||||
// omitted from the catalog (no blank entry).
|
||||
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
|
||||
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
|
||||
}
|
||||
stdout, _, err := run(t, fsys, "list")
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skills []map[string]any `json:"skills"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
|
||||
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadRaw(t *testing.T) {
|
||||
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
|
||||
t.Errorf("raw output: got %q", stdout)
|
||||
}
|
||||
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
|
||||
if strings.Contains(stdout, "Tip:") {
|
||||
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
|
||||
}
|
||||
// Guidance goes to stderr: own files via `skills read <name> ...`, and
|
||||
// cross-skill refs routed to `skills read <other-skill> ...` (version-
|
||||
// consistent), not "read directly".
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
|
||||
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
|
||||
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "instead of opening them directly") ||
|
||||
strings.Contains(stderr, "read those directly") {
|
||||
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v", e)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Guidance is a separate field, not merged into content.
|
||||
if got.Guidance == "" {
|
||||
t.Error("expected guidance field for main SKILL.md")
|
||||
}
|
||||
if strings.Contains(got.Content, "Tip:") {
|
||||
t.Error("guidance must not be merged into content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFile(t *testing.T) {
|
||||
// Both the 2-arg and slash forms read the same file, with no guidance tip.
|
||||
for _, args := range [][]string{
|
||||
{"read", "lark-calendar", "references/agenda.md"},
|
||||
{"read", "lark-calendar/references/agenda.md"},
|
||||
} {
|
||||
stdout, stderr, err := run(t, calFS(), args...)
|
||||
if err != nil {
|
||||
t.Fatalf("read %v error: %v", args, err)
|
||||
}
|
||||
if stdout != "# Agenda" {
|
||||
t.Errorf("read %v output: got %q", args, stdout)
|
||||
}
|
||||
// Reference reads carry no guidance on either stream.
|
||||
if strings.Contains(stderr, "Tip:") {
|
||||
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadFileJSON(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("read file --json error: %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Skill, Path, Content, Guidance string
|
||||
}
|
||||
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
|
||||
}
|
||||
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
|
||||
t.Errorf("envelope: %+v", got)
|
||||
}
|
||||
// Reference reads do not carry the guidance tip.
|
||||
if got.Guidance != "" {
|
||||
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadUnknown(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read", "no-such")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadMissingArg(t *testing.T) {
|
||||
_, _, err := run(t, calFS(), "read")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
|
||||
t.Fatalf("expected arg error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillReadTraversal(t *testing.T) {
|
||||
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on rejection, got %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillNilContentFS(t *testing.T) {
|
||||
_, _, err := run(t, nil, "list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SkillContent is nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not embedded") {
|
||||
t.Errorf("err: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -49,21 +49,12 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.NpmInstallOverride = npmFn
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||
return func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
return func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
@@ -487,10 +478,6 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||
u.RestoreAvailableOverride = func() bool { return false }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
t.Fatal("skills sync should not run when binary verification fails")
|
||||
return nil
|
||||
@@ -823,11 +810,6 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -880,11 +862,6 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
|
||||
}
|
||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||
u.VerifyOverride = func(string) error { return nil }
|
||||
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("index unavailable")
|
||||
return r
|
||||
}
|
||||
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stderr.WriteString("npx: command not found")
|
||||
@@ -1029,7 +1006,6 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1068,7 +1044,6 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallManual,
|
||||
@@ -1113,7 +1088,6 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{
|
||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||
@@ -1173,10 +1147,6 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
||||
DetectOverride: func() selfupdate.DetectResult {
|
||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||
},
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1226,10 +1196,6 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
return &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsIndexFetch()()
|
||||
},
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
skillsCalled = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
for _, keys := range all {
|
||||
for _, k := range keys {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
|
||||
type BoardWhiteboardUpdatedV1Data struct {
|
||||
// WhiteboardID is the id of the whiteboard whose content was updated.
|
||||
WhiteboardID string `json:"whiteboard_id"`
|
||||
// OperatorIDs lists the operators that produced this update batch.
|
||||
OperatorIDs []OperatorID `json:"operator_ids"`
|
||||
}
|
||||
|
||||
// OperatorID identifies an operator that produced the whiteboard update,
|
||||
// expressed in the three Lark identity formats.
|
||||
type OperatorID struct {
|
||||
// OpenID is the operator's open_id within the current app.
|
||||
OpenID string `json:"open_id"`
|
||||
// UnionID is the operator's union_id across apps under the same ISV.
|
||||
UnionID string `json:"union_id"`
|
||||
// UserID is the operator's user_id within the tenant.
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// cleanupTimeout bounds how long the unsubscribe call has to finish during
|
||||
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
|
||||
// and returns a cleanup that invokes the matching unsubscribe.
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// recordedCall captures a single APIClient invocation for assertion.
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
// fakeAPIClient is a minimal event.APIClient stub that records calls and
|
||||
// can be configured to fail when the request path matches errOnPath.
|
||||
type fakeAPIClient struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
errOnPath string
|
||||
}
|
||||
|
||||
// CallAPI records the invocation and optionally returns a simulated error
|
||||
// when the path contains the configured errOnPath substring.
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
|
||||
return nil, errors.New("simulated subscribe failure")
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
|
||||
// PreConsume hook fails fast with an actionable error when whiteboard_id
|
||||
// is absent from the params map.
|
||||
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when whiteboard_id missing")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup on error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
// returns an error when the runtime APIClient dependency is missing.
|
||||
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
// failed subscribe call surfaces the error and skips registering a cleanup,
|
||||
// so no spurious unsubscribe is invoked.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{errOnPath: "/subscribe"}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from subscribe call")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatalf("expected nil cleanup when subscribe fails")
|
||||
}
|
||||
// only the failed subscribe call should have been made; no unsubscribe.
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
|
||||
// happy-path: subscribe is called once with the correct method/path/body,
|
||||
// and the returned cleanup invokes the matching unsubscribe.
|
||||
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatalf("expected non-nil cleanup")
|
||||
}
|
||||
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
|
||||
}
|
||||
got := rt.calls[0]
|
||||
if got.method != "POST" {
|
||||
t.Errorf("subscribe method: got %q, want POST", got.method)
|
||||
}
|
||||
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
|
||||
if got.path != wantSubPath {
|
||||
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
|
||||
}
|
||||
body, _ := got.body.(map[string]string)
|
||||
if body["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if len(rt.calls) != 2 {
|
||||
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
|
||||
}
|
||||
got2 := rt.calls[1]
|
||||
if got2.method != "POST" {
|
||||
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
|
||||
}
|
||||
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
|
||||
if got2.path != wantUnsubPath {
|
||||
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
|
||||
}
|
||||
body2, _ := got2.body.(map[string]string)
|
||||
if body2["event_type"] != eventTypeWhiteboardUpdated {
|
||||
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
|
||||
// whiteboard_id values containing reserved URL characters are properly
|
||||
// path-segment encoded so they cannot escape into adjacent path segments.
|
||||
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
|
||||
rt := &fakeAPIClient{}
|
||||
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
|
||||
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(rt.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(rt.calls))
|
||||
}
|
||||
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
|
||||
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
|
||||
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
|
||||
// required whiteboard_id parameter.
|
||||
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
keys := Keys()
|
||||
for _, k := range keys {
|
||||
if k.Key == eventTypeWhiteboardUpdated {
|
||||
if k.PreConsume == nil {
|
||||
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
if len(k.Params) == 0 {
|
||||
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
var found bool
|
||||
for _, p := range k.Params {
|
||||
if p.Name == "whiteboard_id" && p.Required {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
|
||||
}
|
||||
|
||||
// 确保 event.APIClient 接口与本测试 mock 一致。
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
@@ -1,48 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package whiteboard registers Board-domain EventKeys.
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
|
||||
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
|
||||
|
||||
// Keys returns all Board-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeWhiteboardUpdated,
|
||||
DisplayName: "Whiteboard updated",
|
||||
Description: "Pushed when the whiteboard content is updated.",
|
||||
EventType: eventTypeWhiteboardUpdated,
|
||||
Params: []event.ParamDef{
|
||||
{
|
||||
Name: "whiteboard_id",
|
||||
Type: event.ParamString,
|
||||
Required: true,
|
||||
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
|
||||
},
|
||||
},
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{
|
||||
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
|
||||
"/event/operator_ids/*/open_id": {Kind: "open_id"},
|
||||
"/event/operator_ids/*/union_id": {Kind: "union_id"},
|
||||
"/event/operator_ids/*/user_id": {Kind: "user_id"},
|
||||
},
|
||||
},
|
||||
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package cmdutil
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -44,8 +43,6 @@ type Factory struct {
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
|
||||
@@ -92,18 +92,6 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
base.Troubleshooter = ts
|
||||
}
|
||||
}
|
||||
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
|
||||
// returns these as free-text reason strings with no machine-readable field
|
||||
// name (verified for code 190014:
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
|
||||
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
|
||||
// prompt — rather than fabricated structured params. Lifted before the
|
||||
// category switch so any classified arm inherits it; the CategoryAPI arm
|
||||
// below prefers this server detail over the context-free APIHint default.
|
||||
detailHint := liftErrorDetailValues(resp)
|
||||
if detailHint != "" {
|
||||
base.Hint = detailHint
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
@@ -141,11 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
// A server-supplied detail (lifted into base.Hint above) wins over the
|
||||
// context-free APIHint default; only fall back to APIHint when absent.
|
||||
if base.Hint == "" {
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
}
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
@@ -230,10 +214,6 @@ func stringFromAny(v any) string {
|
||||
// per-subtype recovery hint before returning it, so the wire envelope
|
||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||
// Config categories have authoritative recovery guidance, so the curated
|
||||
// ConfigHint deliberately overrides any server detail lifted into p.Hint
|
||||
// (the opposite precedence from the CategoryAPI arm, where the lifted
|
||||
// detail wins).
|
||||
p.Hint = ConfigHint(p.Subtype)
|
||||
return &errs.ConfigError{Problem: p}
|
||||
}
|
||||
@@ -278,10 +258,6 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
||||
}
|
||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||
// Permission categories have authoritative recovery guidance (scopes to
|
||||
// grant, console URL), so the curated PermissionHint deliberately overrides
|
||||
// any server detail lifted into p.Hint (the opposite precedence from the
|
||||
// CategoryAPI arm, where the lifted detail wins).
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||
permErr := &errs.PermissionError{
|
||||
Problem: p,
|
||||
@@ -390,32 +366,6 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
|
||||
return "check the calling identity has the required scope"
|
||||
}
|
||||
|
||||
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||
// strings and joins them with "; ". Returns "" when the structure is absent or
|
||||
// carries no non-empty value. The shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"<reason>"}]}}.
|
||||
func liftErrorDetailValues(resp map[string]any) string {
|
||||
errBlock, ok := resp["error"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
details, ok := errBlock["details"].([]any)
|
||||
if !ok || len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
var values []string
|
||||
for _, d := range details {
|
||||
m, ok := d.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, _ := m["value"].(string); v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(values, "; ")
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
// Returns nil when the structure is absent.
|
||||
func extractMissingScopes(resp map[string]any) []string {
|
||||
|
||||
@@ -220,111 +220,6 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
|
||||
// resp.error.details[].value into Problem.Hint when the response routes to the
|
||||
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
|
||||
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
|
||||
// — only a human-readable reason string, no machine-readable field name. It is
|
||||
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
|
||||
// structured params.
|
||||
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
map[string]any{"value": "end_time should be later than start_time"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
|
||||
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
|
||||
// detail values are joined with "; " into a single Hint, and empty values are
|
||||
// skipped.
|
||||
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
map[string]any{"value": "first reason"},
|
||||
map[string]any{"value": ""},
|
||||
map[string]any{"value": "second reason"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Hint != "first reason; second reason" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
|
||||
// the details array (not a JSON object) are skipped rather than panicking, and
|
||||
// well-formed siblings still surface in the Hint.
|
||||
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 190014,
|
||||
"msg": "invalid params",
|
||||
"error": map[string]any{
|
||||
"details": []any{
|
||||
"i am a bare string, not an object",
|
||||
map[string]any{"value": "the real reason"},
|
||||
42,
|
||||
},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Hint != "the real reason" {
|
||||
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
|
||||
// block, a non-array details field, and an empty details array all leave the
|
||||
// Hint untouched (no lifted detail) instead of erroring.
|
||||
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp map[string]any
|
||||
}{
|
||||
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
|
||||
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
|
||||
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
|
||||
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
// With no liftable detail, the Hint must not echo a server detail.
|
||||
if strings.Contains(p.Hint, "nope") {
|
||||
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
||||
// when the upstream response omits it — wire envelope must omit the field.
|
||||
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var calendarCodeMeta = map[int]CodeMeta{
|
||||
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
|
||||
// via the codemeta_calendar.go init() merge to its expected
|
||||
// Category/Subtype/Retryable.
|
||||
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 190014: calendar "invalid params" with a field-level detail
|
||||
// (error.details[].value) lifted into Hint by BuildAPIError.
|
||||
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -231,9 +231,14 @@ func TestLoadAutoApproveSet(t *testing.T) {
|
||||
t.Fatal("expected non-empty auto-approve set")
|
||||
}
|
||||
|
||||
// From scope_priorities.json recommend=="true"
|
||||
// From scope_overrides.json allow list
|
||||
if !aaSet["calendar:calendar.event:create"] {
|
||||
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
|
||||
}
|
||||
|
||||
// Verify allow list entries are present
|
||||
if !aaSet["sheets:spreadsheet:read"] {
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)")
|
||||
t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
|
||||
}
|
||||
|
||||
t.Logf("Auto-approve set has %d scopes", len(aaSet))
|
||||
@@ -252,10 +257,16 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
|
||||
|
||||
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
|
||||
allowSet := LoadOverrideAutoApproveAllow()
|
||||
// recommend.allow in scope_overrides.json is intentionally empty:
|
||||
// no scopes are special-cased into the auto-approve set anymore.
|
||||
if len(allowSet) != 0 {
|
||||
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
|
||||
if len(allowSet) == 0 {
|
||||
t.Fatal("expected non-empty override allow set")
|
||||
}
|
||||
|
||||
// Known entries from scope_overrides.json
|
||||
if !allowSet["calendar:calendar.event:create"] {
|
||||
t.Error("expected calendar:calendar.event:create in allow set")
|
||||
}
|
||||
if !allowSet["mail:event"] {
|
||||
t.Error("expected mail:event in allow set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,9 +277,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsAutoApproveScope(t *testing.T) {
|
||||
// Known auto-approve scope (recommend=true in scope_priorities.json)
|
||||
if !IsAutoApproveScope("sheets:spreadsheet:read") {
|
||||
t.Error("expected sheets:spreadsheet:read to be auto-approve")
|
||||
// Known auto-approve scope (in allow list)
|
||||
if !IsAutoApproveScope("calendar:calendar.event:create") {
|
||||
t.Error("expected calendar:calendar.event:create to be auto-approve")
|
||||
}
|
||||
|
||||
// Completely unknown scope
|
||||
@@ -279,8 +290,9 @@ func TestIsAutoApproveScope(t *testing.T) {
|
||||
|
||||
func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
scopes := []string{
|
||||
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
"calendar:calendar.event:create", // auto-approve (in allow list)
|
||||
"zzz:unknown:scope", // not in auto-approve
|
||||
"sheets:spreadsheet:read", // auto-approve (in allow list)
|
||||
}
|
||||
|
||||
result := FilterAutoApproveScopes(scopes)
|
||||
@@ -288,10 +300,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
t.Fatal("expected at least 1 auto-approve scope in result")
|
||||
}
|
||||
|
||||
// Check that sheets:spreadsheet:read is included
|
||||
// Check that calendar:calendar.event:create is included
|
||||
found := false
|
||||
for _, s := range result {
|
||||
if s == "sheets:spreadsheet:read" {
|
||||
if s == "calendar:calendar.event:create" {
|
||||
found = true
|
||||
}
|
||||
// Ensure unknown scopes are not included
|
||||
@@ -300,7 +312,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected sheets:spreadsheet:read in result")
|
||||
t.Error("expected calendar:calendar.event:create in result")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,25 @@
|
||||
"vc:meeting.meetingevent:read": 75
|
||||
},
|
||||
"recommend": {
|
||||
"allow": [],
|
||||
"allow": [
|
||||
"calendar:calendar.event:create",
|
||||
"calendar:calendar.event:delete",
|
||||
"calendar:calendar.event:read",
|
||||
"calendar:calendar.event:update",
|
||||
"calendar:calendar.free_busy:read",
|
||||
"calendar:calendar:create",
|
||||
"calendar:calendar:delete",
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar:update",
|
||||
"contact:user.basic_profile:readonly",
|
||||
"mail:event",
|
||||
"mail:user_mailbox.mail_contact:read",
|
||||
"mail:user_mailbox.mail_contact:write",
|
||||
"mail:user_mailbox.message.address:read",
|
||||
"mail:user_mailbox.message.body:read",
|
||||
"mail:user_mailbox.message.subject:read",
|
||||
"mail:user_mailbox.message:readonly"
|
||||
],
|
||||
"deny": [
|
||||
"im:chat",
|
||||
"im:message.send_as_user"
|
||||
|
||||
@@ -10,13 +10,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -40,15 +37,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
skillsIndexMaxBodySize = 1 << 20
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
npmInstallTimeout = 10 * time.Minute
|
||||
skillsUpdateTimeout = 2 * time.Minute
|
||||
verifyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -92,7 +83,6 @@ func (r *NpmResult) CombinedOutput() string {
|
||||
type Updater struct {
|
||||
DetectOverride func() DetectResult
|
||||
NpmInstallOverride func(version string) *NpmResult
|
||||
SkillsIndexFetchOverride func() *NpmResult
|
||||
SkillsCommandOverride func(args ...string) *NpmResult
|
||||
VerifyOverride func(expectedVersion string) error
|
||||
RestoreAvailableOverride func() bool
|
||||
@@ -163,53 +153,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||
if u.SkillsIndexFetchOverride != nil {
|
||||
return u.SkillsIndexFetchOverride()
|
||||
}
|
||||
|
||||
r := &NpmResult{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
|
||||
client := transport.NewHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
|
||||
return r
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
|
||||
if _, err := io.Copy(&r.Stdout, limited); err != nil {
|
||||
r.Err = err
|
||||
return r
|
||||
}
|
||||
if r.Stdout.Len() > skillsIndexMaxBodySize {
|
||||
r.Stdout.Reset()
|
||||
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
|
||||
return r
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||
if r.Err != nil {
|
||||
|
||||
@@ -4,18 +4,12 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -238,113 +232,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
|
||||
}
|
||||
if result.Stdout.Len() != 0 {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
oldTimeout := skillsIndexFetchTimeout
|
||||
officialSkillsIndexURL = server.URL
|
||||
skillsIndexFetchTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
officialSkillsIndexURL = oldURL
|
||||
skillsIndexFetchTimeout = oldTimeout
|
||||
})
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
var netErr net.Error
|
||||
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldURL := officialSkillsIndexURL
|
||||
officialSkillsIndexURL = server.URL
|
||||
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
|
||||
|
||||
result := New().ListOfficialSkillsIndex()
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
|
||||
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
|
||||
r := &NpmResult{}
|
||||
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
|
||||
return r
|
||||
}}).ListOfficialSkillsIndex()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
|
||||
}
|
||||
if !strings.Contains(result.Stdout.String(), "override-skill") {
|
||||
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||
called := []string{}
|
||||
updater := &Updater{
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillcontent reads embedded skill content from an injected fs.FS
|
||||
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
|
||||
// fed straight back into `read`.
|
||||
type DirEntry struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
func (r *Reader) List() ([]SkillInfo, error) {
|
||||
entries, err := fs.ReadDir(r.fsys, ".")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
|
||||
}
|
||||
out := make([]SkillInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Skip dirs that aren't real skills (no SKILL.md).
|
||||
if info, ok := r.skillInfo(e.Name()); ok {
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
desc, version, metadata := parseFrontmatter(data)
|
||||
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
|
||||
}
|
||||
|
||||
// ListPath lists one directory layer (no recursion) under "<name>" or
|
||||
// "<name>/<sub>", returning the entries and the cleaned path listed.
|
||||
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
|
||||
name, sub := SplitArg(arg)
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir := name
|
||||
if sub != "" {
|
||||
cleaned, err := cleanSubPath(sub)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir = name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q not found in skill %q", sub, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
|
||||
}
|
||||
}
|
||||
entries, err := fs.ReadDir(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, dir, nil
|
||||
}
|
||||
|
||||
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
|
||||
// separator is a bare skill name (rest "").
|
||||
func SplitArg(arg string) (name, rest string) {
|
||||
name, rest, _ = strings.Cut(arg, "/")
|
||||
return name, rest
|
||||
}
|
||||
|
||||
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
|
||||
// unparseable frontmatter yields ("", "", nil), never an error.
|
||||
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
|
||||
lines := strings.Split(string(skillMD), "\n")
|
||||
if strings.TrimRight(lines[0], "\r") != "---" {
|
||||
return "", "", nil
|
||||
}
|
||||
block := make([]string, 0, len(lines))
|
||||
closed := false
|
||||
for _, ln := range lines[1:] {
|
||||
if strings.TrimRight(ln, "\r") == "---" {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
block = append(block, ln)
|
||||
}
|
||||
if !closed {
|
||||
return "", "", nil
|
||||
}
|
||||
var fm struct {
|
||||
Description string `yaml:"description"`
|
||||
Version string `yaml:"version"`
|
||||
Metadata map[string]any `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
return fm.Description, fm.Version, fm.Metadata
|
||||
}
|
||||
|
||||
func (r *Reader) ReadSkill(name string) ([]byte, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *Reader) ensureSkill(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
info, err := fs.Stat(r.fsys, name)
|
||||
if err != nil || !info.IsDir() {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknownSkill(name string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
|
||||
WithHint("run 'lark-cli skills list' to see available skills")
|
||||
}
|
||||
|
||||
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
|
||||
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
|
||||
func cleanSubPath(relpath string) (string, error) {
|
||||
cleaned := path.Clean(relpath)
|
||||
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
|
||||
// survives; reject it explicitly alongside "../".
|
||||
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
|
||||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid path %q: must be a relative path without '..'", relpath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
|
||||
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cleaned, err := cleanSubPath(relpath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
full := name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q not found in skill %q", relpath, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q is a directory, not a file", relpath)
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, cleaned, nil
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
"lark-calendar/references/create.md": {Data: []byte("# Create")},
|
||||
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
|
||||
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
|
||||
"lark-im/references/send.md": {Data: []byte("# Send")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
r := New(testFS())
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("got %d skills, want 2", len(skills))
|
||||
}
|
||||
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
|
||||
t.Fatalf("skills not sorted by name: %v", skills)
|
||||
}
|
||||
if skills[0].Description != "Calendar skill" {
|
||||
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
|
||||
}
|
||||
// version is the frontmatter `version:` field, passed through for drift checks.
|
||||
if skills[0].Version != "1.0.0" {
|
||||
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
|
||||
}
|
||||
// metadata is the frontmatter `metadata:` block, passed through verbatim.
|
||||
if skills[0].Metadata == nil {
|
||||
t.Fatal("expected metadata for lark-calendar")
|
||||
}
|
||||
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
|
||||
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
|
||||
}
|
||||
// No frontmatter → empty description and nil metadata (omitted from JSON).
|
||||
if skills[1].Description != "" {
|
||||
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
|
||||
}
|
||||
if skills[1].Metadata != nil {
|
||||
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
|
||||
}
|
||||
if skills[1].Version != "" {
|
||||
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPath(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
// Skill root: direct children only (one layer), each path skill-prefixed.
|
||||
entries, listed, err := r.ListPath("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath root error: %v", err)
|
||||
}
|
||||
if listed != "lark-calendar" {
|
||||
t.Errorf("listed path: got %q", listed)
|
||||
}
|
||||
want := map[string]bool{ // path → isDir
|
||||
"lark-calendar/SKILL.md": false,
|
||||
"lark-calendar/references": true,
|
||||
"lark-calendar/assets": true,
|
||||
}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
|
||||
}
|
||||
for _, e := range entries {
|
||||
isDir, ok := want[e.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected entry %q", e.Path)
|
||||
continue
|
||||
}
|
||||
if e.IsDir != isDir {
|
||||
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
|
||||
}
|
||||
}
|
||||
// Entries are sorted by path.
|
||||
if entries[0].Path != "lark-calendar/SKILL.md" {
|
||||
t.Errorf("entries not sorted: %v", entries)
|
||||
}
|
||||
|
||||
// Subdirectory: one layer under <name>/<subpath>.
|
||||
subEntries, subListed, err := r.ListPath("lark-calendar/references")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath subdir error: %v", err)
|
||||
}
|
||||
if subListed != "lark-calendar/references" {
|
||||
t.Errorf("listed subpath: got %q", subListed)
|
||||
}
|
||||
if len(subEntries) != 2 ||
|
||||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
|
||||
subEntries[1].Path != "lark-calendar/references/create.md" {
|
||||
t.Errorf("subdir entries: got %v", subEntries)
|
||||
}
|
||||
|
||||
// Unknown skill → typed validation error.
|
||||
if _, _, err := r.ListPath("no-such-skill"); err == nil {
|
||||
t.Error("expected error for unknown skill")
|
||||
} else {
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Path that points at a file (not a dir) → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
|
||||
t.Error("expected error listing a file")
|
||||
} else if !strings.Contains(err.Error(), "is a file") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Nonexistent subpath → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Traversal in the subpath is rejected, no listing leaked.
|
||||
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
|
||||
entries, _, err := r.ListPath(bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if entries != nil {
|
||||
t.Errorf("entries leaked for %q: %v", bad, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkill(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, err := r.ReadSkill("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSkill error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
|
||||
t.Errorf("unexpected content: %q", string(data))
|
||||
}
|
||||
|
||||
_, err = r.ReadSkill("no-such-skill")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown skill")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
|
||||
t.Errorf("message: got %q", verr.Message)
|
||||
}
|
||||
|
||||
if _, err := r.ReadSkill("../etc"); err == nil {
|
||||
t.Error("expected error for name with separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadReference(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadReference error: %v", err)
|
||||
}
|
||||
if string(data) != "# Agenda" {
|
||||
t.Errorf("content: got %q", string(data))
|
||||
}
|
||||
if cleaned != "references/agenda.md" {
|
||||
t.Errorf("cleaned path: got %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
|
||||
t.Error("expected directory error")
|
||||
} else if !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
|
||||
data, _, err := r.ReadReference("lark-calendar", bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if data != nil {
|
||||
t.Errorf("content leaked for %q: %q", bad, string(data))
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected validation error for %q, got %T", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDesc string
|
||||
wantVer string
|
||||
wantHasMeta bool
|
||||
}{
|
||||
{
|
||||
name: "description, version and metadata",
|
||||
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
|
||||
wantDesc: "My skill",
|
||||
wantVer: "2.1.0",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "description only, no metadata",
|
||||
input: "---\ndescription: Plain\n---\nbody\n",
|
||||
wantDesc: "Plain",
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
input: "no frontmatter here\n",
|
||||
},
|
||||
{
|
||||
name: "unclosed frontmatter",
|
||||
input: "---\ndescription: Never closed\n",
|
||||
},
|
||||
{
|
||||
name: "malformed YAML inside frontmatter",
|
||||
input: "---\n: bad: yaml: [\n---\nbody\n",
|
||||
},
|
||||
{
|
||||
name: "CRLF line endings",
|
||||
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
|
||||
wantDesc: "CRLF skill",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, ver, meta := parseFrontmatter([]byte(tc.input))
|
||||
if desc != tc.wantDesc {
|
||||
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
if ver != tc.wantVer {
|
||||
t.Errorf("version = %q, want %q", ver, tc.wantVer)
|
||||
}
|
||||
if (meta != nil) != tc.wantHasMeta {
|
||||
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkillMissingFile(t *testing.T) {
|
||||
// Use a separate MapFS so testFS() (and TestList) are unaffected.
|
||||
emptyFS := fstest.MapFS{
|
||||
"lark-empty/references/x.md": {Data: []byte("# X")},
|
||||
}
|
||||
r := New(emptyFS)
|
||||
_, err := r.ReadSkill("lark-empty")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SKILL.md is absent")
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(err, &ierr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/selfupdate"
|
||||
)
|
||||
|
||||
const skillsStateUpdatedAtLayout = "2006-01-02T15:04:05"
|
||||
|
||||
var (
|
||||
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
|
||||
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
|
||||
@@ -82,30 +80,6 @@ func ParseGlobalSkillsJSON(text string) []string {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
|
||||
type officialSkill struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type officialIndex struct {
|
||||
Skills []officialSkill `json:"skills"`
|
||||
}
|
||||
|
||||
var index officialIndex
|
||||
if err := json.Unmarshal([]byte(text), &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, skill := range index.Skills {
|
||||
candidate := strings.TrimSpace(skill.Name)
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
|
||||
return sortedKeys(seen), nil
|
||||
}
|
||||
|
||||
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||
func parseGlobalSkillsList(lines []string) []string {
|
||||
seen := map[string]bool{}
|
||||
@@ -186,7 +160,8 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
|
||||
if len(parts) > 0 {
|
||||
candidate := parts[0]
|
||||
if skillNamePattern.MatchString(candidate) {
|
||||
// Check if it's a valid official skill name
|
||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
@@ -248,7 +223,6 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
}
|
||||
|
||||
type SkillsRunner interface {
|
||||
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||
ListOfficialSkills() *selfupdate.NpmResult
|
||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
@@ -284,9 +258,14 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
officialResult := opts.Runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
|
||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
@@ -337,7 +316,7 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
UpdatedAt: opts.Now().Format(skillsStateUpdatedAtLayout),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
result.Action = "failed"
|
||||
@@ -348,40 +327,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
indexResult := runner.ListOfficialSkillsIndex()
|
||||
if indexResult == nil || indexResult.Err != nil {
|
||||
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
|
||||
} else {
|
||||
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
|
||||
if err != nil {
|
||||
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
|
||||
} else if len(official) > 0 {
|
||||
return official, "", true
|
||||
} else {
|
||||
reasons = append(reasons, "official skills index contains no skills")
|
||||
}
|
||||
}
|
||||
|
||||
officialResult := runner.ListOfficialSkills()
|
||||
if officialResult == nil || officialResult.Err != nil {
|
||||
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
|
||||
return nil, strings.Join(reasons, "; "), false
|
||||
}
|
||||
official := ParseSkillsList(officialResult.Stdout.String())
|
||||
if len(official) > 0 {
|
||||
return official, "", true
|
||||
}
|
||||
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
||||
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
|
||||
} else {
|
||||
reasons = append(reasons, "official skills list returned no skills")
|
||||
}
|
||||
return nil, strings.Join(reasons, "; "), false
|
||||
}
|
||||
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
@@ -430,7 +375,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
SkippedDeletedSkills: []string{},
|
||||
UpdatedAt: opts.Now().Format(skillsStateUpdatedAtLayout),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if writeErr := WriteState(state); writeErr != nil {
|
||||
return &SyncResult{
|
||||
|
||||
@@ -30,19 +30,6 @@ lark-cli-harness:dev@0.1.0
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
|
||||
input := `Available Skills
|
||||
│ lark-calendar
|
||||
│ official-shared
|
||||
│ bad/name
|
||||
`
|
||||
got := ParseSkillsList(input)
|
||||
want := []string{"lark-calendar", "official-shared"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalSkillsList(t *testing.T) {
|
||||
input := `Global Skills
|
||||
|
||||
@@ -123,43 +110,6 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
|
||||
input := `{
|
||||
"skills": [
|
||||
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
|
||||
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
|
||||
{"name":" lark-base ","description":"Base","files":[]},
|
||||
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
|
||||
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
|
||||
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
|
||||
{"name":"","description":"empty","files":["SKILL.md"]}
|
||||
]
|
||||
}`
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
|
||||
}
|
||||
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`not json`,
|
||||
`[{"name":"lark-calendar"}]`,
|
||||
`{"name":"lark-calendar"}`,
|
||||
`{"skills":[]}`,
|
||||
`{"skills":[{"name":"bad skill"}]}`,
|
||||
} {
|
||||
got, err := ParseOfficialSkillsIndexJSON(input)
|
||||
if err == nil && len(got) != 0 {
|
||||
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||
got := PlanSync(SyncInput{
|
||||
@@ -206,11 +156,9 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeSkillsRunner struct {
|
||||
officialIndexOut string
|
||||
officialOut string
|
||||
globalJSONOut string
|
||||
globalOut string
|
||||
officialIndexErr error
|
||||
officialErr error
|
||||
globalJSONErr error
|
||||
globalErr error
|
||||
@@ -218,8 +166,6 @@ type fakeSkillsRunner struct {
|
||||
installAllErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
listedGlobalText int
|
||||
}
|
||||
@@ -235,19 +181,6 @@ func officialSkillsOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func officialSkillsIndexOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"skills":[`)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func globalSkillsOutput(names ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Global Skills\n\n")
|
||||
@@ -273,16 +206,7 @@ func globalSkillsJSONOutput(names ...string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
|
||||
f.listedIndex++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialIndexOut)
|
||||
r.Err = f.officialIndexErr
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||
f.listedOfficial++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Stdout.WriteString(f.officialOut)
|
||||
r.Err = f.officialErr
|
||||
@@ -331,17 +255,14 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Runner: runner,
|
||||
Now: func() time.Time {
|
||||
return time.Date(2026, 5, 18, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
@@ -363,127 +284,17 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
|
||||
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
|
||||
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
|
||||
if state.UpdatedAt != "2026-05-18T12:00:00" {
|
||||
t.Errorf("state.UpdatedAt = %q, want local wall-clock timestamp without timezone", state.UpdatedAt)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
|
||||
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
globalOut: globalSkillsOutput("lark-mail"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||
if runner.listedIndex != 1 {
|
||||
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||
}
|
||||
if runner.listedOfficial != 0 {
|
||||
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: `{"skills":[]}`,
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -511,9 +322,8 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -532,10 +342,9 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed"),
|
||||
globalOut: globalSkillsOutput("lark-calendar"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -558,10 +367,9 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -583,19 +391,12 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: `[]`,
|
||||
globalOut: "Some unrecognized output format\n",
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Runner: runner,
|
||||
Now: func() time.Time {
|
||||
return time.Date(2026, 5, 18, 12, 0, 0, 0, time.FixedZone("UTC-7", -7*60*60))
|
||||
},
|
||||
})
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
@@ -605,13 +406,6 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.UpdatedAt != "2026-05-18T12:00:00" {
|
||||
t.Errorf("state.UpdatedAt = %q, want local wall-clock timestamp without timezone", state.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
@@ -626,10 +420,9 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput(),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -652,12 +445,11 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -685,12 +477,11 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: fmt.Errorf("full install boom"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -719,9 +510,8 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -737,9 +527,8 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: fmt.Errorf("full install failed"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -762,9 +551,8 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
officialOut: "Some unrecognized output format\n",
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -788,12 +576,11 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -814,12 +601,11 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
installErr: fmt.Errorf("incremental boom"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -839,9 +625,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
officialErr: fmt.Errorf("list failed"),
|
||||
installAllErr: nil,
|
||||
}
|
||||
|
||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
@@ -858,10 +643,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
||||
}
|
||||
|
||||
runner2 := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||
}
|
||||
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||
if result2.Action != "synced" {
|
||||
|
||||
@@ -15,13 +15,8 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/whiteboard/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -16,14 +16,8 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
||||
// calls, not output.Err* identifiers — this AST rule covers that gap.
|
||||
//
|
||||
// Migrated code must call the domain's typed API wrapper or use
|
||||
// Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
|
||||
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||
// typed errs.* errors.
|
||||
//
|
||||
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
File: path,
|
||||
Line: fset.Position(call.Pos()).Line,
|
||||
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
|
||||
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
|
||||
"so failures classify into typed errs.* errors",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -618,35 +618,6 @@ func boom() error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import "github.com/larksuite/cli/internal/output"
|
||||
|
||||
func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "ExitError") {
|
||||
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
@@ -691,7 +662,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -830,26 +801,6 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Message, "CallAPI") {
|
||||
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
@@ -900,14 +851,14 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
src := `package im
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -946,9 +897,6 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
paths := []string{
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -976,29 +924,8 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||
src := `package calendar
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
common.RejectDangerousChars("--summary", "x")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
|
||||
if len(v) != 1 {
|
||||
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Action != ActionReject {
|
||||
t.Errorf("action = %q, want REJECT", v[0].Action)
|
||||
}
|
||||
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
|
||||
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
src := `package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -1006,7 +933,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+advperm-enable", "--base-token", "app_x"}
|
||||
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request")
|
||||
if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
|
||||
@@ -224,5 +226,7 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
|
||||
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied")
|
||||
if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,24 +55,24 @@ func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *c
|
||||
|
||||
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return baseFlagErrorf("--name must not be blank")
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
||||
return baseFlagErrorf("--type must not be blank")
|
||||
return common.FlagErrorf("--type must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
|
||||
return baseFlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("name")) == "" {
|
||||
return baseFlagErrorf("--name must not be blank")
|
||||
return common.FlagErrorf("--name must not be blank")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&dsl); err != nil {
|
||||
return baseFlagErrorf("--dsl invalid JSON: %v", err)
|
||||
return common.FlagErrorf("--dsl invalid JSON: %v", err)
|
||||
}
|
||||
_, hasDim := dsl["dimensions"]
|
||||
_, hasMeas := dsl["measures"]
|
||||
if !hasDim && !hasMeas {
|
||||
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||
return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -4,13 +4,9 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
@@ -28,198 +24,76 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
|
||||
// structured ErrAPI, with server-provided message/hint promoted to the top level.
|
||||
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, baseAPIBoundaryError(err, action)
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
|
||||
}
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
|
||||
}
|
||||
if _, exists := resultMap["code"]; !exists {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
|
||||
}
|
||||
code, numeric := util.ToFloat64(resultMap["code"])
|
||||
if !numeric {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
|
||||
}
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code == 0 {
|
||||
return resultMap["data"], nil
|
||||
}
|
||||
|
||||
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
|
||||
// typed envelope and exists so call sites read as flag rejections.
|
||||
func baseFlagErrorf(format string, args ...any) error {
|
||||
return baseValidationErrorf(format, args...)
|
||||
}
|
||||
|
||||
func baseValidationErrorf(format string, args ...any) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
|
||||
if params := flagParams(msg); len(params) > 0 {
|
||||
err = err.WithParam(params[0].Name).WithParams(params...)
|
||||
larkCode := int(code)
|
||||
msg := extractDataErrorMessage(resultMap)
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
msg, _ = resultMap["msg"].(string)
|
||||
}
|
||||
if cause := firstErrorArg(args); cause != nil {
|
||||
err = err.WithCause(cause)
|
||||
|
||||
detail := extractErrorDetail(resultMap)
|
||||
apiErr := output.ErrAPI(larkCode, msg, detail)
|
||||
hint := extractErrorHint(resultMap)
|
||||
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
|
||||
apiErr.Detail.Hint = hint
|
||||
}
|
||||
return err
|
||||
if apiErr.Detail != nil {
|
||||
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
|
||||
}
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
func flagParams(msg string) []errs.InvalidParam {
|
||||
reason := msg
|
||||
seen := map[string]bool{}
|
||||
params := []errs.InvalidParam{}
|
||||
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
|
||||
end := start + 2
|
||||
for end < len(msg) {
|
||||
ch := msg[end]
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if end > start+2 {
|
||||
name := msg[start:end]
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
|
||||
}
|
||||
}
|
||||
msg = msg[end:]
|
||||
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
|
||||
detailMap, ok := detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return params
|
||||
for key, value := range detailMap {
|
||||
if value == nil {
|
||||
delete(detailMap, key)
|
||||
}
|
||||
}
|
||||
if len(detailMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
return detailMap
|
||||
}
|
||||
|
||||
func firstErrorArg(args []any) error {
|
||||
for _, arg := range args {
|
||||
if err, ok := arg.(error); ok {
|
||||
return err
|
||||
}
|
||||
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
|
||||
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
|
||||
return detail
|
||||
}
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if detail, ok := nonNilMapValue(data, "error"); ok {
|
||||
return detail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// baseMissingFileIOError reports a broken runtime wiring: a command that needs
|
||||
// local file access was constructed without a FileIO provider. The user cannot
|
||||
// fix this by changing flags, so it classifies as internal, not validation.
|
||||
func baseMissingFileIOError(format string, args ...any) error {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
|
||||
}
|
||||
|
||||
func baseInputStatError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
|
||||
if src == nil {
|
||||
return nil, false
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
|
||||
value, ok := src[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
func baseSaveError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithCause(err)
|
||||
case errors.As(err, &me):
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).WithCause(err)
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return nil, false
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
|
||||
func baseAPIBoundaryError(err error, action string) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
|
||||
}
|
||||
|
||||
func baseUploadAttachmentError(filePath string, err error) error {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
|
||||
}
|
||||
|
||||
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
|
||||
if resultMap == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
|
||||
}
|
||||
if msg := extractDataErrorMessage(resultMap); msg != "" {
|
||||
resultMap["msg"] = msg
|
||||
}
|
||||
hint := extractErrorHint(resultMap)
|
||||
if logID := extractBaseErrorLogID(resultMap); logID != "" {
|
||||
resultMap["log_id"] = logID
|
||||
}
|
||||
err := errclass.BuildAPIError(resultMap, cc)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok && hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
return err
|
||||
}
|
||||
result, parseErr := decodeBaseV3Response(body)
|
||||
if parseErr != nil {
|
||||
return err
|
||||
}
|
||||
enriched := baseAPIErrorFromResult(result, cc)
|
||||
if enriched == nil {
|
||||
return err
|
||||
}
|
||||
src, _ := errs.ProblemOf(enriched)
|
||||
dst, _ := errs.ProblemOf(err)
|
||||
if src != nil && dst != nil {
|
||||
dst.Message = src.Message
|
||||
dst.Hint = src.Hint
|
||||
// A body without log_id must not erase a header-derived LogID
|
||||
// already carried by err.
|
||||
if src.LogID != "" {
|
||||
dst.LogID = src.LogID
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
|
||||
for _, key := range []string{"log_id", "logid"} {
|
||||
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"log_id", "logid"} {
|
||||
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
if detail, ok := data["error"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"log_id", "logid"} {
|
||||
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractErrorHint(resultMap map[string]interface{}) string {
|
||||
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
|
||||
if hint := consumeStringField(detail, "hint"); hint != "" {
|
||||
|
||||
@@ -4,15 +4,30 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestErrorDetailHelpers(t *testing.T) {
|
||||
if value, ok := nonNilMapValue(nil, "error"); ok || value != nil {
|
||||
t.Fatalf("nil map should not return value")
|
||||
}
|
||||
if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil {
|
||||
t.Fatalf("nil entry should not return value")
|
||||
}
|
||||
detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
|
||||
if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil {
|
||||
t.Fatalf("expected non-nil detail")
|
||||
}
|
||||
if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil {
|
||||
t.Fatalf("expected root detail")
|
||||
}
|
||||
if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil {
|
||||
t.Fatalf("expected nested detail")
|
||||
}
|
||||
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
|
||||
t.Fatalf("hint=%q", got)
|
||||
}
|
||||
@@ -38,12 +53,9 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
||||
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
|
||||
t.Fatalf("err=%v", err)
|
||||
} else {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Code != 190001 {
|
||||
t.Fatalf("expected typed code 190001, got %T %v", err, err)
|
||||
}
|
||||
if p.Hint != "check field name" {
|
||||
t.Fatalf("hint=%q", p.Hint)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
|
||||
t.Fatalf("expected structured code 190001, got %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
|
||||
@@ -51,7 +63,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
|
||||
func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 800010407,
|
||||
"msg": "cell value invalid",
|
||||
@@ -75,27 +87,55 @@ func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if p.Code != 800010407 {
|
||||
t.Fatalf("code=%d", p.Code)
|
||||
|
||||
errDetail := exitErr.Detail
|
||||
if errDetail.Code != 800010407 {
|
||||
t.Fatalf("code=%d", errDetail.Code)
|
||||
}
|
||||
if p.Message != "The cell value does not match the expected input shape." {
|
||||
t.Fatalf("message=%q", p.Message)
|
||||
if errDetail.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", errDetail.Hint)
|
||||
}
|
||||
if p.Hint != "Provide a number value." {
|
||||
t.Fatalf("hint=%q", p.Hint)
|
||||
detail, _ := errDetail.Detail.(map[string]interface{})
|
||||
if detail == nil {
|
||||
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
|
||||
}
|
||||
if p.LogID != "20260508160000000000000000000000" {
|
||||
t.Fatalf("logID=%q", p.LogID)
|
||||
if _, exists := detail["message"]; exists {
|
||||
t.Fatalf("detail should not repeat message: %#v", detail)
|
||||
}
|
||||
if _, exists := detail["hint"]; exists {
|
||||
t.Fatalf("detail should not repeat hint: %#v", detail)
|
||||
}
|
||||
if _, exists := detail["docs_url"]; exists {
|
||||
t.Fatalf("detail should omit nil docs_url: %#v", detail)
|
||||
}
|
||||
if detail["level"] != "error" {
|
||||
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
|
||||
}
|
||||
if detail["extra_context"] != "future detail field" {
|
||||
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
|
||||
}
|
||||
if detail["path"] != "Amount" || detail["value"] != "abc" {
|
||||
t.Fatalf("cleaned detail mismatch: %#v", detail)
|
||||
}
|
||||
if detail["logid"] != "20260508160000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
}
|
||||
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
|
||||
t.Fatalf("retryable=%v", detail["retryable"])
|
||||
}
|
||||
table, _ := detail["table"].(map[string]interface{})
|
||||
if table["id"] != "tbl_1" || table["name"] != "Orders" {
|
||||
t.Fatalf("table=%#v", detail["table"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
|
||||
func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": 99991676,
|
||||
"code": output.LarkErrTokenNoPermission,
|
||||
"msg": "permission denied",
|
||||
"data": map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
@@ -106,15 +146,15 @@ func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if p.Code != 99991676 {
|
||||
t.Fatalf("code=%d", p.Code)
|
||||
if exitErr.Detail.Message != "Permission denied [99991676]" {
|
||||
t.Fatalf("message=%q", exitErr.Detail.Message)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
if exitErr.Detail.Detail != nil {
|
||||
t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,91 +167,16 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
|
||||
attachBaseErrorLogID(result, "20260508170000000000000000000000")
|
||||
|
||||
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if p.LogID != "20260508170000000000000000000000" {
|
||||
t.Fatalf("logID=%q", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaseAPIResultRejectsNonNumericCode(t *testing.T) {
|
||||
for _, code := range []interface{}{"oops", map[string]interface{}{}, nil} {
|
||||
result := map[string]interface{}{"code": code, "msg": "weird envelope"}
|
||||
_, err := handleBaseAPIResultAny(result, nil, "list tables")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("code=%#v: expected typed error, got %T %v", code, err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("code=%#v: category/subtype=%s/%s", code, p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "list tables") {
|
||||
t.Fatalf("code=%#v: message=%q", code, p.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichBaseAPIErrorFromBodyLogIDMerge(t *testing.T) {
|
||||
t.Run("body without log_id keeps header-derived LogID", func(t *testing.T) {
|
||||
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
|
||||
err := enrichBaseAPIErrorFromBody(outer, []byte(`{"code":190001,"msg":"boom"}`), errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if p.Message != "boom" {
|
||||
t.Fatalf("message=%q", p.Message)
|
||||
}
|
||||
if p.LogID != "header-log-id" {
|
||||
t.Fatalf("logID=%q, want header-log-id", p.LogID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("body log_id overrides header-derived LogID", func(t *testing.T) {
|
||||
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
|
||||
body := `{"code":190001,"msg":"boom","data":{"error":{"logid":"body-log-id"}}}`
|
||||
err := enrichBaseAPIErrorFromBody(outer, []byte(body), errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if p.LogID != "body-log-id" {
|
||||
t.Fatalf("logID=%q, want body-log-id", p.LogID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseMissingFileIOErrorIsInternal(t *testing.T) {
|
||||
p, ok := errs.ProblemOf(baseMissingFileIOError("file operations require a FileIO provider"))
|
||||
if !ok {
|
||||
t.Fatal("expected typed error")
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["logid"] != "20260508170000000000000000000000" {
|
||||
t.Fatalf("logid=%q", detail["logid"])
|
||||
}
|
||||
}
|
||||
|
||||
type assertErr struct{}
|
||||
|
||||
func (assertErr) Error() string { return "network timeout" }
|
||||
|
||||
func assertProblemCode(t *testing.T, err error, code int, messageParts ...string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error with code %d", code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Code != code {
|
||||
t.Fatalf("code=%d, want %d; err=%v", p.Code, code, err)
|
||||
}
|
||||
for _, part := range messageParts {
|
||||
if !strings.Contains(p.Message, part) {
|
||||
t.Fatalf("message=%q missing %q", p.Message, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -514,65 +513,6 @@ func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
params []string
|
||||
}{
|
||||
{
|
||||
name: "create blank name",
|
||||
shortcut: BaseBaseBlockCreate,
|
||||
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
|
||||
params: []string{"--name"},
|
||||
},
|
||||
{
|
||||
name: "move conflicting sibling anchors",
|
||||
shortcut: BaseBaseBlockMove,
|
||||
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
|
||||
params: []string{"--before-id", "--after-id"},
|
||||
},
|
||||
{
|
||||
name: "rename blank name",
|
||||
shortcut: BaseBaseBlockRename,
|
||||
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
|
||||
params: []string{"--name"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.params[0] {
|
||||
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
|
||||
}
|
||||
if len(validationErr.Params) != len(tt.params) {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
||||
}
|
||||
for i, param := range tt.params {
|
||||
if validationErr.Params[i].Name != param {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
||||
}
|
||||
if validationErr.Params[i].Reason == "" {
|
||||
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseHistoryExecute(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -931,10 +871,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
||||
t.Run("list-http-404", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables",
|
||||
Status: 404,
|
||||
RawBody: []byte("404 page not found"),
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables",
|
||||
Status: 404,
|
||||
Body: "404 page not found",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"text/plain"},
|
||||
},
|
||||
@@ -2153,9 +2093,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
|
||||
t.Fatalf("err=%v should name the offending file", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
||||
@@ -2325,23 +2262,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
tmpDir := t.TempDir()
|
||||
withBaseWorkingDir(t, tmpDir)
|
||||
|
||||
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
||||
"+record-download-attachment",
|
||||
"--base-token", "app_x",
|
||||
"--table-id", "tbl_x",
|
||||
"--record-id", "rec_x",
|
||||
"--output", "../escape",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -2538,37 +2458,21 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
"--record-id", "rec_x",
|
||||
"--output", "downloads",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var partialErr *output.PartialFailureError
|
||||
if !errors.As(err, &partialErr) {
|
||||
t.Fatalf("expected partial failure error, got %T %v", err, err)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
downloaded, _ := detail["downloaded"].([]map[string]interface{})
|
||||
failed, _ := detail["failed"].([]map[string]interface{})
|
||||
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
|
||||
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if envelope["ok"] != false {
|
||||
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
|
||||
t.Fatalf("message=%q", msg)
|
||||
}
|
||||
downloaded, _ := data["downloaded"].([]interface{})
|
||||
failed, _ := data["failed"].([]interface{})
|
||||
if len(downloaded) != 1 || len(failed) != 1 {
|
||||
t.Fatalf("data=%#v", data)
|
||||
}
|
||||
downloadedItem, _ := downloaded[0].(map[string]interface{})
|
||||
failedItem, _ := failed[0].(map[string]interface{})
|
||||
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
|
||||
t.Fatalf("data=%#v", data)
|
||||
}
|
||||
if data["log_id"] != "202605270001" {
|
||||
t.Fatalf("data=%#v, want log_id", data)
|
||||
if detail["log_id"] != "202605270001" {
|
||||
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
||||
t.Fatalf("expected first file to remain: %v", err)
|
||||
|
||||
@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "POST",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
|
||||
|
||||
var questionIds []string
|
||||
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
|
||||
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err)
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err)
|
||||
}
|
||||
|
||||
_, err := baseV3Call(runtime, "DELETE",
|
||||
|
||||
@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
|
||||
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
|
||||
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
|
||||
}
|
||||
|
||||
data, err := baseV3Call(runtime, "PATCH",
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -61,31 +62,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
|
||||
attachments, hasAttachments := raw["attachments"]
|
||||
|
||||
if !hasAttachments && fields == nil {
|
||||
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
|
||||
}
|
||||
|
||||
if hasAttachments {
|
||||
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
|
||||
if runtime.Str("base-token") == "" {
|
||||
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
|
||||
}
|
||||
|
||||
attMap, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
|
||||
}
|
||||
for fieldName, value := range attMap {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
for i, item := range paths {
|
||||
if _, ok := item.(string); !ok {
|
||||
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,21 +111,21 @@ func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if attachments, ok := raw["attachments"]; ok {
|
||||
attObj, ok := attachments.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
|
||||
}
|
||||
if len(attObj) > 0 {
|
||||
attMap = make(map[string][]string, len(attObj))
|
||||
for fieldName, value := range attObj {
|
||||
paths, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil, nil, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
|
||||
}
|
||||
filePaths := make([]string, 0, len(paths))
|
||||
for _, item := range paths {
|
||||
if s, ok := item.(string); ok {
|
||||
filePaths = append(filePaths, s)
|
||||
} else {
|
||||
return nil, nil, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
|
||||
}
|
||||
}
|
||||
if len(filePaths) > 0 {
|
||||
@@ -194,33 +195,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
|
||||
}
|
||||
|
||||
// Step 1: 收集所有唯一路径(跨字段去重)
|
||||
allPaths := collectUniquePaths(attachmentMap)
|
||||
if len(allPaths) == 0 {
|
||||
return baseFlagErrorf("attachments in --json contains no valid file paths")
|
||||
return common.FlagErrorf("attachments in --json contains no valid file paths")
|
||||
}
|
||||
|
||||
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
|
||||
sizeMap := make(map[string]int64, len(allPaths))
|
||||
for _, filePath := range allPaths {
|
||||
if _, err := validate.SafeInputPath(filePath); err != nil {
|
||||
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err)
|
||||
return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
|
||||
}
|
||||
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err)
|
||||
return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
|
||||
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return baseValidationErrorf("attachment file %s is not a regular file", filePath)
|
||||
return output.ErrValidation("attachment file %s is not a regular file", filePath)
|
||||
}
|
||||
sizeMap[filePath] = fileInfo.Size()
|
||||
}
|
||||
@@ -327,7 +328,7 @@ func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, t
|
||||
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
|
||||
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
|
||||
if err != nil {
|
||||
return nil, baseUploadAttachmentError(filePath, err)
|
||||
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -18,14 +17,6 @@ import (
|
||||
// - Inner: business-level code/message inside the data object
|
||||
//
|
||||
// The data field may be a JSON object (actual behavior) or a JSON string (per doc).
|
||||
func handleRoleAPIResponse(runtime *common.RuntimeContext, apiResp *larkcore.ApiResp, action string) error {
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
enriched := enrichBaseAPIErrorFromBody(err, apiResp.RawBody, runtime.APIClassifyContext())
|
||||
return prefixRoleActionError(enriched, action)
|
||||
}
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, action)
|
||||
}
|
||||
|
||||
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
@@ -33,17 +24,23 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rawBody, &resp); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err)
|
||||
return fmt.Errorf("failed to parse response: %v", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg}
|
||||
if len(resp.Data) > 0 {
|
||||
var data interface{}
|
||||
if json.Unmarshal(resp.Data, &data) == nil {
|
||||
result["data"] = data
|
||||
msg := resp.Msg
|
||||
// When outer msg is empty, try to extract error details from data.error.message
|
||||
if msg == "" && len(resp.Data) > 0 {
|
||||
var errData struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" {
|
||||
msg = errData.Error.Message
|
||||
}
|
||||
}
|
||||
return baseRoleAPIError(runtime, result, action)
|
||||
return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
|
||||
@@ -78,8 +75,7 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
}
|
||||
if codeInt != 0 {
|
||||
msg, _ := m["message"].(string)
|
||||
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
|
||||
return baseRoleAPIError(runtime, result, action)
|
||||
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
|
||||
}
|
||||
// code == 0, extract the inner data if present
|
||||
if innerData, hasInner := m["data"]; hasInner {
|
||||
@@ -102,20 +98,3 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseRoleAPIError(runtime *common.RuntimeContext, result map[string]interface{}, action string) error {
|
||||
return prefixRoleActionError(baseAPIErrorFromResult(result, runtime.APIClassifyContext()), action)
|
||||
}
|
||||
|
||||
// prefixRoleActionError prepends the failed role action ("create role failed",
|
||||
// "get role failed", ...) to a typed error's message so both the classified
|
||||
// outer-response path and the parsed-body path carry the same context.
|
||||
func prefixRoleActionError(err error, action string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok && action != "" {
|
||||
p.Message = action + ": " + p.Message
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ var BaseRoleCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "create role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "create role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ var BaseRoleDelete = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "delete role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ var BaseRoleGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "get role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "get role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ var BaseRoleList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "list roles failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -375,7 +375,9 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request")
|
||||
if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleListExecuteTransportError(t *testing.T) {
|
||||
@@ -403,7 +405,9 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-list", "--base-token", "app_x"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found")
|
||||
if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||
@@ -417,7 +421,9 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden")
|
||||
if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||
@@ -431,7 +437,9 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params")
|
||||
if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||
@@ -449,7 +457,9 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
|
||||
},
|
||||
})
|
||||
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
|
||||
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found")
|
||||
if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -477,7 +487,9 @@ func TestHandleRoleResponse(t *testing.T) {
|
||||
|
||||
t.Run("outer error code", func(t *testing.T) {
|
||||
rt := newRoleResponseRuntime(t)
|
||||
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error")
|
||||
if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
|
||||
@@ -562,7 +574,9 @@ func TestHandleRoleResponse(t *testing.T) {
|
||||
t.Run("business code non-zero", func(t *testing.T) {
|
||||
rt := newRoleResponseRuntime(t)
|
||||
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
|
||||
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied")
|
||||
if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("data is array", func(t *testing.T) {
|
||||
|
||||
@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("role-id")) == "" {
|
||||
return baseFlagErrorf("--role-id must not be blank")
|
||||
return common.FlagErrorf("--role-id must not be blank")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
|
||||
return baseFlagErrorf("--json must be valid JSON: %v", err)
|
||||
return common.FlagErrorf("--json must be valid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
return handleRoleAPIResponse(runtime, apiResp, "update role failed")
|
||||
return handleRoleResponse(runtime, apiResp.RawBody, "update role failed")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -30,34 +30,34 @@ func baseTableID(runtime *common.RuntimeContext) string {
|
||||
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", baseFlagErrorf("--%s cannot be empty", flagName)
|
||||
return "", common.FlagErrorf("--%s cannot be empty", flagName)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "@") {
|
||||
return raw, nil
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||
if path == "" {
|
||||
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||
return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
|
||||
}
|
||||
if pc.fio == nil {
|
||||
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName)
|
||||
return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
|
||||
}
|
||||
f, err := pc.fio.Open(path)
|
||||
if err != nil {
|
||||
var pathErr *fileio.PathValidationError
|
||||
if errors.As(err, &pathErr) {
|
||||
return "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||
return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
|
||||
}
|
||||
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||
return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||
return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path)
|
||||
return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
|
||||
|
||||
func formatJSONError(flagName string, target string, err error) error {
|
||||
if syntaxErr, ok := err.(*json.SyntaxError); ok {
|
||||
return baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
||||
return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
|
||||
}
|
||||
if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
|
||||
if typeErr.Field != "" {
|
||||
return baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
||||
return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
|
||||
}
|
||||
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
}
|
||||
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
|
||||
}
|
||||
|
||||
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
|
||||
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return "", baseFlagErrorf("specify one action")
|
||||
return "", common.FlagErrorf("specify one action")
|
||||
}
|
||||
if len(active) > 1 {
|
||||
flags := make([]string, 0, len(active))
|
||||
for _, item := range active {
|
||||
flags = append(flags, "--"+item)
|
||||
}
|
||||
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
||||
return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
|
||||
}
|
||||
return active[0], nil
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
|
||||
for idx, item := range arr {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
||||
return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1)
|
||||
}
|
||||
items = append(items, obj)
|
||||
}
|
||||
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
|
||||
case map[string]interface{}, []interface{}:
|
||||
return value, nil
|
||||
default:
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object or array", flagName)
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package base
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
// text 类型必须提供 data-config(含 text 内容)
|
||||
if strings.ToLower(runtime.Str("type")) == "text" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config")
|
||||
return fmt.Errorf("text 类型组件必须提供 data-config,包含必填字段 text")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
|
||||
if fieldType == "lookup" {
|
||||
guidePath = "skills/lark-base/references/lookup-field-guide.md"
|
||||
}
|
||||
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
||||
return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -42,10 +41,10 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
|
||||
if errors.As(err, &syntaxErr) {
|
||||
return nil, formatJSONError(flagName, "object", err)
|
||||
}
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
if result == nil {
|
||||
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -153,7 +152,7 @@ func cloneValue(value interface{}) interface{} {
|
||||
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
||||
trimmed := strings.TrimSpace(typeName)
|
||||
if trimmed == "" {
|
||||
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
|
||||
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
|
||||
}
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "text", "phone", "url", "email", "barcode":
|
||||
@@ -193,7 +192,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
|
||||
case "modifiedtime", "modified_time", "modified-time":
|
||||
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
|
||||
default:
|
||||
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName)
|
||||
return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,10 +252,10 @@ func normalizeSelectOptions(raw interface{}) []interface{} {
|
||||
|
||||
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
|
||||
if isPrimary {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body")
|
||||
return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
|
||||
}
|
||||
if isHidden {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body")
|
||||
return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
|
||||
}
|
||||
spec, err := resolveFieldTypeSpec(typeName)
|
||||
if err != nil {
|
||||
@@ -355,7 +354,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
||||
if rawFields != "" {
|
||||
var fields []interface{}
|
||||
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
|
||||
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array")
|
||||
return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -367,7 +366,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
|
||||
for _, spec := range specs {
|
||||
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err)
|
||||
return nil, fmt.Errorf("field %q: %w", spec.Name, err)
|
||||
}
|
||||
fields = append(fields, body)
|
||||
}
|
||||
@@ -411,15 +410,20 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
|
||||
h.Set("X-App-Id", runtime.Config.AppID)
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
|
||||
if err != nil {
|
||||
return nil, baseAPIBoundaryError(err, "API call failed")
|
||||
}
|
||||
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
|
||||
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
|
||||
return nil, statusErr
|
||||
}
|
||||
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
|
||||
return nil, err
|
||||
}
|
||||
result, parseErr := decodeBaseV3Response(resp.RawBody)
|
||||
if parseErr == nil && baseV3ResultCode(result) != 0 {
|
||||
attachBaseErrorLogID(result, baseResponseLogID(resp))
|
||||
return result, nil
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
body := strings.TrimSpace(string(resp.RawBody))
|
||||
if body == "" {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
@@ -431,14 +435,18 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
|
||||
return nil, fmt.Errorf("response parse error: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func baseV3ResultCode(result map[string]interface{}) int {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt(result["code"])
|
||||
}
|
||||
|
||||
func attachBaseErrorLogID(result map[string]interface{}, logID string) {
|
||||
if result == nil || strings.TrimSpace(logID) == "" {
|
||||
return
|
||||
@@ -472,33 +480,6 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
|
||||
return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
|
||||
}
|
||||
|
||||
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
|
||||
if resp == nil || resp.StatusCode < http.StatusBadRequest {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(classified)
|
||||
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
return nil
|
||||
}
|
||||
body := strings.TrimSpace(string(resp.RawBody))
|
||||
if resp.StatusCode >= http.StatusInternalServerError {
|
||||
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
|
||||
if logID := baseResponseLogID(resp); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
|
||||
if logID := baseResponseLogID(resp); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
|
||||
result, err := baseV3Raw(runtime, method, path, params, data)
|
||||
return handleBaseAPIResult(result, err, "API call failed")
|
||||
@@ -544,7 +525,7 @@ func toStringSlice(v interface{}) []string {
|
||||
|
||||
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||
if limit <= 0 {
|
||||
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||
if err != nil {
|
||||
@@ -574,7 +555,7 @@ func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, lim
|
||||
|
||||
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||
if limit <= 0 {
|
||||
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||
if err != nil {
|
||||
@@ -596,7 +577,7 @@ func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, of
|
||||
|
||||
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
|
||||
if limit <= 0 {
|
||||
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0")
|
||||
return nil, 0, fmt.Errorf("limit must be greater than 0")
|
||||
}
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
|
||||
if err != nil {
|
||||
@@ -622,7 +603,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
|
||||
return field, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref)
|
||||
return nil, fmt.Errorf("field %q not found", ref)
|
||||
}
|
||||
|
||||
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||
@@ -631,7 +612,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
|
||||
return table, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref)
|
||||
return nil, fmt.Errorf("table %q not found", ref)
|
||||
}
|
||||
|
||||
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
|
||||
@@ -640,7 +621,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
|
||||
return view, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref)
|
||||
return nil, fmt.Errorf("view %q not found", ref)
|
||||
}
|
||||
|
||||
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
|
||||
@@ -757,18 +738,18 @@ func canonicalValue(v interface{}) string {
|
||||
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
|
||||
var tuples []interface{}
|
||||
if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
|
||||
return nil, baseValidationErrorf("--%s invalid JSON array", flagName)
|
||||
return nil, fmt.Errorf("--%s invalid JSON array", flagName)
|
||||
}
|
||||
result := make([]namedTypeSpec, 0, len(tuples))
|
||||
for idx, item := range tuples {
|
||||
pair, ok := item.([]interface{})
|
||||
if !ok || len(pair) != 2 {
|
||||
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1)
|
||||
return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1)
|
||||
}
|
||||
name, ok1 := pair[0].(string)
|
||||
typeName, ok2 := pair[1].(string)
|
||||
if !ok1 || !ok2 {
|
||||
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||
return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
|
||||
}
|
||||
result = append(result, namedTypeSpec{Name: name, Type: typeName})
|
||||
}
|
||||
@@ -1174,9 +1155,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
|
||||
return errs
|
||||
}
|
||||
|
||||
func formatDataConfigErrors(problems []string) error {
|
||||
if len(problems) == 0 {
|
||||
func formatDataConfigErrors(errs []string) error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
|
||||
return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -20,7 +19,7 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
|
||||
case "", "json", "markdown":
|
||||
return nil
|
||||
default:
|
||||
return baseValidationErrorf("--format must be json or markdown")
|
||||
return output.ErrValidation("--format must be json or markdown")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +33,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
|
||||
return output.ErrValidation("--jq and --format markdown are mutually exclusive")
|
||||
}
|
||||
rendered, err := renderer(data)
|
||||
if err != nil {
|
||||
@@ -44,7 +43,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
}
|
||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return baseContentSafetyBlockError(scanResult)
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||
@@ -53,20 +52,6 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
|
||||
message := "content safety violation detected"
|
||||
var rules []string
|
||||
if scanResult.Alert != nil {
|
||||
rules = scanResult.Alert.MatchedRules
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
|
||||
}
|
||||
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
|
||||
WithRules(rules...).
|
||||
WithCause(scanResult.BlockErr)
|
||||
}
|
||||
|
||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
|
||||
}
|
||||
@@ -76,7 +61,7 @@ func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
if len(recordIDs) == 1 && len(rows) == 1 {
|
||||
rowItems, _ := rows[0].([]interface{})
|
||||
@@ -93,7 +78,7 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
|
||||
recordIDs := stringSliceValue(data["record_id_list"])
|
||||
rows, ok := data["data"].([]interface{})
|
||||
if len(fields) == 0 || !ok {
|
||||
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -213,12 +212,9 @@ func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T)
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}},
|
||||
})
|
||||
var csErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &csErr) {
|
||||
t.Fatalf("err=%v, want typed content safety error", err)
|
||||
}
|
||||
if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" {
|
||||
t.Fatalf("rules=%v", csErr.Rules)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
|
||||
t.Fatalf("err=%v, want content safety exit error", err)
|
||||
}
|
||||
if stdout.Len() > 0 {
|
||||
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())
|
||||
|
||||
@@ -49,7 +49,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
|
||||
}
|
||||
if jsonRaw != "" {
|
||||
pc := newParseCtx(runtime)
|
||||
@@ -59,11 +59,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
}
|
||||
recordIDListValue, ok := body["record_id_list"]
|
||||
if !ok {
|
||||
return recordSelection{}, baseFlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
recordIDItems, ok := recordIDListValue.([]interface{})
|
||||
if !ok {
|
||||
return recordSelection{}, baseFlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordIDs(recordIDItems)
|
||||
if err != nil {
|
||||
@@ -117,14 +117,14 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
return nil, baseFlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, baseFlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
@@ -152,7 +152,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
|
||||
if opts.allowNil {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, baseFlagErrorf(opts.typeError)
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
case []interface{}:
|
||||
rawItems = typed
|
||||
case []string:
|
||||
@@ -161,30 +161,30 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
|
||||
rawItems = append(rawItems, item)
|
||||
}
|
||||
default:
|
||||
return nil, baseFlagErrorf(opts.typeError)
|
||||
return nil, common.FlagErrorf(opts.typeError)
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
if opts.allowEmpty {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, baseFlagErrorf(opts.emptyError)
|
||||
return nil, common.FlagErrorf(opts.emptyError)
|
||||
}
|
||||
if opts.max > 0 && len(rawItems) > opts.max {
|
||||
return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
|
||||
}
|
||||
seen := make(map[string]int, len(rawItems))
|
||||
result := make([]string, 0, len(rawItems))
|
||||
for index, value := range rawItems {
|
||||
item, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, baseFlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
|
||||
}
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return nil, baseFlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
|
||||
}
|
||||
if first, exists := seen[item]; exists {
|
||||
return nil, baseFlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
|
||||
}
|
||||
seen[item] = index + 1
|
||||
result = append(result, item)
|
||||
@@ -332,10 +332,10 @@ const maxShareBatchSize = 100
|
||||
func validateRecordShareBatch(runtime *common.RuntimeContext) error {
|
||||
recordIDs := deduplicateRecordIDs(runtime)
|
||||
if len(recordIDs) == 0 {
|
||||
return baseFlagErrorf("--record-ids is required and must not be empty")
|
||||
return common.FlagErrorf("--record-ids is required and must not be empty")
|
||||
}
|
||||
if len(recordIDs) > maxShareBatchSize {
|
||||
return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||
return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,18 +71,18 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
|
||||
} else if obj, ok := value.(map[string]interface{}); ok {
|
||||
rawSortConfig, ok := obj["sort_config"]
|
||||
if !ok {
|
||||
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
parsed, ok := rawSortConfig.([]interface{})
|
||||
if !ok {
|
||||
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label)
|
||||
return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
|
||||
}
|
||||
sortConfig = parsed
|
||||
} else {
|
||||
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
|
||||
}
|
||||
if len(sortConfig) > recordSortMaxCount {
|
||||
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||
return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
|
||||
}
|
||||
return sortConfig, nil
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
|
||||
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||
return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -220,16 +220,16 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("keyword")) == "" {
|
||||
return baseFlagErrorf("--keyword is required unless --json is used")
|
||||
return common.FlagErrorf("--keyword is required unless --json is used")
|
||||
}
|
||||
if len(runtime.StrArray("search-field")) == 0 {
|
||||
return baseFlagErrorf("--search-field is required unless --json is used")
|
||||
return common.FlagErrorf("--search-field is required unless --json is used")
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -224,7 +225,7 @@ func dryRunRecordRemoveAttachment(_ context.Context, runtime *common.RuntimeCont
|
||||
|
||||
func validateRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("name") {
|
||||
return baseFlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
return common.FlagErrorf("--name is no longer supported; uploaded attachment names are derived from local file basenames")
|
||||
}
|
||||
files, err := normalizeAttachmentFiles(runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
@@ -244,16 +245,9 @@ func validateRecordDownloadAttachment(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
const outputDirRequired = "--output must be an existing directory when downloading multiple attachments or when --file-token is omitted"
|
||||
info, statErr := runtime.FileIO().Stat(runtime.Str("output"))
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, fileio.ErrPathValidation) {
|
||||
return baseValidationErrorf("unsafe output path: %s", statErr)
|
||||
}
|
||||
return baseFlagErrorf(outputDirRequired)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return baseFlagErrorf(outputDirRequired)
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return common.FlagErrorf("--output must be an existing directory when downloading multiple attachments or when --file-token is omitted")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -275,7 +269,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
@@ -322,7 +316,7 @@ func executeRecordRemoveAttachment(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" {
|
||||
return baseValidationErrorf("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized)
|
||||
}
|
||||
resolvedFieldID := fieldID(field)
|
||||
if resolvedFieldID == "" {
|
||||
@@ -359,7 +353,7 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
|
||||
saved, err := downloadBaseAttachment(ctx, runtime, target.Item, target.TargetPath, runtime.Bool("overwrite"))
|
||||
if err != nil {
|
||||
failed := attachmentDownloadFailure(target, err)
|
||||
return attachmentDownloadProgressError(runtime, err, downloaded, []map[string]interface{}{failed})
|
||||
return attachmentDownloadProgressError(err, downloaded, []map[string]interface{}{failed})
|
||||
}
|
||||
downloaded = append(downloaded, saved)
|
||||
}
|
||||
@@ -370,20 +364,20 @@ func executeRecordDownloadAttachment(ctx context.Context, runtime *common.Runtim
|
||||
func validateAttachmentInputFile(runtime *common.RuntimeContext, filePath string) (fileio.FileInfo, error) {
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
return nil, baseValidationErrorf("file operations require a FileIO provider")
|
||||
return nil, output.ErrValidation("file operations require a FileIO provider")
|
||||
}
|
||||
fileInfo, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return nil, baseValidationErrorf("unsafe file path: %s", err)
|
||||
return nil, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
return nil, baseValidationErrorf("file not accessible: %s: %v", filePath, err)
|
||||
return nil, output.ErrValidation("file not accessible: %s: %v", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return nil, baseValidationErrorf("file path is a directory: %s", filePath)
|
||||
return nil, output.ErrValidation("file path is a directory: %s", filePath)
|
||||
}
|
||||
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
|
||||
return nil, baseValidationErrorf("file %s exceeds 2GB limit (size: %s)", filePath, common.FormatSize(fileInfo.Size()))
|
||||
return nil, output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
|
||||
}
|
||||
return fileInfo, nil
|
||||
}
|
||||
@@ -418,13 +412,13 @@ func normalizeOptionalDownloadAttachmentFileTokens(tokens []string) ([]string, e
|
||||
for index, token := range tokens {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, baseFlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
return nil, common.FlagErrorf("attachment file token %d must not be empty", index+1)
|
||||
}
|
||||
normalized = append(normalized, token)
|
||||
}
|
||||
normalized = dedupeStringsPreserveOrder(normalized)
|
||||
if len(normalized) > baseAttachmentMaxBatchSize {
|
||||
return nil, baseFlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
return nil, common.FlagErrorf("attachment file token count exceeds maximum limit of %d (got %d)", baseAttachmentMaxBatchSize, len(normalized))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -459,10 +453,10 @@ func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fie
|
||||
|
||||
func fetchBaseAttachments(runtime *common.RuntimeContext, baseToken, tableIDValue string, recordIDs []string) (map[string]interface{}, error) {
|
||||
if len(recordIDs) == 0 {
|
||||
return nil, baseValidationErrorf("provide at least one record id")
|
||||
return nil, output.ErrValidation("provide at least one record id")
|
||||
}
|
||||
if len(recordIDs) > baseAttachmentGetMaxRecords {
|
||||
return nil, baseValidationErrorf("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
return nil, output.ErrValidation("get attachments record selection exceeds maximum limit of %d (got %d)", baseAttachmentGetMaxRecords, len(recordIDs))
|
||||
}
|
||||
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "get_attachments"), nil, map[string]interface{}{
|
||||
"record_id_list": recordIDs,
|
||||
@@ -566,14 +560,14 @@ func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (str
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return "", baseInputStatError(err)
|
||||
return "", common.WrapInputStatError(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, readErr := f.Read(buf)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
return "", baseValidationErrorf("cannot read file: %s", readErr)
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
return detectAttachmentMIMEFromContent(buf[:n]), nil
|
||||
}
|
||||
@@ -623,11 +617,11 @@ type baseAttachmentDownloadTarget struct {
|
||||
func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID string, tokens []string) ([]baseAttachmentDownloadItem, error) {
|
||||
recordRaw, ok := attachments[recordID]
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
return nil, output.ErrValidation("record %q has no attachment metadata; verify the record-id", recordID)
|
||||
}
|
||||
fields, ok := recordRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
return nil, output.ErrValidation("record %q attachment metadata has unexpected type %T", recordID, recordRaw)
|
||||
}
|
||||
byToken := map[string]baseAttachmentDownloadItem{}
|
||||
fieldIDs := make([]string, 0, len(fields))
|
||||
@@ -639,12 +633,12 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
||||
rawList := fields[currentFieldID]
|
||||
items, ok := rawList.([]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
return nil, output.ErrValidation("record %q field %q attachment metadata has unexpected type %T", recordID, currentFieldID, rawList)
|
||||
}
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
return nil, output.ErrValidation("record %q field %q contains unexpected attachment item type %T", recordID, currentFieldID, rawItem)
|
||||
}
|
||||
fileToken, _ := item["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
@@ -674,7 +668,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
||||
result = append(result, item)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, baseValidationErrorf("record %q has no attachments to download", recordID)
|
||||
return nil, output.ErrValidation("record %q has no attachments to download", recordID)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
leftName := strings.ToLower(baseAttachmentDownloadName(result[i]))
|
||||
@@ -689,7 +683,7 @@ func selectAttachmentDownloadItems(attachments map[string]interface{}, recordID
|
||||
for _, token := range tokens {
|
||||
item, ok := byToken[token]
|
||||
if !ok {
|
||||
return nil, baseValidationErrorf("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
return nil, output.ErrValidation("attachment file_token %q not found in record %q; verify the record-id/file-token pair", token, recordID)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
@@ -708,15 +702,15 @@ func planAttachmentDownloadTargets(runtime *common.RuntimeContext, items []baseA
|
||||
}
|
||||
resolved, err := runtime.ResolveSavePath(targetPath)
|
||||
if err != nil {
|
||||
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if previous, exists := seen[resolved]; exists {
|
||||
return nil, baseValidationErrorf("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
return nil, output.ErrValidation("multiple attachments resolve to the same output path %q (%s and %s); download them separately or choose a different directory", resolved, previous.FileToken, item.FileToken)
|
||||
}
|
||||
seen[resolved] = item
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
targets = append(targets, baseAttachmentDownloadTarget{
|
||||
@@ -782,7 +776,7 @@ func safeAttachmentFileTokenSuffix(fileToken string) string {
|
||||
|
||||
func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext, item baseAttachmentDownloadItem, targetPath string, overwrite bool) (map[string]interface{}, error) {
|
||||
if _, err := runtime.ResolveSavePath(targetPath); err != nil {
|
||||
return nil, baseValidationErrorf("unsafe output path: %s", err)
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
query := larkcore.QueryParams{}
|
||||
@@ -801,7 +795,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
||||
|
||||
if !overwrite {
|
||||
if _, statErr := runtime.FileIO().Stat(targetPath); statErr == nil {
|
||||
return nil, baseValidationErrorf("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", targetPath)
|
||||
}
|
||||
}
|
||||
result, err := runtime.FileIO().Save(targetPath, fileio.SaveOptions{
|
||||
@@ -809,7 +803,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, baseSaveError(err)
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(targetPath)
|
||||
if savedPath == "" {
|
||||
@@ -828,7 +822,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
|
||||
}
|
||||
|
||||
func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) map[string]interface{} {
|
||||
failure := map[string]interface{}{
|
||||
return map[string]interface{}{
|
||||
"record_id": target.Item.RecordID,
|
||||
"field_id": target.Item.FieldID,
|
||||
"file_token": target.Item.FileToken,
|
||||
@@ -837,45 +831,72 @@ func attachmentDownloadFailure(target baseAttachmentDownloadTarget, err error) m
|
||||
"resolved_path": target.ResolvedPath,
|
||||
"error": err.Error(),
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
failure["type"] = string(p.Category)
|
||||
failure["subtype"] = string(p.Subtype)
|
||||
if p.Code != 0 {
|
||||
failure["code"] = p.Code
|
||||
}
|
||||
if p.LogID != "" {
|
||||
failure["log_id"] = p.LogID
|
||||
}
|
||||
}
|
||||
return failure
|
||||
}
|
||||
|
||||
func attachmentDownloadProgressError(runtime *common.RuntimeContext, err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
func attachmentDownloadProgressError(err error, downloaded []map[string]interface{}, failed []map[string]interface{}) error {
|
||||
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
|
||||
payload := map[string]interface{}{
|
||||
"message": msg,
|
||||
detail := map[string]interface{}{
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
}
|
||||
const hint = "Some files may already have been saved. Inspect downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
||||
payload["hint"] = hint
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
payload["type"] = string(p.Category)
|
||||
payload["subtype"] = string(p.Subtype)
|
||||
if p.Code != 0 {
|
||||
payload["code"] = p.Code
|
||||
if logID := baseAttachmentDownloadLogID(err); logID != "" {
|
||||
detail["log_id"] = logID
|
||||
}
|
||||
const hint = "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists."
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return &output.ExitError{
|
||||
Code: exitErr.Code,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: exitErr.Detail.Type,
|
||||
Code: exitErr.Detail.Code,
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
if logID := baseAttachmentDownloadLogID(err); logID != "" {
|
||||
payload["log_id"] = logID
|
||||
var netErr *errs.NetworkError
|
||||
if errors.As(err, &netErr) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitNetwork,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "network",
|
||||
Code: netErr.Code,
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitInternal,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "io",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
return runtime.OutPartialFailure(payload, nil)
|
||||
}
|
||||
|
||||
func baseAttachmentDownloadLogID(err error) string {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if logID := strings.TrimSpace(p.LogID); logID != "" {
|
||||
return logID
|
||||
var netErr *errs.NetworkError
|
||||
if errors.As(err, &netErr) {
|
||||
if id := strings.TrimSpace(netErr.LogID); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
if detail, ok := exitErr.Detail.Detail.(map[string]interface{}); ok {
|
||||
if logID, _ := detail["log_id"].(string); logID != "" {
|
||||
return strings.TrimSpace(logID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -5,6 +5,7 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -116,7 +117,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error {
|
||||
for idx, item := range fieldItems {
|
||||
body, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return baseValidationErrorf("--fields item %d must be an object", idx+1)
|
||||
return fmt.Errorf("--fields item %d must be an object", idx+1)
|
||||
}
|
||||
if idx == 0 && len(defaultFields) > 0 {
|
||||
fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body)
|
||||
|
||||
@@ -31,7 +31,7 @@ var BaseWorkflowCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
raw, err := loadJSONInput(pc, runtime.Str("json"), "json")
|
||||
|
||||
@@ -27,10 +27,10 @@ var BaseWorkflowDisable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -28,10 +28,10 @@ var BaseWorkflowEnable = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -30,10 +30,10 @@ var BaseWorkflowGet = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ var BaseWorkflowList = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -33,10 +33,10 @@ var BaseWorkflowUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("base-token")) == "" {
|
||||
return baseFlagErrorf("--base-token must not be blank")
|
||||
return common.FlagErrorf("--base-token must not be blank")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("workflow-id")) == "" {
|
||||
return baseFlagErrorf("--workflow-id must not be blank")
|
||||
return common.FlagErrorf("--workflow-id must not be blank")
|
||||
}
|
||||
pc := newParseCtx(runtime)
|
||||
if _, err := parseJSONObject(pc, runtime.Str("json"), "json"); err != nil {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -29,7 +29,7 @@ const (
|
||||
|
||||
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
|
||||
if depth > 10 {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "too many splits for instance_view")
|
||||
return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view")
|
||||
}
|
||||
if startTime > endTime {
|
||||
return nil, nil
|
||||
@@ -48,67 +48,68 @@ func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
|
||||
return append(left, right...), nil
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
result, err := runtime.RawAPI("GET",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)),
|
||||
map[string]interface{}{
|
||||
"start_time": fmt.Sprintf("%d", startTime),
|
||||
"end_time": fmt.Sprintf("%d", endTime),
|
||||
}, nil)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
// CallAPITyped returns a typed error for any non-zero API code. The two
|
||||
// calendar instance_view limits (193103 time-range, 193104 too-many) are
|
||||
// recoverable by narrowing the window, so inspect the typed code and
|
||||
// recurse instead of treating them as fatal. Any other code falls through
|
||||
// to return the typed error unchanged.
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
switch p.Code {
|
||||
case larkErrCalendarTimeRangeExceeded:
|
||||
mid := startTime + span/2
|
||||
if mid <= startTime {
|
||||
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
|
||||
"query failed: time range exceeds 40-day limit, please narrow the range").
|
||||
WithCode(larkErrCalendarTimeRangeExceeded)
|
||||
}
|
||||
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
|
||||
case larkErrCalendarTooManyInstances:
|
||||
if span <= minSplitWindowSeconds {
|
||||
return nil, errs.NewAPIError(errs.SubtypeInvalidParameters,
|
||||
"query failed: more than 1000 instances in the time range, please narrow the range").
|
||||
WithCode(larkErrCalendarTooManyInstances)
|
||||
}
|
||||
mid := startTime + span/2
|
||||
return fetchInstanceViewSplit(ctx, runtime, calendarId, startTime, mid, endTime, depth)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err)
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
var events []map[string]interface{}
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
events = append(events, m)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
|
||||
// fetchInstanceViewSplit halves [startTime, endTime] at mid and concatenates the
|
||||
// results of the two recursive sub-range queries. Shared by the 193103/193104
|
||||
// split paths.
|
||||
func fetchInstanceViewSplit(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, mid, endTime int64, depth int) ([]map[string]interface{}, error) {
|
||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if code == 0 {
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
items, _ := data["items"].([]interface{})
|
||||
var events []map[string]interface{}
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
events = append(events, m)
|
||||
}
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
// Error 193103: time range exceeds limit -> split
|
||||
if int(code) == larkErrCalendarTimeRangeExceeded {
|
||||
mid := startTime + span/2
|
||||
if mid <= startTime {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range")
|
||||
}
|
||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(left, right...), nil
|
||||
}
|
||||
return append(left, right...), nil
|
||||
|
||||
// Error 193104: too many instances -> split
|
||||
if int(code) == larkErrCalendarTooManyInstances {
|
||||
if span <= minSplitWindowSeconds {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range")
|
||||
}
|
||||
mid := startTime + span/2
|
||||
left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(left, right...), nil
|
||||
}
|
||||
|
||||
msg, _ := resultMap["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), msg, resultMap["error"])
|
||||
}
|
||||
|
||||
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
|
||||
@@ -146,20 +147,20 @@ func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) {
|
||||
|
||||
startTime, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
return 0, 0, output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
endTime, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
return 0, 0, output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
|
||||
startInt, err := strconv.ParseInt(startTime, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
return 0, 0, output.ErrValidation("invalid start time: %v", err)
|
||||
}
|
||||
endInt, err := strconv.ParseInt(endTime, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
return 0, 0, output.ErrValidation("invalid end time: %v", err)
|
||||
}
|
||||
|
||||
return startInt, endInt, nil
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -61,7 +60,7 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
attendees = append(attendees, map[string]string{"type": "user", "user_id": id})
|
||||
default:
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported attendee id format: %s", id)
|
||||
return nil, fmt.Errorf("unsupported attendee id format: %s", id)
|
||||
}
|
||||
}
|
||||
return attendees, nil
|
||||
@@ -90,8 +89,8 @@ var CalendarCreate = common.Shortcut{
|
||||
}
|
||||
for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} {
|
||||
if val := runtime.Str(flag); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,35 +102,35 @@ var CalendarCreate = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--attendee-ids")
|
||||
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.Str("start") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --start (e.g. '2026-03-12T14:00+08:00')").WithParam("--start")
|
||||
return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')")
|
||||
}
|
||||
if runtime.Str("end") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --end (e.g. '2026-03-12T15:00+08:00')").WithParam("--end")
|
||||
return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')")
|
||||
}
|
||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
return common.FlagErrorf("--start: %v", err)
|
||||
}
|
||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
return common.FlagErrorf("--end: %v", err)
|
||||
}
|
||||
s, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
return common.FlagErrorf("invalid start time: %v", err)
|
||||
}
|
||||
e, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
return common.FlagErrorf("invalid end time: %v", err)
|
||||
}
|
||||
if e <= s {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
|
||||
return common.FlagErrorf("end time must be after start time")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -184,26 +183,27 @@ var CalendarCreate = common.Shortcut{
|
||||
|
||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
return output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
return output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
|
||||
// Create event
|
||||
data, err := runtime.CallAPITyped("POST",
|
||||
data, err := runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)),
|
||||
nil, eventData)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event, _ := data["event"].(map[string]interface{})
|
||||
eventId, _ := event["event_id"].(string)
|
||||
if eventId == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create event: no event_id returned")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned")
|
||||
}
|
||||
|
||||
// Add attendees if specified
|
||||
@@ -214,25 +214,27 @@ var CalendarCreate = common.Shortcut{
|
||||
}
|
||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||
if err != nil {
|
||||
return withParam(err, "--attendee-ids")
|
||||
return output.ErrValidation("invalid attendee id: %v", err)
|
||||
}
|
||||
|
||||
_, err = runtime.CallAPITyped("POST",
|
||||
_, err = runtime.CallAPI("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
map[string]interface{}{"user_id_type": "open_id"},
|
||||
map[string]interface{}{
|
||||
"attendees": attendees,
|
||||
"need_notification": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
// Rollback: delete the event
|
||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||
_, rollbackErr := runtime.RawAPI("DELETE",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||
map[string]interface{}{"need_notification": false}, nil)
|
||||
rollbackErr = wrapPredefinedError(rollbackErr)
|
||||
if rollbackErr != nil {
|
||||
return withStepContext(err, "rollback also failed (%v); orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId)
|
||||
}
|
||||
return withStepContext(err, "event rolled back successfully")
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -21,20 +20,20 @@ func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, err
|
||||
|
||||
startTs, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
return "", "", output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
endTs, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
return "", "", output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
|
||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
|
||||
}
|
||||
|
||||
timeMin := time.Unix(startSec, 0).Format(time.RFC3339)
|
||||
@@ -74,13 +73,13 @@ var CalendarFreebusy = common.Shortcut{
|
||||
}
|
||||
userId := runtime.Str("user-id")
|
||||
if userId == "" && runtime.IsBot() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required for bot identity").WithParam("--user-id")
|
||||
return common.FlagErrorf("--user-id is required for bot identity")
|
||||
}
|
||||
if userId == "" && runtime.UserOpenId() == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot determine user ID, specify --user-id or ensure you are logged in").WithParam("--user-id")
|
||||
return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in")
|
||||
}
|
||||
if userId != "" {
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userId); err != nil {
|
||||
if _, err := common.ValidateUserID(userId); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -94,17 +93,16 @@ var CalendarFreebusy = common.Shortcut{
|
||||
|
||||
timeMin, timeMax, err := parseFreebusyTimeRange(runtime)
|
||||
if err != nil {
|
||||
// parseFreebusyTimeRange already returns a typed *errs.ValidationError
|
||||
// carrying the offending flag in .Param; pass it through unchanged.
|
||||
return err
|
||||
return output.ErrValidation("--start/--end: %v", err)
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{
|
||||
"time_min": timeMin,
|
||||
"time_max": timeMax,
|
||||
"user_id": userId,
|
||||
"need_rsvp_status": true,
|
||||
})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -126,40 +126,40 @@ func collectRoomFindResults(slots []roomFindSlot, limit int, fetch func(roomFind
|
||||
func parseRoomFindSlots(runtime *common.RuntimeContext) ([]roomFindSlot, error) {
|
||||
rawSlots := runtime.StrArray(flagSlot)
|
||||
if len(rawSlots) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one --slot").WithParam("--slot")
|
||||
return nil, output.ErrValidation("specify at least one --slot")
|
||||
}
|
||||
slots := make([]roomFindSlot, 0, len(rawSlots))
|
||||
for _, raw := range rawSlots {
|
||||
parts := strings.Split(strings.TrimSpace(raw), "~")
|
||||
if len(parts) != 2 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --slot format %q, expected start~end", raw).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid --slot format %q, expected start~end", raw)
|
||||
}
|
||||
startTs, err := common.ParseTime(parts[0])
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start time %q: %v", parts[0], err).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid slot start time %q: %v", parts[0], err)
|
||||
}
|
||||
endTs, err := common.ParseTime(parts[1])
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end time %q: %v", parts[1], err).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid slot end time %q: %v", parts[1], err)
|
||||
}
|
||||
startSec, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
}
|
||||
if endSec <= startSec {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--slot end time must be after start time: %q", raw).WithParam("--slot")
|
||||
return nil, output.ErrValidation("--slot end time must be after start time: %q", raw)
|
||||
}
|
||||
startRFC3339, err := unixStringToRFC3339(startTs)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot start timestamp %q: %v", startTs, err).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid slot start timestamp %q: %v", startTs, err)
|
||||
}
|
||||
endRFC3339, err := unixStringToRFC3339(endTs)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid slot end timestamp %q: %v", endTs, err).WithParam("--slot")
|
||||
return nil, output.ErrValidation("invalid slot end timestamp %q: %v", endTs, err)
|
||||
}
|
||||
slots = append(slots, roomFindSlot{Start: startRFC3339, End: endRFC3339})
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func parseRoomFindAttendees(attendeesStr string, currentUserID string) ([]string
|
||||
seenChats[id] = true
|
||||
}
|
||||
default:
|
||||
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
|
||||
return nil, nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
||||
}
|
||||
}
|
||||
if currentUserID != "" && !seenUsers[currentUserID] {
|
||||
@@ -249,19 +249,20 @@ func callRoomFind(runtime *common.RuntimeContext, req *roomFindRequest) ([]*room
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.WrapInternal(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return nil, err
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
var resp = &OpenAPIResponse[*roomFindData]{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
|
||||
return nil, output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
||||
}
|
||||
|
||||
if resp.Code != 0 {
|
||||
return nil, output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
||||
}
|
||||
|
||||
if resp.Data != nil {
|
||||
@@ -316,8 +317,8 @@ var CalendarRoomFind = common.Shortcut{
|
||||
}
|
||||
for _, flag := range []string{flagCity, flagBuilding, flagFloor, flagEventRrule, flagTimezone} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,8 +327,8 @@ var CalendarRoomFind = common.Shortcut{
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--"+flagRoomName, name); err != nil {
|
||||
return err
|
||||
if err := common.RejectDangerousChars("--"+flagRoomName, name); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
if _, err := parseRoomFindSlots(runtime); err != nil {
|
||||
@@ -337,13 +338,13 @@ var CalendarRoomFind = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
if minCapacity := runtime.Int(flagMinCapacity); minCapacity < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be >= 0").WithParam("--min-capacity")
|
||||
return output.ErrValidation("--min-capacity must be >= 0")
|
||||
}
|
||||
if maxCapacity := runtime.Int(flagMaxCapacity); maxCapacity < 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-capacity must be >= 0").WithParam("--max-capacity")
|
||||
return output.ErrValidation("--max-capacity must be >= 0")
|
||||
}
|
||||
if minCapacity, maxCapacity := runtime.Int(flagMinCapacity), runtime.Int(flagMaxCapacity); minCapacity > 0 && maxCapacity > 0 && minCapacity > maxCapacity {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--min-capacity must be <= --max-capacity").WithParam("--min-capacity")
|
||||
return output.ErrValidation("--min-capacity must be <= --max-capacity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -51,15 +51,15 @@ var CalendarRsvp = common.Shortcut{
|
||||
}
|
||||
for _, flag := range []string{"calendar-id", "event-id", "rsvp-status"} {
|
||||
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
if eventId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||
return output.ErrValidation("event-id cannot be empty")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -71,7 +71,7 @@ var CalendarRsvp = common.Shortcut{
|
||||
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||
status := strings.TrimSpace(runtime.Str("rsvp-status"))
|
||||
|
||||
_, err := runtime.CallAPITyped("POST",
|
||||
_, err := runtime.DoAPIJSON("POST",
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/reply",
|
||||
validate.EncodePathSegment(calendarId),
|
||||
validate.EncodePathSegment(eventId)),
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -70,11 +70,11 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
|
||||
timeMin, err := common.ParseTime(startInput)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --start: %v", err).WithParam("--start")
|
||||
return nil, output.ErrValidation("invalid --start: %v", err)
|
||||
}
|
||||
minSec, err := strconv.ParseInt(timeMin, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
return nil, output.ErrValidation("invalid start timestamp: %v", err)
|
||||
}
|
||||
startTime := time.Unix(minSec, 0)
|
||||
|
||||
@@ -87,12 +87,12 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
|
||||
timeMax, err := common.ParseTime(endInput, "end")
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --end: %v", err).WithParam("--end")
|
||||
return nil, output.ErrValidation("invalid --end: %v", err)
|
||||
}
|
||||
// Convert Unix timestamp string back to RFC3339 since the API requires RFC3339
|
||||
maxSec, err := strconv.ParseInt(timeMax, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
return nil, output.ErrValidation("invalid end timestamp: %v", err)
|
||||
}
|
||||
req.SearchStartTime = startTime.Format(time.RFC3339)
|
||||
req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339)
|
||||
@@ -157,23 +157,23 @@ func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest,
|
||||
}
|
||||
parts := strings.Split(r, "~")
|
||||
if len(parts) != 2 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --exclude format %q, expected 'start~end'", r).WithParam("--exclude")
|
||||
return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r)
|
||||
}
|
||||
startTsStr, err := common.ParseTime(parts[0])
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
|
||||
return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
|
||||
}
|
||||
endTsStr, err := common.ParseTime(parts[1], "end")
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
|
||||
return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
|
||||
}
|
||||
startSec, err := strconv.ParseInt(startTsStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp in --exclude: %v", err).WithParam("--exclude")
|
||||
return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err)
|
||||
}
|
||||
endSec, err := strconv.ParseInt(endTsStr, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp in --exclude: %v", err).WithParam("--exclude")
|
||||
return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err)
|
||||
}
|
||||
excludedTimes = append(excludedTimes, &EventTime{
|
||||
EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339),
|
||||
@@ -219,13 +219,13 @@ var CalendarSuggestion = common.Shortcut{
|
||||
}
|
||||
durationMinutes := runtime.Int(flagDurationMinutes)
|
||||
if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--duration-minutes must be between 1 and 1440").WithParam("--duration-minutes")
|
||||
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
|
||||
}
|
||||
|
||||
for _, flag := range []string{flagEventRrule, flagTimezone} {
|
||||
if val := runtime.Str(flag); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,7 @@ var CalendarSuggestion = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_' or 'oc_'", id).WithParam("--" + flagAttendees)
|
||||
return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,14 +245,14 @@ var CalendarSuggestion = common.Shortcut{
|
||||
startInput := runtime.Str(flagStart)
|
||||
if startInput != "" {
|
||||
if _, err := common.ParseTime(startInput); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
return output.ErrValidation("invalid start time: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
endInput := runtime.Str(flagEnd)
|
||||
if endInput != "" {
|
||||
if _, err := common.ParseTime(endInput, "end"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
return output.ErrValidation("invalid end time: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,13 +267,13 @@ var CalendarSuggestion = common.Shortcut{
|
||||
}
|
||||
parts := strings.Split(r, "~")
|
||||
if len(parts) != 2 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid range format in --exclude: %q, expect start~end", r).WithParam("--exclude")
|
||||
return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r)
|
||||
}
|
||||
if _, err := common.ParseTime(parts[0]); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time in --exclude: %q (%v)", parts[0], err).WithParam("--exclude")
|
||||
return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err)
|
||||
}
|
||||
if _, err := common.ParseTime(parts[1], "end"); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time in --exclude: %q (%v)", parts[1], err).WithParam("--exclude")
|
||||
return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,19 +292,20 @@ var CalendarSuggestion = common.Shortcut{
|
||||
Body: req,
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.WrapInternal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
||||
return err
|
||||
if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody))
|
||||
}
|
||||
|
||||
var resp = &OpenAPIResponse[*SuggestionResponse]{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "unmarshal response fail").WithCause(err)
|
||||
return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error())
|
||||
}
|
||||
|
||||
if resp.Code != 0 {
|
||||
return output.ErrAPI(resp.Code, resp.Msg, resp.Data)
|
||||
}
|
||||
|
||||
data := resp.Data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -54,14 +53,14 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
for _, flag := range []string{"event-id", "summary", "description", "rrule", "calendar-id", "start", "end", "add-attendee-ids", "remove-attendee-ids"} {
|
||||
if val := runtime.Str(flag); val != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||
return err
|
||||
if err := common.RejectDangerousChars("--"+flag, val); err != nil {
|
||||
return output.ErrValidation(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(runtime.Str("event-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
return common.FlagErrorf("specify --event-id")
|
||||
}
|
||||
if _, _, err := buildCalendarUpdateEventData(runtime); err != nil {
|
||||
return err
|
||||
@@ -70,7 +69,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if !hasCalendarUpdateOperation(runtime) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
return common.FlagErrorf("nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -78,11 +77,11 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
||||
func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
|
||||
addIDs, err := parseCalendarAttendeeIDs(runtime.Str("add-attendee-ids"))
|
||||
if err != nil {
|
||||
return withParam(err, "--add-attendee-ids")
|
||||
return err
|
||||
}
|
||||
removeIDs, err := parseCalendarAttendeeIDs(runtime.Str("remove-attendee-ids"))
|
||||
if err != nil {
|
||||
return withParam(err, "--remove-attendee-ids")
|
||||
return err
|
||||
}
|
||||
removeSet := make(map[string]struct{}, len(removeIDs))
|
||||
for _, id := range removeIDs {
|
||||
@@ -90,7 +89,7 @@ func validateCalendarUpdateAttendees(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
for _, id := range addIDs {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
|
||||
return output.ErrValidation("attendee id %q appears in both --add-attendee-ids and --remove-attendee-ids", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -125,27 +124,27 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
startChanged := runtime.Cmd.Flags().Changed("start")
|
||||
endChanged := runtime.Cmd.Flags().Changed("end")
|
||||
if startChanged != endChanged {
|
||||
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start and --end must be specified together when updating event time")
|
||||
return nil, false, common.FlagErrorf("--start and --end must be specified together when updating event time")
|
||||
}
|
||||
if startChanged {
|
||||
startTs, err := common.ParseTime(runtime.Str("start"))
|
||||
if err != nil {
|
||||
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
return nil, false, common.FlagErrorf("--start: %v", err)
|
||||
}
|
||||
endTs, err := common.ParseTime(runtime.Str("end"), "end")
|
||||
if err != nil {
|
||||
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
return nil, false, common.FlagErrorf("--end: %v", err)
|
||||
}
|
||||
s, err := strconv.ParseInt(startTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start time: %v", err).WithParam("--start")
|
||||
return nil, false, common.FlagErrorf("invalid start time: %v", err)
|
||||
}
|
||||
e, err := strconv.ParseInt(endTs, 10, 64)
|
||||
if err != nil {
|
||||
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end time: %v", err).WithParam("--end")
|
||||
return nil, false, common.FlagErrorf("invalid end time: %v", err)
|
||||
}
|
||||
if e <= s {
|
||||
return nil, false, errs.NewValidationError(errs.SubtypeInvalidArgument, "end time must be after start time")
|
||||
return nil, false, common.FlagErrorf("end time must be after start time")
|
||||
}
|
||||
body["start_time"] = map[string]string{"timestamp": startTs}
|
||||
body["end_time"] = map[string]string{"timestamp": endTs}
|
||||
@@ -170,7 +169,7 @@ func parseCalendarAttendeeIDs(attendeesStr string) ([]string, error) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
||||
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
@@ -196,7 +195,7 @@ func attendeeDeleteIDs(attendeesStr string) ([]map[string]string, error) {
|
||||
case strings.HasPrefix(id, "ou_"):
|
||||
deleteIDs = append(deleteIDs, map[string]string{"type": "user", "user_id": id})
|
||||
default:
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id).WithParam("--remove-attendee-ids")
|
||||
return nil, output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id)
|
||||
}
|
||||
}
|
||||
return deleteIDs, nil
|
||||
@@ -281,7 +280,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
calendarID, eventID := calendarUpdateIDs(runtime)
|
||||
if eventID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
return output.ErrValidation("specify --event-id")
|
||||
}
|
||||
|
||||
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
||||
@@ -292,9 +291,10 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
||||
completed := []string{}
|
||||
event := map[string]interface{}{}
|
||||
if hasEventFields {
|
||||
data, err := runtime.CallAPITyped("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
|
||||
data, err := runtime.CallAPI("PATCH", calendarUpdateEventPath(calendarID, eventID), map[string]interface{}{"user_id_type": "open_id"}, body)
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return withStepContext(err, "failed to update event %s after completed steps %v", eventID, completed)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to update event %s: %v", eventID, err)
|
||||
}
|
||||
if v, _ := data["event"].(map[string]interface{}); v != nil {
|
||||
event = v
|
||||
@@ -308,11 +308,12 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
|
||||
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
|
||||
map[string]interface{}{"user_id_type": "open_id"},
|
||||
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return withStepContext(err, "failed to remove attendees from event %s after completed steps %v", eventID, completed)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to remove attendees from event %s after completed steps %v: %v", eventID, completed, err)
|
||||
}
|
||||
removedCount = len(deleteIDs)
|
||||
completed = append(completed, "remove_attendees")
|
||||
@@ -322,13 +323,14 @@ func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) er
|
||||
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
|
||||
attendees, err := parseAttendees(addStr, "")
|
||||
if err != nil {
|
||||
return withParam(err, "--add-attendee-ids")
|
||||
return output.ErrValidation("invalid attendee id: %v", err)
|
||||
}
|
||||
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID),
|
||||
_, err = runtime.CallAPI("POST", calendarUpdateAttendeesPath(calendarID, eventID),
|
||||
map[string]interface{}{"user_id_type": "open_id"},
|
||||
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
|
||||
err = wrapPredefinedError(err)
|
||||
if err != nil {
|
||||
return withStepContext(err, "failed to add attendees to event %s after completed steps %v", eventID, completed)
|
||||
return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees to event %s after completed steps %v: %v", eventID, completed, err)
|
||||
}
|
||||
addedCount = len(attendees)
|
||||
}
|
||||
|
||||
@@ -6,39 +6,68 @@ package calendar
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// withStepContext annotates err with multi-step context (e.g. which steps
|
||||
// already completed, or that a rollback ran) while preserving the underlying
|
||||
// failure's classification. An already-typed error keeps its own
|
||||
// category/subtype/code/log_id; we only append the formatted context to its
|
||||
// Hint so the top-level envelope still tells the truth about what failed.
|
||||
// Only an unclassified error falls back to a typed internal wrap.
|
||||
func withStepContext(err error, format string, args ...any) error {
|
||||
const (
|
||||
errCodeInvalidParamsWithDetail = 190014
|
||||
)
|
||||
|
||||
// getErrorDetailValue extracts the first detail value from the output.ErrDetail.
|
||||
// It assumes Detail is a map containing a "details" array of objects with "value" string fields.
|
||||
// For example: {"details": [{"value": "error message 1"}, {"value": "error message 2"}]}
|
||||
// Returns an empty string if the structure doesn't match or the array is empty.
|
||||
//
|
||||
// Deprecated: getErrorDetailValue reads from the legacy *output.ErrDetail
|
||||
// that predates the typed error contract introduced by errs/. New code MUST
|
||||
// NOT use it — typed errs.* errors expose Message, Hint, and extension
|
||||
// fields directly on the typed struct via errors.As / errs.ProblemOf. This
|
||||
// helper is retained only while existing call sites are migrated; it will
|
||||
// be removed once they have moved to the typed surface.
|
||||
func getErrorDetailValue(e *output.ErrDetail) string {
|
||||
if e == nil || e.Detail == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
errMap, ok := e.Detail.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
details, ok := errMap["details"].([]interface{})
|
||||
if !ok || len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
detailObj, ok := details[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
val, _ := detailObj["value"].(string)
|
||||
return val
|
||||
}
|
||||
|
||||
// wrapPredefinedError wraps an error into *output.ExitError if it matches predefined error codes.
|
||||
// Currently handles error code 190014 (invalid params with detail), extracting the detail value into the message.
|
||||
// If the error is nil or doesn't match predefined codes, returns the original error.
|
||||
func wrapPredefinedError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
extra := fmt.Sprintf(format, args...)
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + extra
|
||||
} else {
|
||||
p.Hint = extra
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(extra).WithCause(err)
|
||||
}
|
||||
|
||||
// withParam attaches the offending flag to a typed validation error, preserving
|
||||
// the original error instead of re-wrapping it. Non-validation errors pass through.
|
||||
func withParam(err error, flag string) error {
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
return ve.WithParam(flag)
|
||||
if exitErr.Detail.Code == errCodeInvalidParamsWithDetail {
|
||||
if val := getErrorDetailValue(exitErr.Detail); val != "" {
|
||||
fullMsg := fmt.Sprintf("%s: %s", exitErr.Detail.Message, val)
|
||||
return output.ErrAPI(exitErr.Detail.Code, fullMsg, exitErr.Detail.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newAttendeeValidateRuntime builds a RuntimeContext with the add/remove
|
||||
// attendee-id flags set, for exercising validateCalendarUpdateAttendees.
|
||||
func newAttendeeValidateRuntime(t *testing.T, add, remove string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("add-attendee-ids", "", "")
|
||||
cmd.Flags().String("remove-attendee-ids", "", "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags: %v", err)
|
||||
}
|
||||
if add != "" {
|
||||
_ = cmd.Flags().Set("add-attendee-ids", add)
|
||||
}
|
||||
if remove != "" {
|
||||
_ = cmd.Flags().Set("remove-attendee-ids", remove)
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// assertValidationParam asserts err is a *errs.ValidationError whose Param
|
||||
// equals wantParam, and returns it for any further message assertions.
|
||||
func assertValidationParam(t *testing.T, err error, wantParam string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withStepContext helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWithStepContext_Nil(t *testing.T) {
|
||||
if got := withStepContext(nil, "step %d", 1); got != nil {
|
||||
t.Fatalf("withStepContext(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStepContext_AppendsToTypedHint(t *testing.T) {
|
||||
// A typed error keeps its classification; the context is appended to Hint.
|
||||
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
|
||||
got := withStepContext(inner, "after steps %v", []string{"event"})
|
||||
var ae *errs.APIError
|
||||
if !errors.As(got, &ae) {
|
||||
t.Fatalf("want *errs.APIError, got %T", got)
|
||||
}
|
||||
if ae.Hint == "" || !strings.Contains(ae.Hint, "first") || !strings.Contains(ae.Hint, "after steps") {
|
||||
t.Errorf("hint should append context, got %q", ae.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStepContext_SetsHintWhenEmpty(t *testing.T) {
|
||||
inner := errs.NewAPIError(errs.SubtypeUnknown, "boom")
|
||||
got := withStepContext(inner, "after steps %v", []string{"event"})
|
||||
var ae *errs.APIError
|
||||
if !errors.As(got, &ae) {
|
||||
t.Fatalf("want *errs.APIError, got %T", got)
|
||||
}
|
||||
if !strings.Contains(ae.Hint, "after steps") {
|
||||
t.Errorf("hint should be set, got %q", ae.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStepContext_UnclassifiedFallsBackToInternal(t *testing.T) {
|
||||
// A plain, unclassified error is wrapped into a typed internal error so the
|
||||
// envelope still tells the truth.
|
||||
got := withStepContext(errors.New("raw failure"), "after steps %v", []string{"event"})
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("want *errs.InternalError, got %T", got)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("subtype=%q, want sdk_error", ie.Subtype)
|
||||
}
|
||||
if !strings.Contains(ie.Message, "raw failure") {
|
||||
t.Errorf("message should preserve original, got %q", ie.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// withParam helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWithParam_AttachesToValidationError(t *testing.T) {
|
||||
inner := errs.NewValidationError(errs.SubtypeInvalidArgument, "boom")
|
||||
got := withParam(inner, "--attendee-ids")
|
||||
ve := assertValidationParam(t, got, "--attendee-ids")
|
||||
if ve != inner {
|
||||
t.Errorf("withParam should return the same underlying error, got a different pointer")
|
||||
}
|
||||
if ve.Message != "boom" {
|
||||
t.Errorf("message mutated: got %q, want %q", ve.Message, "boom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithParam_NonValidationPassesThrough(t *testing.T) {
|
||||
inner := errs.NewInternalError(errs.SubtypeSDKError, "io failure")
|
||||
got := withParam(inner, "--attendee-ids")
|
||||
if got != inner {
|
||||
t.Fatalf("non-validation error should pass through unchanged, got %v", got)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(got, &ve) {
|
||||
t.Fatalf("non-validation error must not become a ValidationError")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithParam_NilPassesThrough(t *testing.T) {
|
||||
if got := withParam(nil, "--attendee-ids"); got != nil {
|
||||
t.Fatalf("withParam(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part A — re-wrap sites: the parseAttendees error, attributed by the caller's
|
||||
// flag, must be the inner typed error (not a re-wrapped nesting).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseAttendees_AttributedToCreateFlag(t *testing.T) {
|
||||
_, err := parseAttendees("bad-id", "")
|
||||
// create's add path: withParam(err, "--attendee-ids")
|
||||
got := withParam(err, "--attendee-ids")
|
||||
assertValidationParam(t, got, "--attendee-ids")
|
||||
}
|
||||
|
||||
func TestParseAttendees_AttributedToAddFlag(t *testing.T) {
|
||||
_, err := parseAttendees("bad-id", "")
|
||||
// update's add path: withParam(err, "--add-attendee-ids")
|
||||
got := withParam(err, "--add-attendee-ids")
|
||||
assertValidationParam(t, got, "--add-attendee-ids")
|
||||
}
|
||||
|
||||
func TestParseAttendees_InnerStaysFlagAgnostic(t *testing.T) {
|
||||
// The shared inner parser must not pre-attribute a flag; callers do.
|
||||
_, err := parseAttendees("bad-id", "")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "" {
|
||||
t.Errorf("inner parseAttendees should stay flag-agnostic, got Param = %q", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part B — direct attendee-id format validations carry their flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseRoomFindAttendees_FormatErrorParam(t *testing.T) {
|
||||
_, _, err := parseRoomFindAttendees("bad-id", "")
|
||||
assertValidationParam(t, err, "--"+flagAttendees)
|
||||
}
|
||||
|
||||
func TestParseRoomFindAttendees_RejectsRoomID(t *testing.T) {
|
||||
// room find only supports ou_/oc_; omm_ rooms are not valid attendees.
|
||||
_, _, err := parseRoomFindAttendees("omm_room", "")
|
||||
assertValidationParam(t, err, "--"+flagAttendees)
|
||||
}
|
||||
|
||||
func TestParseCalendarAttendeeIDs_StaysFlagAgnostic(t *testing.T) {
|
||||
// parseCalendarAttendeeIDs serves BOTH --add-attendee-ids and
|
||||
// --remove-attendee-ids, so it must not pre-attribute a flag.
|
||||
_, err := parseCalendarAttendeeIDs("bad-id")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if ve.Param != "" {
|
||||
t.Errorf("shared parser should stay flag-agnostic, got Param = %q", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCalendarUpdateAttendees_RemoveFormatParam(t *testing.T) {
|
||||
// The remove path attributes its parser error to --remove-attendee-ids.
|
||||
rt := newAttendeeValidateRuntime(t, "", "bad-id")
|
||||
err := validateCalendarUpdateAttendees(rt)
|
||||
assertValidationParam(t, err, "--remove-attendee-ids")
|
||||
}
|
||||
|
||||
func TestValidateCalendarUpdateAttendees_AddFormatParam(t *testing.T) {
|
||||
// The add path attributes its parser error to --add-attendee-ids.
|
||||
rt := newAttendeeValidateRuntime(t, "bad-id", "")
|
||||
err := validateCalendarUpdateAttendees(rt)
|
||||
assertValidationParam(t, err, "--add-attendee-ids")
|
||||
}
|
||||
|
||||
// attendeeDeleteIDs's switch default is defensive: parseCalendarAttendeeIDs
|
||||
// already rejects any non-ou_/oc_/omm_ id, so only a well-formed id reaches the
|
||||
// switch and the valid branches map it. This asserts the happy path maps types.
|
||||
func TestAttendeeDeleteIDs_MapsKnownTypes(t *testing.T) {
|
||||
got, err := attendeeDeleteIDs("ou_a,oc_b,omm_c")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 delete ids, got %d: %v", len(got), got)
|
||||
}
|
||||
wantTypes := map[string]string{"user": "user_id", "chat": "chat_id", "resource": "room_id"}
|
||||
for _, m := range got {
|
||||
key, ok := wantTypes[m["type"]]
|
||||
if !ok {
|
||||
t.Errorf("unexpected type %q in %v", m["type"], m)
|
||||
continue
|
||||
}
|
||||
if m[key] == "" {
|
||||
t.Errorf("missing %s for type %q in %v", key, m["type"], m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCalendarAttendeeIDs_Valid(t *testing.T) {
|
||||
ids, err := parseCalendarAttendeeIDs(" ou_a , oc_b , ou_a ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ids) != 2 || ids[0] != "ou_a" || ids[1] != "oc_b" {
|
||||
t.Errorf("dedup/trim failed: got %v", ids)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -199,58 +198,3 @@ func TestCallAPITyped_NonObjectJSON(t *testing.T) {
|
||||
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoAPIJSONTyped_Success returns the data object on code 0, confirming the
|
||||
// typed DoAPIJSON replacement preserves the success contract of DoAPIJSON.
|
||||
func TestDoAPIJSONTyped_Success(t *testing.T) {
|
||||
rt, reg := newCallAPITypedRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/x/z",
|
||||
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"id": "z1"}},
|
||||
})
|
||||
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data["id"] != "z1" {
|
||||
t.Errorf("data[id] = %v, want z1", data["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoAPIJSONTyped_RawClientErrorBecomesTypedInternal(t *testing.T) {
|
||||
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, &core.CliConfig{}, nil, core.AsUser)
|
||||
rt.apiClientFunc = func() (*client.APIClient, error) {
|
||||
return nil, errors.New("raw client construction error")
|
||||
}
|
||||
|
||||
_, err := rt.DoAPIJSONTyped("GET", "/open-apis/x/z", nil, nil)
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected raw client errors to be lifted to typed internal errors, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoAPIJSONTyped_NonZeroCode classifies a non-zero API code into a typed
|
||||
// errs.* error (carrying log_id), never a legacy output.ExitError envelope.
|
||||
func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
|
||||
rt, reg := newCallAPITypedRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/x/z",
|
||||
Body: map[string]interface{}{"code": float64(1061044), "msg": "boom", "log_id": "lz"},
|
||||
})
|
||||
|
||||
_, err := rt.DoAPIJSONTyped("POST", "/open-apis/x/z", nil, map[string]any{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.LogID != "lz" {
|
||||
t.Errorf("LogID = %q, want lz", p.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,28 +492,6 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark
|
||||
return ctx.doAPIJSON(method, apiPath, query, body, true)
|
||||
}
|
||||
|
||||
// DoAPIJSONTyped is the typed-only replacement for DoAPIJSON: it issues the same
|
||||
// larkcore.ApiReq request (identical method / path / query / body model) but
|
||||
// classifies failures into typed errs.* errors via ClassifyAPIResponse instead
|
||||
// of emitting a legacy output.ExitError "api_error" envelope. A transport / auth
|
||||
// error from the client boundary is already typed and passes through unchanged;
|
||||
// a non-zero API code is classified with subtype / code / log_id.
|
||||
func (ctx *RuntimeContext) DoAPIJSONTyped(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
QueryParams: query,
|
||||
}
|
||||
if body != nil {
|
||||
req.Body = body
|
||||
}
|
||||
resp, err := ctx.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, typedOrInternal(err)
|
||||
}
|
||||
return ctx.ClassifyAPIResponse(resp)
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
@@ -625,6 +603,27 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// WrapSaveError matches a FileIO.Save error against known categories and wraps
|
||||
// it with the caller-provided message prefix, preserving backward-compatible
|
||||
// error text per shortcut.
|
||||
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return fmt.Errorf("%s: %w", pathMsg, err)
|
||||
case errors.As(err, &me):
|
||||
return fmt.Errorf("%s: %w", mkdirMsg, err)
|
||||
case errors.As(err, &we):
|
||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
||||
default:
|
||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
||||
// caller-provided message prefix.
|
||||
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
@@ -704,9 +703,6 @@ func WrapSaveErrorTyped(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -176,6 +177,25 @@ func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousChars returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
//
|
||||
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
|
||||
func RejectDangerousChars(paramName, value string) error {
|
||||
for _, r := range value {
|
||||
if r < 0x20 && r != '\t' && r != '\n' {
|
||||
return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r)
|
||||
}
|
||||
if r == 0x7F {
|
||||
return fmt.Errorf("parameter %q contains DEL character", paramName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectDangerousCharsTyped returns an error if value contains ASCII control
|
||||
// characters or dangerous Unicode code points.
|
||||
func RejectDangerousCharsTyped(paramName, value string) error {
|
||||
|
||||
@@ -9,6 +9,18 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided.
|
||||
//
|
||||
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
|
||||
func ValidateChatID(input string) (string, error) {
|
||||
chatID, msg := normalizeChatID(input)
|
||||
if msg != "" {
|
||||
return "", output.ErrValidation("%s", msg)
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||
// Also extracts token from URL if provided. param names the flag being
|
||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||
|
||||
@@ -194,21 +194,6 @@ func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSaveErrorTyped_PreservesTypedWriteCause(t *testing.T) {
|
||||
typed := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP 500: chunk failed").
|
||||
WithCode(500)
|
||||
err := WrapSaveErrorTyped(&fileio.WriteError{Err: typed})
|
||||
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != 500 {
|
||||
t.Fatalf("problem = category %q subtype %q code %d, want network/%s/500",
|
||||
p.Category, p.Subtype, p.Code, errs.SubtypeNetworkServer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtLeastOne(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
secureLabelReadScope = "docs:secure_label:readonly"
|
||||
secureLabelReadScope = "drive:file.meta.sec_label.read_only"
|
||||
secureLabelUpdateScope = "docs:secure_label:write_only"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,17 +12,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestDriveSecureLabelScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if len(DriveSecureLabelList.Scopes) != 1 || DriveSecureLabelList.Scopes[0] != "docs:secure_label:readonly" {
|
||||
t.Fatalf("list scopes = %v, want docs:secure_label:readonly", DriveSecureLabelList.Scopes)
|
||||
}
|
||||
if len(DriveSecureLabelUpdate.Scopes) != 1 || DriveSecureLabelUpdate.Scopes[0] != "docs:secure_label:write_only" {
|
||||
t.Fatalf("update scopes = %v, want docs:secure_label:write_only", DriveSecureLabelUpdate.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSecureLabelList_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -162,7 +162,7 @@ func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []str
|
||||
}
|
||||
batch := missingIDs[i:end]
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
"/open-apis/contact/v3/users/basic_batch",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]interface{}{"user_ids": batch},
|
||||
@@ -198,7 +198,7 @@ func batchResolveUsers(runtime *common.RuntimeContext, missingIDs []string, name
|
||||
}
|
||||
apiURL := "/open-apis/contact/v3/users/batch?" + strings.Join(parts, "&")
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil)
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, apiURL, nil, nil)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -200,20 +200,20 @@ func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch ma
|
||||
// container via a single API call. Returns a flat list of raw message items
|
||||
// with upper_message_id for tree reconstruction.
|
||||
//
|
||||
// Uses DoAPIJSONTyped so the response envelope's code/msg are checked and surfaced
|
||||
// Uses DoAPIJSON so the response envelope's code/msg are checked and surfaced
|
||||
// — earlier this used the low-level DoAPI and reported every non-zero code
|
||||
// as a generic "empty data" error, hiding the real failure (e.g. a server
|
||||
// "code: 2200 Internal Error" with its log_id would show up as just "empty
|
||||
// data" in the output).
|
||||
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
|
||||
"user_id_type": []string{"open_id"},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// DoAPIJSONTyped returns the envelope's `data` field; when the server's JSON
|
||||
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
|
||||
// has `code: 0` but omits `data` entirely, that field comes back as nil.
|
||||
// Reading from a nil map in Go is safe (returns the zero value, never
|
||||
// panics), but guarding explicitly makes the "successful empty
|
||||
|
||||
@@ -156,7 +156,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
|
||||
queries = append(queries, map[string]interface{}{"message_id": id})
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
"/open-apis/im/v1/messages/reactions/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{"queries": queries},
|
||||
|
||||
@@ -243,7 +243,7 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i
|
||||
// Returns the raw message items, whether more replies exist beyond the limit,
|
||||
// and a non-nil error when the API call fails.
|
||||
func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit int) ([]map[string]interface{}, bool, error) {
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{
|
||||
"container_id_type": []string{"thread"},
|
||||
"container_id": []string{threadID},
|
||||
"sort_type": []string{"ByCreateTimeAsc"},
|
||||
@@ -251,7 +251,7 @@ func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit i
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err) //nolint:forbidigo // best-effort internal thread fetch; never surfaced as a final shortcut error (ExpandThreadReplies is void)
|
||||
return nil, false, fmt.Errorf("fetch thread replies for %s: %w", threadID, err)
|
||||
}
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
rawItems, _ := data["items"].([]interface{})
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -37,11 +37,11 @@ var messageIDRe = regexp.MustCompile(`^om_`)
|
||||
func flagMessageID(rt *common.RuntimeContext) (string, error) {
|
||||
id := strings.TrimSpace(rt.Str("message-id"))
|
||||
if id == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required").WithParam("--message-id")
|
||||
return "", output.ErrValidation("--message-id is required")
|
||||
}
|
||||
if strings.HasPrefix(id, "omt_") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id).WithParam("--message-id")
|
||||
return "", output.ErrValidation(
|
||||
"invalid message ID %q: omt_ prefix is a thread ID, not a message ID; flag operations require om_ message IDs", id)
|
||||
}
|
||||
return validateMessageID(id)
|
||||
}
|
||||
@@ -65,10 +65,10 @@ func buildMGetURL(ids []string) string {
|
||||
func validateMessageID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id")
|
||||
return "", output.ErrValidation("message ID cannot be empty")
|
||||
}
|
||||
if !strings.HasPrefix(input, "om_") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id")
|
||||
return "", output.ErrValidation("invalid message ID %q: must start with om_", input)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -173,16 +173,14 @@ func sanitizeURLForDisplay(rawURL string) string {
|
||||
// startURLDownload performs URL validation, creates an HTTP client, and sends a
|
||||
// GET request. It returns the response (with Body still open) and the file
|
||||
// extension inferred from the URL. The caller must close resp.Body.
|
||||
func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL, param string) (*http.Response, string, error) {
|
||||
func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL string) (*http.Response, string, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked URL: %v", err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
return nil, "", fmt.Errorf("blocked URL: %w", err)
|
||||
}
|
||||
|
||||
httpClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
return nil, "", fmt.Errorf("http client: %w", err)
|
||||
}
|
||||
httpClient = validate.NewDownloadHTTPClient(httpClient, validate.DownloadHTTPClientOptions{
|
||||
AllowHTTP: true,
|
||||
@@ -190,19 +188,17 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid URL: %v", err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
return nil, "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", wrapIMNetworkErr(err, "download failed")
|
||||
return nil, "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fileNameFromURL(rawURL))
|
||||
@@ -212,8 +208,8 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
||||
// downloadURLToReader returns a size-limited io.ReadCloser for the URL content
|
||||
// and the file extension inferred from the URL. The caller must close the
|
||||
// returned ReadCloser. No temp file is created and the content is not buffered.
|
||||
func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (io.ReadCloser, string, error) {
|
||||
resp, ext, err := startURLDownload(ctx, runtime, rawURL, param) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
|
||||
func downloadURLToReader(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (io.ReadCloser, string, error) {
|
||||
resp, ext, err := startURLDownload(ctx, runtime, rawURL) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -237,7 +233,7 @@ func (l *limitedReadCloser) Read(p []byte) (int, error) {
|
||||
n, err := l.r.Read(p)
|
||||
l.n += int64(n)
|
||||
if l.n > l.max {
|
||||
return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max)) //nolint:forbidigo // io.Reader.Read contract returns a plain error; classified by the download caller
|
||||
return n, fmt.Errorf("download exceeds size limit (max %s)", common.FormatSize(l.max))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -318,7 +314,7 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "downloading %s: %s\n", s.flagName, sanitizeURLForDisplay(s.value))
|
||||
|
||||
if s.kind == mediaKindImage {
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize, s.flagName)
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -328,7 +324,7 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi
|
||||
}
|
||||
|
||||
// File-kind: buffer in memory for possible duration parsing.
|
||||
mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize, s.flagName)
|
||||
mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -345,7 +341,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value))
|
||||
|
||||
if s.kind == mediaKindImage {
|
||||
return uploadImageToIM(ctx, runtime, s.value, "message", s.flagName)
|
||||
return uploadImageToIM(ctx, runtime, s.value, "message")
|
||||
}
|
||||
|
||||
ft := detectIMFileType(s.value)
|
||||
@@ -353,7 +349,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me
|
||||
if s.withDuration {
|
||||
dur = parseMediaDuration(runtime, s.value, ft)
|
||||
}
|
||||
return uploadFileToIM(ctx, runtime, s.value, ft, dur, s.flagName)
|
||||
return uploadFileToIM(ctx, runtime, s.value, ft, dur)
|
||||
}
|
||||
|
||||
// resolveVideoContent handles the video case which needs both a file_key and
|
||||
@@ -374,7 +370,7 @@ func resolveVideoContent(ctx context.Context, runtime *common.RuntimeContext, vi
|
||||
}
|
||||
coverKey, err := resolveOneMedia(ctx, runtime, coverSpec)
|
||||
if err != nil {
|
||||
return "", "", wrapIMNetworkErr(err, "cover image upload failed")
|
||||
return "", "", fmt.Errorf("cover image upload failed: %w", err)
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(map[string]string{"file_key": fKey, "image_key": coverKey})
|
||||
@@ -390,13 +386,13 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
|
||||
jsonBytes, _ := json.Marshal(map[string]string{"text": fallbackText})
|
||||
return "text", string(jsonBytes), nil
|
||||
}
|
||||
return "", "", wrapIMNetworkErr(uploadErr, "%s upload failed", mediaType)
|
||||
return "", "", fmt.Errorf("%s upload failed: %w", mediaType, uploadErr)
|
||||
}
|
||||
|
||||
// resolveP2PChatID resolves user open_id to P2P chat_id.
|
||||
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
|
||||
if runtime.IsBot() {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
|
||||
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
}
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
@@ -409,10 +405,11 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse chat_p2p response: %w", err)
|
||||
}
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
|
||||
chats, _ := data["p2p_chats"].([]interface{})
|
||||
for _, item := range chats {
|
||||
@@ -423,7 +420,7 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
|
||||
}
|
||||
}
|
||||
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user")
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
|
||||
}
|
||||
|
||||
// resolveThreadID normalizes a message ID to its thread ID when possible.
|
||||
@@ -432,7 +429,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
||||
return id, nil
|
||||
}
|
||||
if !messageIDRe.MatchString(id) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid thread ID format: must start with om_ or omt_").WithParam("--thread")
|
||||
return "", output.Errorf(output.ExitValidation, "validation", "invalid thread ID format: must start with om_ or omt_")
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
@@ -442,10 +439,11 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse message response: %w", err)
|
||||
}
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -456,7 +454,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
||||
}
|
||||
}
|
||||
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "thread ID not found for this message")
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "thread ID not found for this message")
|
||||
}
|
||||
|
||||
// parseOggOpusDuration parses the duration in milliseconds from an OGG/Opus
|
||||
@@ -614,8 +612,8 @@ type mediaBuffer struct {
|
||||
}
|
||||
|
||||
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
|
||||
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (*mediaBuffer, error) {
|
||||
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize, param)
|
||||
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (*mediaBuffer, error) {
|
||||
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -623,7 +621,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, wrapIMNetworkErr(err, "download failed")
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
return newMediaBufferFromBytes(data, ext, rawURL), nil
|
||||
}
|
||||
@@ -929,7 +927,7 @@ func resolveMarkdownImageURLs(ctx context.Context, runtime *common.RuntimeContex
|
||||
}
|
||||
imgURL := sub[1]
|
||||
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize, "--markdown")
|
||||
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
|
||||
return ""
|
||||
@@ -1051,14 +1049,14 @@ func detectIMFileType(filePath string) string {
|
||||
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
||||
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
|
||||
|
||||
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType, param string) (string, error) {
|
||||
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) {
|
||||
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())).WithParam(param)
|
||||
return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size()))
|
||||
}
|
||||
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -1075,25 +1073,27 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
imageKey, _ := data["image_key"].(string)
|
||||
if imageKey == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response")
|
||||
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
}
|
||||
return imageKey, nil
|
||||
}
|
||||
|
||||
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration, param string) (string, error) {
|
||||
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) {
|
||||
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())).WithParam(param)
|
||||
return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size()))
|
||||
}
|
||||
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
@@ -1114,13 +1114,15 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileKey, _ := data["file_key"].(string)
|
||||
if fileKey == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response")
|
||||
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
@@ -1140,13 +1142,15 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
imageKey, _ := data["image_key"].(string)
|
||||
if imageKey == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response")
|
||||
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
}
|
||||
return imageKey, nil
|
||||
}
|
||||
@@ -1170,13 +1174,15 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
||||
return "", err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileKey, _ := data["file_key"].(string)
|
||||
if fileKey == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response")
|
||||
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
@@ -1231,9 +1237,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req
|
||||
}
|
||||
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
|
||||
if err != nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "cannot verify required scope(s): %v", err).
|
||||
WithHint("%s", flagScopeLoginHint(required)).
|
||||
WithCause(err)
|
||||
return output.ErrWithHint(output.ExitAuth, "auth",
|
||||
fmt.Sprintf("cannot verify required scope(s): %v", err),
|
||||
flagScopeLoginHint(required))
|
||||
}
|
||||
if result == nil || result.Scopes == "" {
|
||||
fmt.Fprintf(rt.IO().ErrOut,
|
||||
@@ -1242,9 +1248,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req
|
||||
return nil
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", flagScopeLoginHint(missing))
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
flagScopeLoginHint(missing))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1270,11 +1276,11 @@ func parseItemID(id string) (ItemType, FlagType, error) {
|
||||
case strings.HasPrefix(id, "om_"):
|
||||
return ItemTypeDefault, FlagTypeMessage, nil
|
||||
case id == "":
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id cannot be empty").WithParam("--message-id")
|
||||
return 0, 0, output.ErrValidation("--message-id cannot be empty")
|
||||
default:
|
||||
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
return 0, 0, output.ErrValidation(
|
||||
"cannot infer item type from id %q: expected om_ (message) prefix; "+
|
||||
"pass --item-type and --flag-type explicitly if you are using a different id format", id).WithParam("--message-id")
|
||||
"pass --item-type and --flag-type explicitly if you are using a different id format", id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,7 +1294,7 @@ func parseItemType(s string) (ItemType, error) {
|
||||
case "msg_thread":
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --item-type %q: expected one of default|thread|msg_thread", s).WithParam("--item-type")
|
||||
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
|
||||
}
|
||||
|
||||
// parseFlagType converts a user-facing string to the server enum.
|
||||
@@ -1299,7 +1305,7 @@ func parseFlagType(s string) (FlagType, error) {
|
||||
case "feed":
|
||||
return FlagTypeFeed, nil
|
||||
}
|
||||
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --flag-type %q: expected one of message|feed", s).WithParam("--flag-type")
|
||||
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
|
||||
}
|
||||
|
||||
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
|
||||
@@ -1357,24 +1363,24 @@ func newFlagItem(itemID string, it ItemType, ft FlagType) flagItem {
|
||||
// getMessageChatID queries the message API to get the chat_id.
|
||||
// Used by flag-create to determine the chat type for feed-layer flags.
|
||||
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
items, ok := data["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "message not found")
|
||||
return "", output.ErrValidation("message not found or unexpected API response format")
|
||||
}
|
||||
|
||||
msg, ok := items[0].(map[string]any)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "unexpected message format in API response")
|
||||
return "", output.ErrValidation("unexpected message format in API response")
|
||||
}
|
||||
|
||||
chatID, ok := msg["chat_id"].(string)
|
||||
if !ok {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "message response missing chat_id field")
|
||||
return "", output.ErrValidation("message response missing chat_id field")
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
@@ -1387,324 +1393,15 @@ func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, erro
|
||||
// Returns an error if the chat query fails, since guessing the wrong item_type
|
||||
// can cause silent failures in flag operations.
|
||||
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
|
||||
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
|
||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
|
||||
if err != nil {
|
||||
return ItemTypeDefault, wrapIMNetworkErr(err, "failed to query chat_mode for chat %s", chatID)
|
||||
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
|
||||
}
|
||||
|
||||
// DoAPIJSONTyped returns envelope.Data, so chat_mode is at the top level
|
||||
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
|
||||
chatMode, _ := data["chat_mode"].(string)
|
||||
if chatMode == "topic" {
|
||||
return ItemTypeThread, nil
|
||||
}
|
||||
return ItemTypeMsgThread, nil
|
||||
}
|
||||
|
||||
// ShortcutType enumerates the OpenAPI feed-shortcut types.
|
||||
// Currently the server only opens CHAT (1) externally; other internal values
|
||||
// (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway.
|
||||
type ShortcutType int
|
||||
|
||||
const (
|
||||
ShortcutTypeUnknown ShortcutType = 0
|
||||
ShortcutTypeChat ShortcutType = 1
|
||||
)
|
||||
|
||||
const (
|
||||
feedShortcutBatchLimit = 10
|
||||
feedShortcutWriteScope = "im:feed.shortcut:write"
|
||||
feedShortcutReadScope = "im:feed.shortcut:read"
|
||||
)
|
||||
|
||||
// shortcutItem is one entry in the feed_shortcuts API body.
|
||||
type shortcutItem struct {
|
||||
FeedCardID string `json:"feed_card_id"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
|
||||
// returns deduped, validated oc_ IDs. The server batch limit is 10.
|
||||
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||
raw := rt.StrSlice("chat-id")
|
||||
if len(raw) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values").WithParam("--chat-id")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(v, "oc_") {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --chat-id %q: must be an open_chat_id starting with oc_", v).WithParam("--chat-id")
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||
}
|
||||
if len(out) > feedShortcutBatchLimit {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"too many --chat-id values (%d); the server accepts up to %d per request",
|
||||
len(out), feedShortcutBatchLimit).WithParam("--chat-id")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildShortcutItems converts chat IDs to API payload entries (type=CHAT).
|
||||
func buildShortcutItems(ids []string) []shortcutItem {
|
||||
items := make([]shortcutItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
items = append(items, shortcutItem{FeedCardID: id, Type: int(ShortcutTypeChat)})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// shortcutFailedReasonString converts the numeric failed-reason enum returned
|
||||
// by the server into a human-readable label. Used to enrich the response
|
||||
// when the API reports per-item failures.
|
||||
func shortcutFailedReasonString(reason int) string {
|
||||
switch reason {
|
||||
case 0:
|
||||
return "unknown"
|
||||
case 1:
|
||||
return "no_permission"
|
||||
case 2:
|
||||
return "invalid_item"
|
||||
case 3:
|
||||
return "has_pending_delete"
|
||||
case 4:
|
||||
return "type_not_support"
|
||||
case 5:
|
||||
return "internal_error"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// chatBatchQueryScope is the scope required by im.chats.batch_query, which
|
||||
// the CHAT detail resolver depends on. Surfaced as a conditional scope on
|
||||
// +feed-shortcut-list so the framework's scope diagnostics know about it.
|
||||
const chatBatchQueryScope = "im:chat:read"
|
||||
|
||||
// chatBatchQuerySize matches the server-side limit on /im/v1/chats/batch_query.
|
||||
const chatBatchQuerySize = 50
|
||||
|
||||
// shortcutTypeFromValue parses the type field as returned by the v2
|
||||
// feed_shortcuts API. JSON numbers come back as float64 after generic
|
||||
// unmarshal; we also tolerate the int form for forward-compat.
|
||||
func shortcutTypeFromValue(v any) ShortcutType {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return ShortcutType(int(n))
|
||||
case int:
|
||||
return ShortcutType(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return ShortcutType(i)
|
||||
}
|
||||
}
|
||||
return ShortcutTypeUnknown
|
||||
}
|
||||
|
||||
// queryChatBatch fetches one im.chats.batch_query page (at most
|
||||
// chatBatchQuerySize ids) and merges the full chat objects into dst keyed by
|
||||
// chat_id. Shared by feed-shortcut detail enrichment and message-search chat
|
||||
// context lookup, which apply their own per-chunk error policies.
|
||||
func queryChatBatch(rt *common.RuntimeContext, batch []string, dst map[string]map[string]any) error {
|
||||
res, err := rt.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
map[string]any{"chat_ids": batch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, _ := res["items"].([]any)
|
||||
for _, ci := range items {
|
||||
cm, _ := ci.(map[string]any)
|
||||
if cm == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(cm["chat_id"]); id != "" {
|
||||
dst[id] = cm
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveChatDetail batch-fetches the full chat object via
|
||||
// im.chats.batch_query (50 ids per request — server limit) and returns the
|
||||
// objects keyed by chat_id, verbatim, so the caller can decide which fields
|
||||
// to surface. The server's `name` field is empty for p2p chats (client UI
|
||||
// shows the partner's display name there), but the full object still carries
|
||||
// `chat_mode`, `p2p_target_id`, `description`, etc., so callers can render
|
||||
// p2p entries however they want.
|
||||
func resolveChatDetail(rt *common.RuntimeContext, ids []string) (map[string]map[string]any, error) {
|
||||
out := map[string]map[string]any{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := checkFlagRequiredScopes(rt.Ctx(), rt, []string{chatBatchQueryScope}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, batch := range chunkStrings(ids, chatBatchQuerySize) {
|
||||
if err := queryChatBatch(rt, batch, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// enrichFeedShortcutDetail walks the list response and attaches the full chat
|
||||
// object under `detail` for CHAT-type entries — the only type the OpenAPI
|
||||
// gateway exposes today. Mutates data in place.
|
||||
//
|
||||
// Failures are returned to the caller so it can decide whether to hard-fail
|
||||
// the command or downgrade to a warning. Listing the shortcuts succeeds even
|
||||
// if enrichment is unavailable (missing scope, network error, etc.).
|
||||
func enrichFeedShortcutDetail(rt *common.RuntimeContext, data map[string]any) error {
|
||||
items, _ := data["shortcuts"].([]any)
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
|
||||
continue
|
||||
}
|
||||
id := asString(m["feed_card_id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
details, err := resolveChatDetail(rt, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Missing items (server didn't return one for an id we asked about) are
|
||||
// left untouched, so the presence of `detail` signals a successful lookup.
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
|
||||
continue
|
||||
}
|
||||
if info, ok := details[asString(m["feed_card_id"])]; ok {
|
||||
m["detail"] = info
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// annotateFailedShortcuts walks the API response and attaches a
|
||||
// reason_label string next to each numeric reason. Mutates data in place.
|
||||
func annotateFailedShortcuts(data map[string]any) {
|
||||
items, ok := data["failed_shortcuts"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
// reason is serialized as a JSON number → float64 after generic unmarshal.
|
||||
switch r := m["reason"].(type) {
|
||||
case float64:
|
||||
m["reason_label"] = shortcutFailedReasonString(int(r))
|
||||
case int:
|
||||
m["reason_label"] = shortcutFailedReasonString(r)
|
||||
case json.Number:
|
||||
i, err := r.Int64()
|
||||
if err == nil {
|
||||
m["reason_label"] = shortcutFailedReasonString(int(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emitFeedShortcutWriteResult preserves the server payload while adding a
|
||||
// batch ledger. A feed-shortcut write can return HTTP/API success with
|
||||
// failed_shortcuts populated; callers still need a complete account of which
|
||||
// requested entries succeeded and which failed.
|
||||
func emitFeedShortcutWriteResult(rt *common.RuntimeContext, requested []shortcutItem, data map[string]any) error {
|
||||
// A fully-successful write can come back as code:0 with data:null, in
|
||||
// which case DoAPIJSON hands us a nil map; the caller is still owed a
|
||||
// ledger, so start from an empty object instead of panicking on write.
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
annotateFailedShortcuts(data)
|
||||
addFeedShortcutWriteLedger(data, requested)
|
||||
if hasFailedShortcuts(data) {
|
||||
return rt.OutPartialFailure(data, nil)
|
||||
}
|
||||
rt.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addFeedShortcutWriteLedger(data map[string]any, requested []shortcutItem) {
|
||||
failed := failedShortcutItems(data)
|
||||
// Failed entries are matched back to requested items by feed_card_id
|
||||
// alone: every requested item is CHAT-type, so the id is the identity,
|
||||
// and a failed echo with a missing or zero type still excludes its item
|
||||
// from the success list.
|
||||
failedIDs := map[string]struct{}{}
|
||||
for _, it := range failed {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
shortcut, _ := m["shortcut"].(map[string]any)
|
||||
if shortcut == nil {
|
||||
continue
|
||||
}
|
||||
if id := asString(shortcut["feed_card_id"]); id != "" {
|
||||
failedIDs[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
succeeded := make([]shortcutItem, 0, len(requested))
|
||||
for _, it := range requested {
|
||||
if _, isFailed := failedIDs[it.FeedCardID]; isFailed {
|
||||
continue
|
||||
}
|
||||
succeeded = append(succeeded, it)
|
||||
}
|
||||
|
||||
// Counts are derived from the requested-item accounting alone so the
|
||||
// success+failure==total invariant holds even if the server echoes a
|
||||
// failed entry twice or reports one we never asked about;
|
||||
// failed_shortcuts still carries the raw server report.
|
||||
data["total"] = len(requested)
|
||||
data["success_count"] = len(succeeded)
|
||||
data["failure_count"] = len(requested) - len(succeeded)
|
||||
data["succeeded_shortcuts"] = succeeded
|
||||
}
|
||||
|
||||
func hasFailedShortcuts(data map[string]any) bool {
|
||||
return len(failedShortcutItems(data)) > 0
|
||||
}
|
||||
|
||||
func failedShortcutItems(data map[string]any) []any {
|
||||
items, _ := data["failed_shortcuts"].([]any)
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -23,7 +22,6 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -447,15 +445,8 @@ func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
target := "out.bin"
|
||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want errors.Is(context.Canceled)", err)
|
||||
}
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(err, &ne) {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("network subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
if err != context.Canceled {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
|
||||
}
|
||||
// First attempt is made, then retry checks ctx.Err() and returns
|
||||
if attempts != 1 {
|
||||
@@ -609,14 +600,6 @@ func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %T, want typed problem", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer || p.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("network problem = subtype %q code %d, want subtype %q code %d",
|
||||
p.Subtype, p.Code, errs.SubtypeNetworkServer, http.StatusInternalServerError)
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output file exists after failed download, stat error = %v", statErr)
|
||||
}
|
||||
@@ -733,7 +716,7 @@ func TestUploadImageToIMSuccess(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := uploadImageToIM(context.Background(), runtime, path, "message", "--image")
|
||||
got, err := uploadImageToIM(context.Background(), runtime, path, "message")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||
}
|
||||
@@ -771,7 +754,7 @@ func TestUploadFileToIMSuccess(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200", "--file")
|
||||
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
@@ -801,14 +784,10 @@ func TestUploadImageToIMSizeLimit(t *testing.T) {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
_, err = uploadImageToIM(context.Background(), rt, path, "message", "--image")
|
||||
_, err = uploadImageToIM(context.Background(), rt, path, "message")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--image" {
|
||||
t.Fatalf("uploadImageToIM() size error must carry Param=--image, got %T %+v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
@@ -826,21 +805,13 @@ func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
_, err = uploadFileToIM(context.Background(), rt, path, "stream", "", "--file")
|
||||
_, err = uploadFileToIM(context.Background(), rt, path, "stream", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--file" {
|
||||
t.Fatalf("uploadFileToIM() size error must carry Param=--file, got %T %+v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveMediaContentMissingLocalFileIsValidation pins that a missing local
|
||||
// media path is a typed validation error (bad --image input), not a network or
|
||||
// internal error: the file never opened, so there is no transport failure to
|
||||
// classify as network.
|
||||
func TestResolveMediaContentMissingLocalFileIsValidation(t *testing.T) {
|
||||
func TestResolveMediaContentWrapsUploadError(t *testing.T) {
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
@@ -855,49 +826,8 @@ func TestResolveMediaContentMissingLocalFileIsValidation(t *testing.T) {
|
||||
|
||||
missing := "missing.png"
|
||||
_, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("missing local media file must be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--image" {
|
||||
t.Fatalf("missing local media file Param = %q, want --image", ve.Param)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Fatalf("error should explain the unreadable file, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileToIMMissingLocalFileCarriesParam(t *testing.T) {
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
IOStreams: &cmdutil.IOStreams{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, err := uploadFileToIM(context.Background(), runtime, "missing.bin", "stream", "", "--file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("missing local file must be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("missing local file Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartURLDownloadBlockedURLCarriesParam(t *testing.T) {
|
||||
_, _, err := startURLDownload(context.Background(), nil, "http://127.0.0.1/image.png", "--image")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("blocked URL must be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--image" {
|
||||
t.Fatalf("blocked URL Param = %q, want --image", ve.Param)
|
||||
if err == nil || !strings.Contains(err.Error(), "image upload failed") {
|
||||
t.Fatalf("resolveMediaContent() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,7 +920,7 @@ func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", "", "--file"); err != nil {
|
||||
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
|
||||
|
||||
@@ -606,12 +606,6 @@ func TestShortcuts(t *testing.T) {
|
||||
"+flag-create",
|
||||
"+flag-cancel",
|
||||
"+flag-list",
|
||||
"+feed-shortcut-create",
|
||||
"+feed-shortcut-remove",
|
||||
"+feed-shortcut-list",
|
||||
"+feed-group-list",
|
||||
"+feed-group-list-item",
|
||||
"+feed-group-query-item",
|
||||
}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -53,7 +52,7 @@ var ImChatCreate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Bool("set-bot-manager") && !runtime.IsBot() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--set-bot-manager is only supported with bot identity (--as bot)").WithParam("--set-bot-manager")
|
||||
return output.ErrValidation("--set-bot-manager is only supported with bot identity (--as bot)")
|
||||
}
|
||||
|
||||
name := runtime.Str("name")
|
||||
@@ -61,25 +60,25 @@ var ImChatCreate = common.Shortcut{
|
||||
|
||||
// Public groups must have a name with at least 2 characters.
|
||||
if chatType == "public" && len([]rune(name)) < 2 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required for public groups and must be at least 2 characters").WithParam("--name")
|
||||
return output.ErrValidation("--name is required for public groups and must be at least 2 characters")
|
||||
}
|
||||
// Group name length must not exceed 60 characters.
|
||||
if len([]rune(name)) > 60 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
|
||||
return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name)))
|
||||
}
|
||||
// Description length must not exceed 100 characters.
|
||||
if desc := runtime.Str("description"); len([]rune(desc)) > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
|
||||
return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc)))
|
||||
}
|
||||
|
||||
// Validate users.
|
||||
if users := runtime.Str("users"); users != "" {
|
||||
ids := common.SplitCSV(users)
|
||||
if len(ids) > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--users exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--users")
|
||||
return output.ErrValidation("--users exceeds the maximum of 50 (got %d)", len(ids))
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserIDTyped("--users", id); err != nil {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -89,18 +88,18 @@ var ImChatCreate = common.Shortcut{
|
||||
if bots := runtime.Str("bots"); bots != "" {
|
||||
ids := common.SplitCSV(bots)
|
||||
if len(ids) > 5 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--bots exceeds the maximum of 5 (got %d)", len(ids)).WithParam("--bots")
|
||||
return output.ErrValidation("--bots exceeds the maximum of 5 (got %d)", len(ids))
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !strings.HasPrefix(id, "cli_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bot id %q: expected app ID (cli_xxx)", id).WithParam("--bots")
|
||||
return output.ErrValidation("invalid bot id %q: expected app ID (cli_xxx)", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate owner.
|
||||
if owner := runtime.Str("owner"); owner != "" {
|
||||
if _, err := common.ValidateUserIDTyped("--owner", owner); err != nil {
|
||||
if _, err := common.ValidateUserID(owner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -113,7 +112,7 @@ var ImChatCreate = common.Shortcut{
|
||||
if runtime.Bool("set-bot-manager") {
|
||||
qp["set_bot_manager"] = []string{"true"}
|
||||
}
|
||||
resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
|
||||
resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -128,7 +127,7 @@ var ImChatCreate = common.Shortcut{
|
||||
|
||||
// Try to fetch the group share link without blocking on failure.
|
||||
if chatID, ok := resData["chat_id"].(string); ok && chatID != "" {
|
||||
linkData, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||
linkData, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
fmt.Sprintf("/open-apis/im/v1/chats/%s/link", validate.EncodePathSegment(chatID)),
|
||||
nil, nil)
|
||||
if err == nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -72,15 +71,15 @@ var ImChatList = common.Shortcut{
|
||||
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 100")
|
||||
}
|
||||
parts, err := normalizeTypes(runtime.StrSlice("types"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`).WithParam("--types")
|
||||
return output.ErrValidation(
|
||||
`--types=p2p (single chats) is only supported with user identity (--as user). To protect user privacy, bot identity cannot list p2p chats. Use --as user, or include "group" in --types.`)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -96,7 +95,7 @@ var ImChatList = common.Shortcut{
|
||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||
}
|
||||
params := buildChatListParams(runtime, effective)
|
||||
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
|
||||
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,10 +211,10 @@ func normalizeTypes(raw []string) ([]string, error) {
|
||||
for _, p := range raw {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
if p == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types must contain at least one of p2p, group").WithParam("--types")
|
||||
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
|
||||
}
|
||||
if p != "p2p" && p != "group" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types contains invalid value %q: expected one of p2p, group", p).WithParam("--types")
|
||||
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||
@@ -67,15 +66,15 @@ var ImChatMessageList = common.Shortcut{
|
||||
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||
if runtime.IsBot() {
|
||||
if runtime.Str("user-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
|
||||
return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
|
||||
}
|
||||
if runtime.Str("chat-id") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --chat-id (bot identity does not support --user-id)").WithParam("--chat-id")
|
||||
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
|
||||
}
|
||||
} else {
|
||||
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
|
||||
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
|
||||
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --chat-id or --user-id")
|
||||
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -83,12 +82,12 @@ var ImChatMessageList = common.Shortcut{
|
||||
|
||||
// Validate ID formats
|
||||
if chatFlag := runtime.Str("chat-id"); chatFlag != "" {
|
||||
if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
|
||||
if _, err := common.ValidateChatID(chatFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if userFlag := runtime.Str("user-id"); userFlag != "" {
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
|
||||
if _, err := common.ValidateUserID(userFlag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -110,7 +109,7 @@ var ImChatMessageList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,14 +205,14 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string)
|
||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||
startTime, err := common.ParseTime(startFlag)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||
return nil, output.ErrValidation("--start: %v", err)
|
||||
}
|
||||
params["start_time"] = []string{startTime}
|
||||
}
|
||||
if endFlag := runtime.Str("end"); endFlag != "" {
|
||||
endTime, err := common.ParseTime(endFlag, "end")
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
return nil, output.ErrValidation("--end: %v", err)
|
||||
}
|
||||
params["end_time"] = []string{endTime}
|
||||
}
|
||||
@@ -237,7 +236,7 @@ func resolveChatIDForMessagesList(runtime *common.RuntimeContext, dryRun bool) (
|
||||
return "", err
|
||||
}
|
||||
if chatId == "" {
|
||||
return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user")
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
|
||||
}
|
||||
return chatId, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -54,10 +53,10 @@ var ImChatSearch = common.Shortcut{
|
||||
query := runtime.Str("query")
|
||||
memberIDs := runtime.Str("member-ids")
|
||||
if query == "" && memberIDs == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
return output.ErrValidation("--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
}
|
||||
if query != "" && len([]rune(query)) > 64 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
|
||||
return output.ErrValidation("--query exceeds the maximum of 64 characters (got %d)", len([]rune(query)))
|
||||
}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
allowed := map[string]struct{}{
|
||||
@@ -68,23 +67,23 @@ var ImChatSearch = common.Shortcut{
|
||||
}
|
||||
for _, item := range common.SplitCSV(st) {
|
||||
if _, ok := allowed[item]; !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item).WithParam("--search-types")
|
||||
return output.ErrValidation("invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mi := runtime.Str("member-ids"); mi != "" {
|
||||
ids := common.SplitCSV(mi)
|
||||
if len(ids) > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-ids exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--member-ids")
|
||||
return output.ErrValidation("--member-ids exceeds the maximum of 50 (got %d)", len(ids))
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil {
|
||||
if _, err := common.ValidateUserID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
|
||||
return output.ErrValidation("--page-size must be an integer between 1 and 100")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -95,7 +94,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildSearchChatBody(runtime)
|
||||
params := buildSearchChatParams(runtime)
|
||||
resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
|
||||
resData, err := runtime.CallAPI("POST", "/open-apis/im/v2/chats/search", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -38,25 +38,25 @@ var ImChatUpdate = common.Shortcut{
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
chat := runtime.Str("chat-id")
|
||||
if _, err := common.ValidateChatIDTyped("--chat-id", chat); err != nil {
|
||||
if _, err := common.ValidateChatID(chat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate --name length.
|
||||
name := runtime.Str("name")
|
||||
if name != "" && len([]rune(name)) > 60 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
|
||||
return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name)))
|
||||
}
|
||||
|
||||
// Validate --description length.
|
||||
if desc := runtime.Str("description"); desc != "" && len([]rune(desc)) > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
|
||||
return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc)))
|
||||
}
|
||||
|
||||
// At least one field must be provided for update.
|
||||
body := buildUpdateChatBody(runtime)
|
||||
if len(body) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one field must be specified to update")
|
||||
return output.ErrValidation("at least one field must be specified to update")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -65,7 +65,7 @@ var ImChatUpdate = common.Shortcut{
|
||||
chatID := runtime.Str("chat-id")
|
||||
body := buildUpdateChatBody(runtime)
|
||||
|
||||
_, err := runtime.DoAPIJSONTyped(http.MethodPut,
|
||||
_, err := runtime.DoAPIJSON(http.MethodPut,
|
||||
fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)),
|
||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||
body,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// wrapIMNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving its subtype / code / log_id from the runtime boundary),
|
||||
// and only wraps a raw, unclassified error as a transport-level network error.
|
||||
func wrapIMNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
func imContextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
|
||||
func withIMValidationParam(err error, param string) error {
|
||||
if err == nil || param == "" {
|
||||
return err
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) && ve.Param == "" {
|
||||
ve.WithParam(param)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// appendIMRecoveryHint attaches a recovery hint to err. A typed error keeps its
|
||||
// classification (category/subtype/code/log_id); only the hint is appended to
|
||||
// p.Hint (newline-joined when a hint already exists), and err is returned
|
||||
// unchanged. An unclassified error falls back to a typed internal error.
|
||||
func appendIMRecoveryHint(err error, hint string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s", err.Error()).WithHint(hint).WithCause(err)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestWrapIMNetworkErr_PassthroughTyped(t *testing.T) {
|
||||
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")
|
||||
got := wrapIMNetworkErr(typed, "download failed")
|
||||
if got != error(typed) {
|
||||
t.Fatalf("typed error must be passed through unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapIMNetworkErr_WrapsRaw(t *testing.T) {
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapIMNetworkErr(raw, "download failed: %s", "x")
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("raw error must become *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Errorf("cause must be chained for errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_TypedPreservedHintAppended(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeNotFound, "message not found")
|
||||
got := appendIMRecoveryHint(typed, "specify --item-type explicitly")
|
||||
if got != error(typed) {
|
||||
t.Fatalf("typed error must be returned unchanged, got %T", got)
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(got, &ae) {
|
||||
t.Fatalf("typed classification must be preserved, got %T", got)
|
||||
}
|
||||
if ae.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want %q", ae.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok || p.Hint != "specify --item-type explicitly" {
|
||||
t.Errorf("hint = %q (ok=%v), want %q", p.Hint, ok, "specify --item-type explicitly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_RawBecomesInternal(t *testing.T) {
|
||||
got := appendIMRecoveryHint(errors.New("boom"), "specify --item-type explicitly")
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("raw error must become *errs.InternalError, got %T", got)
|
||||
}
|
||||
if ie.Hint != "specify --item-type explicitly" {
|
||||
t.Errorf("hint = %q, want %q", ie.Hint, "specify --item-type explicitly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_Nil(t *testing.T) {
|
||||
if appendIMRecoveryHint(nil, "hint") != nil {
|
||||
t.Errorf("nil in -> nil out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIMRecoveryHint_AppendsExistingHint(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeNotFound, "message not found").WithHint("first")
|
||||
got := appendIMRecoveryHint(typed, "second")
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T", got)
|
||||
}
|
||||
if p.Hint != "first\nsecond" {
|
||||
t.Errorf("hint = %q, want %q", p.Hint, "first\nsecond")
|
||||
}
|
||||
}
|
||||
@@ -1,713 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// recordedFGRequest captures one outbound request for assertion.
|
||||
type recordedFGRequest struct {
|
||||
method string
|
||||
path string
|
||||
query map[string][]string
|
||||
body map[string]interface{}
|
||||
}
|
||||
|
||||
// fgResponder maps a URL path suffix to a JSON response body.
|
||||
type fgResponder func(path string, page int) (int, interface{})
|
||||
|
||||
// newFGCmd builds a cobra command carrying the shortcut's flags, applying the
|
||||
// provided overrides.
|
||||
func newFGCmd(t *testing.T, sc common.Shortcut, flags map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: sc.Command}
|
||||
for _, fl := range sc.Flags {
|
||||
switch fl.Type {
|
||||
case "bool":
|
||||
cmd.Flags().Bool(fl.Name, fl.Default == "true", fl.Desc)
|
||||
case "int":
|
||||
def := 0
|
||||
if fl.Default != "" {
|
||||
n, _ := strconv.Atoi(fl.Default)
|
||||
def = n
|
||||
}
|
||||
cmd.Flags().Int(fl.Name, def, fl.Desc)
|
||||
default:
|
||||
cmd.Flags().String(fl.Name, fl.Default, fl.Desc)
|
||||
}
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, val := range flags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("set flag %s=%s: %v", name, val, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newFGRuntime wires a user-identity runtime with the shortcut's flags and an
|
||||
// httpmock transport that records requests and replies via the responder.
|
||||
func newFGRuntime(t *testing.T, sc common.Shortcut, flags map[string]string, recorded *[]recordedFGRequest, responder fgResponder) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
pageByPath := map[string]int{}
|
||||
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
rec := recordedFGRequest{
|
||||
method: req.Method,
|
||||
path: req.URL.Path,
|
||||
query: req.URL.Query(),
|
||||
}
|
||||
if req.Body != nil {
|
||||
data, _ := io.ReadAll(req.Body)
|
||||
if len(data) > 0 {
|
||||
_ = json.Unmarshal(data, &rec.body)
|
||||
}
|
||||
}
|
||||
if recorded != nil {
|
||||
*recorded = append(*recorded, rec)
|
||||
}
|
||||
pageByPath[req.URL.Path]++
|
||||
status, body := 200, interface{}(map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
if responder != nil {
|
||||
status, body = responder(req.URL.Path, pageByPath[req.URL.Path])
|
||||
}
|
||||
return shortcutJSONResponse(status, body), nil
|
||||
})
|
||||
|
||||
runtime := newUserShortcutRuntime(t, rt)
|
||||
runtime.Cmd = newFGCmd(t, sc, flags)
|
||||
runtime.Format = "json"
|
||||
return runtime
|
||||
}
|
||||
|
||||
func wrapData(d map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{"code": 0, "data": d}
|
||||
}
|
||||
|
||||
func findFGRequest(reqs []recordedFGRequest, pathSuffix string) *recordedFGRequest {
|
||||
for i := range reqs {
|
||||
if strings.HasSuffix(reqs[i].path, pathSuffix) {
|
||||
return &reqs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstQueryValue(q map[string][]string, key string) string {
|
||||
if v := q[key]; len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dryRunJSON marshals a DryRunAPI to its wire shape so tests can assert against
|
||||
// the public JSON (calls/extra are unexported on the struct).
|
||||
func dryRunJSON(t *testing.T, d *common.DryRunAPI) map[string]interface{} {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("unmarshal dry-run: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func dryRunCalls(t *testing.T, d *common.DryRunAPI) []map[string]interface{} {
|
||||
t.Helper()
|
||||
m := dryRunJSON(t, d)
|
||||
raw, _ := m["api"].([]interface{})
|
||||
calls := make([]map[string]interface{}, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
calls = append(calls, cm)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func countFGRequests(reqs []recordedFGRequest, pathSuffix string) int {
|
||||
n := 0
|
||||
for i := range reqs {
|
||||
if strings.HasSuffix(reqs[i].path, pathSuffix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── list-item: happy path with enrichment of items + deleted_items ──
|
||||
|
||||
func TestFeedGroupListItemEnrichesBothLists(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs,
|
||||
func(path string, _ int) (int, interface{}) {
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/list_item"):
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}},
|
||||
"deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}},
|
||||
"page_token": "",
|
||||
"has_more": false,
|
||||
})
|
||||
case strings.HasSuffix(path, "/chats/batch_query"):
|
||||
return 200, wrapData(map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"},
|
||||
map[string]interface{}{"chat_id": "oc_def", "name": "Old Channel"},
|
||||
}})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
|
||||
list := findFGRequest(reqs, "/list_item")
|
||||
if list == nil {
|
||||
t.Fatal("expected list_item request")
|
||||
}
|
||||
if list.method != http.MethodGet {
|
||||
t.Errorf("list_item method = %s, want GET", list.method)
|
||||
}
|
||||
if !strings.HasSuffix(list.path, "/open-apis/im/v1/groups/ofg_x/list_item") {
|
||||
t.Errorf("list_item path = %s", list.path)
|
||||
}
|
||||
if findFGRequest(reqs, "/chats/batch_query") == nil {
|
||||
t.Error("expected chats/batch_query enrichment request")
|
||||
}
|
||||
}
|
||||
|
||||
// ── list-item: empty items skips enrichment ──
|
||||
|
||||
func TestFeedGroupListItemEmptySkipsEnrichment(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs,
|
||||
func(path string, _ int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/list_item") {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{}, "deleted_items": []interface{}{},
|
||||
"page_token": "", "has_more": false,
|
||||
})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
if findFGRequest(reqs, "/chats/batch_query") != nil {
|
||||
t.Error("did not expect batch_query when there are no items")
|
||||
}
|
||||
}
|
||||
|
||||
// ── list-item: page-all merges across 2 pages, empty deleted serializes as [] ──
|
||||
|
||||
func TestFeedGroupListItemPageAllMerges(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-all": "true"}, &reqs,
|
||||
func(path string, page int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/list_item") {
|
||||
if page == 1 {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}},
|
||||
"deleted_items": []interface{}{},
|
||||
"page_token": "TKN", "has_more": true,
|
||||
})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_b", "feed_type": "chat", "update_time": "2"}},
|
||||
"deleted_items": []interface{}{},
|
||||
"page_token": "", "has_more": false,
|
||||
})
|
||||
}
|
||||
if strings.HasSuffix(path, "/chats/batch_query") {
|
||||
return 200, wrapData(map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_a", "name": "A"},
|
||||
map[string]interface{}{"chat_id": "oc_b", "name": "B"},
|
||||
}})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
if got := countFGRequests(reqs, "/list_item"); got != 2 {
|
||||
t.Errorf("expected 2 list_item requests, got %d", got)
|
||||
}
|
||||
// Second list_item page must carry the continuation token.
|
||||
var second *recordedFGRequest
|
||||
n := 0
|
||||
for i := range reqs {
|
||||
if strings.HasSuffix(reqs[i].path, "/list_item") {
|
||||
n++
|
||||
if n == 2 {
|
||||
second = &reqs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if second == nil || firstQueryValue(second.query, "page_token") != "TKN" {
|
||||
t.Errorf("second page token = %q, want TKN", firstQueryValue(second.query, "page_token"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── list-item: explicit page-token ignores page-all (single page) ──
|
||||
|
||||
func TestFeedGroupListItemPageTokenIgnoresPageAll(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "page-all": "true", "page-token": "SOMETOKEN",
|
||||
}, &reqs, func(path string, _ int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/list_item") {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{}, "deleted_items": []interface{}{},
|
||||
"page_token": "NEXT", "has_more": true,
|
||||
})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
if got := countFGRequests(reqs, "/list_item"); got != 1 {
|
||||
t.Errorf("expected 1 list_item request (page-token wins), got %d", got)
|
||||
}
|
||||
req := findFGRequest(reqs, "/list_item")
|
||||
if got := firstQueryValue(req.query, "page_token"); got != "SOMETOKEN" {
|
||||
t.Errorf("page_token query = %q, want SOMETOKEN", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── query-item: builds correct body and enriches ──
|
||||
|
||||
func TestFeedGroupQueryItemBuildsBody(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b",
|
||||
}, &reqs, func(path string, _ int) (int, interface{}) {
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/batch_query_item"):
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}},
|
||||
"deleted_items": []interface{}{},
|
||||
})
|
||||
case strings.HasSuffix(path, "/chats/batch_query"):
|
||||
return 200, wrapData(map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_a", "name": "Team A"},
|
||||
}})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
req := findFGRequest(reqs, "/batch_query_item")
|
||||
if req == nil {
|
||||
t.Fatal("expected batch_query_item request")
|
||||
}
|
||||
if req.method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", req.method)
|
||||
}
|
||||
if !strings.HasSuffix(req.path, "/open-apis/im/v1/groups/ofg_x/batch_query_item") {
|
||||
t.Errorf("path = %s", req.path)
|
||||
}
|
||||
items, ok := req.body["items"].([]interface{})
|
||||
if !ok || len(items) != 2 {
|
||||
t.Fatalf("body items = %#v, want 2 entries", req.body["items"])
|
||||
}
|
||||
first, _ := items[0].(map[string]interface{})
|
||||
if first["feed_id"] != "oc_a" || first["feed_type"] != "chat" {
|
||||
t.Errorf("first item = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// ── table output: renders feed_id / chat_name / update_time + summary lines ──
|
||||
|
||||
func TestFeedGroupListItemTableOutput(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, nil,
|
||||
func(path string, _ int) (int, interface{}) {
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/list_item"):
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}},
|
||||
"deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}},
|
||||
"page_token": "TKN", "has_more": true,
|
||||
})
|
||||
case strings.HasSuffix(path, "/chats/batch_query"):
|
||||
return 200, wrapData(map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"},
|
||||
}})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
runtime.Format = "pretty"
|
||||
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if out == nil {
|
||||
t.Fatal("stdout buffer missing")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"feed_id", "chat_name", "update_time", "oc_abc", "Release Team", "1 item(s)", "more available", "(1 deleted)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("table output missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// update_time must be rendered human-readable (RFC3339), not as raw Unix millis.
|
||||
if strings.Contains(got, "1767196800000") {
|
||||
t.Errorf("table output should not contain raw millis timestamp; got:\n%s", got)
|
||||
}
|
||||
wantTime := time.UnixMilli(1767196800000).Local().Format(time.RFC3339)
|
||||
if !strings.Contains(got, wantTime) {
|
||||
t.Errorf("table output should contain formatted update_time %q; got:\n%s", wantTime, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── enrichment graceful degradation: unresolved feed_id keeps no chat_name ──
|
||||
|
||||
func TestEnrichFeedGroupItemsGracefulDegradation(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "feed-id": "oc_known",
|
||||
}, nil, func(path string, _ int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/chats/batch_query") {
|
||||
// Only oc_known resolves; oc_gone is absent.
|
||||
return 200, wrapData(map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_known", "name": "Known"},
|
||||
}})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
data := map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"feed_id": "oc_known", "feed_type": "chat"},
|
||||
map[string]any{"feed_id": "oc_gone", "feed_type": "chat"},
|
||||
},
|
||||
"deleted_items": []any{},
|
||||
}
|
||||
enrichFeedGroupItemsChatName(runtime, data)
|
||||
items := data["items"].([]any)
|
||||
known := items[0].(map[string]any)
|
||||
gone := items[1].(map[string]any)
|
||||
if known["chat_name"] != "Known" {
|
||||
t.Errorf("oc_known chat_name = %v, want Known", known["chat_name"])
|
||||
}
|
||||
if _, present := gone["chat_name"]; present {
|
||||
t.Errorf("oc_gone should not have chat_name, got %v", gone["chat_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── validation errors ──
|
||||
|
||||
func TestFeedGroupValidationErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
flags map[string]string
|
||||
want string
|
||||
}{
|
||||
{"list missing feed-group-id", ImFeedGroupListItem, map[string]string{}, "--feed-group-id is required"},
|
||||
{"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "--page-size must be an integer between 1 and 50"},
|
||||
{"list bad page-limit", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-limit": "2000"}, "--page-limit must be an integer between 1 and 1000"},
|
||||
{"list bad start-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "start-time": "notnum"}, "--start-time must be Unix milliseconds"},
|
||||
{"list bad end-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "end-time": "notnum"}, "--end-time must be Unix milliseconds"},
|
||||
{"query missing feed-group-id", ImFeedGroupQueryItem, map[string]string{"feed-id": "oc_a"}, "--feed-group-id is required"},
|
||||
{"query missing feed-id", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, "--feed-id is required (comma-separated chat IDs)"},
|
||||
{"query blank feed-id tokens", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x", "feed-id": ", ,"}, "--feed-id is required (comma-separated chat IDs)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime := newFGRuntime(t, tc.sc, tc.flags, nil, nil)
|
||||
err := tc.sc.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error %q, got nil", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("error = %q, want contains %q", err.Error(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── dry-run shapes ──
|
||||
|
||||
func TestFeedGroupListItemDryRun(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "page-size": "10", "page-token": "TKN", "start-time": "100", "end-time": "200",
|
||||
}, nil, nil)
|
||||
d := ImFeedGroupListItem.DryRun(context.Background(), runtime)
|
||||
calls := dryRunCalls(t, d)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0]["method"] != "GET" {
|
||||
t.Errorf("method = %v, want GET", calls[0]["method"])
|
||||
}
|
||||
if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/list_item") {
|
||||
t.Errorf("url = %s", url)
|
||||
}
|
||||
params, _ := calls[0]["params"].(map[string]interface{})
|
||||
for key, want := range map[string]string{
|
||||
"page_size": "10", "page_token": "TKN", "start_time": "100", "end_time": "200",
|
||||
} {
|
||||
if params[key] != want {
|
||||
t.Errorf("params %s = %v, want %s", key, params[key], want)
|
||||
}
|
||||
}
|
||||
if desc, _ := calls[0]["desc"].(string); !strings.Contains(desc, "im:chat:read") {
|
||||
t.Errorf("desc = %q, want chat_name enrichment note", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupListItemDryRunValidationError(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{}, nil, nil)
|
||||
d := ImFeedGroupListItem.DryRun(context.Background(), runtime)
|
||||
m := dryRunJSON(t, d)
|
||||
errMsg, _ := m["error"].(string)
|
||||
if errMsg == "" {
|
||||
t.Fatalf("expected error in dry-run output, got %#v", m)
|
||||
}
|
||||
if !strings.Contains(errMsg, "--feed-group-id is required") {
|
||||
t.Errorf("error = %v", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupQueryItemDryRun(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b",
|
||||
}, nil, nil)
|
||||
d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime)
|
||||
calls := dryRunCalls(t, d)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0]["method"] != "POST" {
|
||||
t.Errorf("method = %v, want POST", calls[0]["method"])
|
||||
}
|
||||
if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/batch_query_item") {
|
||||
t.Errorf("url = %s", url)
|
||||
}
|
||||
body, _ := calls[0]["body"].(map[string]interface{})
|
||||
items, _ := body["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("dry-run body items = %#v, want 2", body["items"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupQueryItemDryRunValidationError(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, nil, nil)
|
||||
d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime)
|
||||
m := dryRunJSON(t, d)
|
||||
if errMsg, _ := m["error"].(string); errMsg == "" {
|
||||
t.Fatalf("expected error in dry-run output, got %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// ── list-item: time-window flags reach the query ──
|
||||
|
||||
func TestFeedGroupListItemTimeWindowQueryParams(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "start-time": "100", "end-time": "200",
|
||||
}, &reqs, func(path string, _ int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/list_item") {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{}, "deleted_items": []interface{}{},
|
||||
"page_token": "", "has_more": false,
|
||||
})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
req := findFGRequest(reqs, "/list_item")
|
||||
if req == nil {
|
||||
t.Fatal("expected list_item request")
|
||||
}
|
||||
if got := firstQueryValue(req.query, "start_time"); got != "100" {
|
||||
t.Errorf("start_time query = %q, want 100", got)
|
||||
}
|
||||
if got := firstQueryValue(req.query, "end_time"); got != "200" {
|
||||
t.Errorf("end_time query = %q, want 200", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── list-item: infinite-loop guard + defensive page-limit clamping ──
|
||||
|
||||
func TestFeedGroupListItemPageAllStopsOnRepeatedToken(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pageLimit string
|
||||
}{
|
||||
{"limit clamped up from 0", "0"},
|
||||
{"limit clamped down from 1001", "1001"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "page-all": "true", "page-limit": tc.pageLimit,
|
||||
"start-time": "100", "end-time": "200",
|
||||
}, &reqs, func(path string, _ int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/list_item") {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}},
|
||||
"deleted_items": []interface{}{},
|
||||
"page_token": "SAME", "has_more": true,
|
||||
})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
runtime.Format = "pretty" // exercise the page-all table-render path too
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
if got := countFGRequests(reqs, "/list_item"); got != 2 {
|
||||
t.Errorf("expected 2 list_item requests (stop on repeated token), got %d", got)
|
||||
}
|
||||
errOut, _ := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "page_token did not change") {
|
||||
t.Errorf("stderr missing loop warning; got:\n%s", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── list-item: API errors surface from both Execute paths ──
|
||||
|
||||
func TestFeedGroupListItemAPIError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
}{
|
||||
{"single page", map[string]string{"feed-group-id": "ofg_x"}},
|
||||
{"page-all", map[string]string{"feed-group-id": "ofg_x", "page-all": "true"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupListItem, tc.flags, nil,
|
||||
func(_ string, _ int) (int, interface{}) {
|
||||
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
|
||||
})
|
||||
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── enrichment: total resolution failure warns on stderr, nil data is a no-op ──
|
||||
|
||||
func TestEnrichFeedGroupItemsWarnsWhenResolutionFails(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{}, nil,
|
||||
func(path string, _ int) (int, interface{}) {
|
||||
if strings.HasSuffix(path, "/chats/batch_query") {
|
||||
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
|
||||
// nil data must not panic.
|
||||
enrichFeedGroupItemsChatName(runtime, nil)
|
||||
|
||||
data := map[string]any{
|
||||
"items": []any{map[string]any{"feed_id": "oc_a", "feed_type": "chat"}},
|
||||
"deleted_items": []any{},
|
||||
}
|
||||
enrichFeedGroupItemsChatName(runtime, data)
|
||||
|
||||
item := data["items"].([]any)[0].(map[string]any)
|
||||
if _, present := item["chat_name"]; present {
|
||||
t.Errorf("chat_name should be absent when resolution fails, got %v", item["chat_name"])
|
||||
}
|
||||
errOut, _ := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "could not resolve chat names") {
|
||||
t.Errorf("stderr missing resolution warning; got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── query-item: Execute error paths ──
|
||||
|
||||
func TestFeedGroupQueryItemExecuteErrors(t *testing.T) {
|
||||
t.Run("invalid flags", func(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, nil, nil)
|
||||
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err == nil {
|
||||
t.Fatal("expected validation error from Execute, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("api error", func(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "feed-id": "oc_a",
|
||||
}, nil, func(_ string, _ int) (int, interface{}) {
|
||||
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
|
||||
})
|
||||
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── formatFeedGroupUpdateTime: empty / non-numeric inputs pass through ──
|
||||
|
||||
func TestFormatFeedGroupUpdateTime(t *testing.T) {
|
||||
if got := formatFeedGroupUpdateTime(""); got != "" {
|
||||
t.Errorf("empty input = %q, want empty passthrough", got)
|
||||
}
|
||||
if got := formatFeedGroupUpdateTime("not-millis"); got != "not-millis" {
|
||||
t.Errorf("non-numeric input = %q, want raw passthrough", got)
|
||||
}
|
||||
want := time.UnixMilli(1767196800000).Local().Format(time.RFC3339)
|
||||
if got := formatFeedGroupUpdateTime("1767196800000"); got != want {
|
||||
t.Errorf("millis input = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── query-item: pretty table output renders enriched items ──
|
||||
|
||||
func TestFeedGroupQueryItemTableOutput(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
|
||||
"feed-group-id": "ofg_x", "feed-id": "oc_a",
|
||||
}, nil, func(path string, _ int) (int, interface{}) {
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/batch_query_item"):
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1767196800000"}},
|
||||
"deleted_items": []interface{}{},
|
||||
})
|
||||
case strings.HasSuffix(path, "/chats/batch_query"):
|
||||
return 200, wrapData(map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"chat_id": "oc_a", "name": "Team A"},
|
||||
}})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{})
|
||||
})
|
||||
runtime.Format = "pretty"
|
||||
|
||||
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if out == nil {
|
||||
t.Fatal("stdout buffer missing")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"oc_a", "Team A", "1 item(s)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("table output missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
// feedGroupReadScope is required to read feed-group items.
|
||||
feedGroupReadScope = "im:feed_group_v1:read"
|
||||
// chatReadScope is required to resolve chat_name from feed_id via chats/batch_query.
|
||||
chatReadScope = "im:chat:read"
|
||||
)
|
||||
|
||||
// enrichFeedGroupItemsChatName resolves a human-readable chat_name for each feed
|
||||
// card in data["items"] and data["deleted_items"] using chats/batch_query.
|
||||
//
|
||||
// The feed_id of a v1 feed card is always a chat ID (oc_xxx), so the chat's name
|
||||
// is the natural display label. Resolution degrades gracefully: any feed_id that
|
||||
// cannot be resolved simply keeps no chat_name key, and the function never returns
|
||||
// an error or alters the exit code.
|
||||
//
|
||||
// NOTE: This mutates the item maps in place by adding a "chat_name" key.
|
||||
func enrichFeedGroupItemsChatName(rt *common.RuntimeContext, data map[string]any) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]any)
|
||||
deletedItems, _ := data["deleted_items"].([]any)
|
||||
|
||||
// Collect deduped, ordered feed_id strings from both lists.
|
||||
ids := make([]string, 0, len(items)+len(deletedItems))
|
||||
seen := make(map[string]bool)
|
||||
collect := func(list []any) {
|
||||
for _, it := range list {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := m["feed_id"].(string)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
collect(items)
|
||||
collect(deletedItems)
|
||||
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
contexts := batchQueryChatContexts(rt, ids)
|
||||
if len(contexts) == 0 {
|
||||
// We had feed_ids to resolve but got nothing back — most likely the
|
||||
// chats/batch_query call failed (it degrades silently). Tell the user so
|
||||
// an empty chat_name column is not mistaken for chats that simply have no name.
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: could not resolve chat names for %d feed(s); chat_name will be empty\n", len(ids))
|
||||
return
|
||||
}
|
||||
|
||||
apply := func(list []any) {
|
||||
for _, it := range list {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := m["feed_id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if ctx, ok := contexts[id]; ok {
|
||||
if name, _ := ctx["name"].(string); name != "" {
|
||||
m["chat_name"] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
apply(items)
|
||||
apply(deletedItems)
|
||||
}
|
||||
|
||||
// renderFeedGroupItemsTable prints the active items[] as a table (feed_id /
|
||||
// chat_name / update_time), followed by a summary line. When hasMore is true a
|
||||
// pagination hint is appended; when there are deleted items their count is noted.
|
||||
func renderFeedGroupItemsTable(w io.Writer, data map[string]any, hasMore bool) {
|
||||
items, _ := data["items"].([]any)
|
||||
rows := make([]map[string]interface{}, 0, len(items))
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
chatName, _ := m["chat_name"].(string)
|
||||
updateTime, _ := m["update_time"].(string)
|
||||
feedID, _ := m["feed_id"].(string)
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"feed_id": feedID,
|
||||
"chat_name": chatName,
|
||||
"update_time": formatFeedGroupUpdateTime(updateTime),
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
|
||||
moreHint := ""
|
||||
if hasMore {
|
||||
moreHint = " (more available, use --page-token to fetch next page)"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d item(s)%s\n", len(items), moreHint)
|
||||
|
||||
if deleted, _ := data["deleted_items"].([]any); len(deleted) > 0 {
|
||||
fmt.Fprintf(w, "(%d deleted)\n", len(deleted))
|
||||
}
|
||||
}
|
||||
|
||||
// formatFeedGroupUpdateTime renders a Unix-millisecond timestamp string as a
|
||||
// human-readable local time for the pretty table. The raw value is returned
|
||||
// unchanged when it is empty or not a valid millisecond integer, so JSON output
|
||||
// (which never calls this) keeps the original wire value.
|
||||
func formatFeedGroupUpdateTime(raw string) string {
|
||||
if raw == "" {
|
||||
return raw
|
||||
}
|
||||
ms, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return time.UnixMilli(ms).Local().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const feedGroupListPath = "/open-apis/im/v1/groups"
|
||||
|
||||
// ImFeedGroupList provides the +feed-group-list shortcut: it lists the caller's
|
||||
// feed groups (tags) with auto-pagination that correctly merges BOTH the live
|
||||
// (groups) and soft-deleted (deleted_groups) lists across pages.
|
||||
//
|
||||
// The raw `feed.groups list --page-all` goes through the generic paginator,
|
||||
// which follows only one array field and silently drops the other list's later
|
||||
// pages; this shortcut paginates the dual-list response itself.
|
||||
var ImFeedGroupList = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-group-list",
|
||||
Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
{Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"},
|
||||
{Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFeedGroupListPageOptions(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if err := validateFeedGroupListPageOptions(runtime); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET(feedGroupListPath).
|
||||
Params(feedGroupListGroupsDryRunParams(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// When --page-token is explicitly provided, the user wants a specific
|
||||
// page — no auto-pagination regardless of --page-all.
|
||||
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
|
||||
return executeFeedGroupListGroupsAllPages(runtime)
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderFeedGroupsTable(w, data, hasMore)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error {
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
|
||||
}
|
||||
if v := rt.Str("start-time"); v != "" {
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-time must be Unix milliseconds (a decimal integer string)").WithParam("--start-time")
|
||||
}
|
||||
}
|
||||
if v := rt.Str("end-time"); v != "" {
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-time must be Unix milliseconds (a decimal integer string)").WithParam("--end-time")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// feedGroupListGroupsQuery builds the query parameters. page_token is always
|
||||
// sent (empty string = first page) because the groups endpoint rejects requests
|
||||
// that omit it (HTTP 400 "Missing required parameter: page_token").
|
||||
func feedGroupListGroupsQuery(rt *common.RuntimeContext) larkcore.QueryParams {
|
||||
params := larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{rt.Str("page-token")},
|
||||
}
|
||||
if start := rt.Str("start-time"); start != "" {
|
||||
params["start_time"] = []string{start}
|
||||
}
|
||||
if end := rt.Str("end-time"); end != "" {
|
||||
params["end_time"] = []string{end}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// feedGroupListGroupsDryRunParams mirrors feedGroupListGroupsQuery for dry-run display.
|
||||
func feedGroupListGroupsDryRunParams(rt *common.RuntimeContext) map[string]any {
|
||||
params := map[string]any{
|
||||
"page_size": strconv.Itoa(rt.Int("page-size")),
|
||||
"page_token": rt.Str("page-token"),
|
||||
}
|
||||
if start := rt.Str("start-time"); start != "" {
|
||||
params["start_time"] = start
|
||||
}
|
||||
if end := rt.Str("end-time"); end != "" {
|
||||
params["end_time"] = end
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// executeFeedGroupListGroupsAllPages fetches all pages and merges both the live
|
||||
// (groups) and soft-deleted (deleted_groups) lists into a single response. It
|
||||
// merges each array independently so neither list loses its later pages.
|
||||
func executeFeedGroupListGroupsAllPages(rt *common.RuntimeContext) error {
|
||||
maxPages := rt.Int("page-limit")
|
||||
if maxPages < 1 {
|
||||
maxPages = 20
|
||||
}
|
||||
if maxPages > 1000 {
|
||||
maxPages = 1000
|
||||
}
|
||||
|
||||
// Use make([]any, 0) so empty arrays serialize as [] not null.
|
||||
allGroups := make([]any, 0)
|
||||
allDeletedGroups := make([]any, 0)
|
||||
var lastHasMore bool
|
||||
var lastPageToken string
|
||||
prevPageToken := "__START__"
|
||||
|
||||
for page := 0; page < maxPages; page++ {
|
||||
// page_token is always sent (empty on the first page) — the groups
|
||||
// endpoint rejects requests that omit it.
|
||||
params := larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
"page_token": []string{""},
|
||||
}
|
||||
if page > 0 {
|
||||
params["page_token"] = []string{lastPageToken}
|
||||
}
|
||||
if start := rt.Str("start-time"); start != "" {
|
||||
params["start_time"] = []string{start}
|
||||
}
|
||||
if end := rt.Str("end-time"); end != "" {
|
||||
params["end_time"] = []string{end}
|
||||
}
|
||||
|
||||
data, err := rt.DoAPIJSONTyped("GET", feedGroupListPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v, ok := data["groups"].([]any); ok {
|
||||
allGroups = append(allGroups, v...)
|
||||
}
|
||||
if v, ok := data["deleted_groups"].([]any); ok {
|
||||
allDeletedGroups = append(allDeletedGroups, v...)
|
||||
}
|
||||
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
|
||||
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d groups, %d deleted\n",
|
||||
page+1, len(allGroups), len(allDeletedGroups))
|
||||
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
if lastPageToken == prevPageToken {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
|
||||
break
|
||||
}
|
||||
prevPageToken = lastPageToken
|
||||
}
|
||||
|
||||
merged := map[string]any{
|
||||
"groups": allGroups,
|
||||
"deleted_groups": allDeletedGroups,
|
||||
"has_more": lastHasMore,
|
||||
"page_token": lastPageToken,
|
||||
}
|
||||
|
||||
rt.OutFormat(merged, nil, func(w io.Writer) {
|
||||
renderFeedGroupsTable(w, merged, lastHasMore)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderFeedGroupsTable prints the active groups[] as a table (group_id / name /
|
||||
// type), followed by a summary line. When hasMore is true a pagination hint is
|
||||
// appended; when there are deleted groups their count is noted.
|
||||
func renderFeedGroupsTable(w io.Writer, data map[string]any, hasMore bool) {
|
||||
groups, _ := data["groups"].([]any)
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
m, _ := g.(map[string]any)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := m["group_id"].(string)
|
||||
name, _ := m["name"].(string)
|
||||
typ, _ := m["type"].(string)
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"group_id": id,
|
||||
"name": name,
|
||||
"type": typ,
|
||||
})
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
|
||||
moreHint := ""
|
||||
if hasMore {
|
||||
moreHint = " (more available, use --page-token to fetch next page)"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d group(s)%s\n", len(groups), moreHint)
|
||||
|
||||
if deleted, _ := data["deleted_groups"].([]any); len(deleted) > 0 {
|
||||
fmt.Fprintf(w, "(%d deleted)\n", len(deleted))
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ImFeedGroupListItem provides the +feed-group-list-item shortcut: it lists the
|
||||
// feed cards inside one feed group and enriches each item with chat_name resolved
|
||||
// from its feed_id.
|
||||
var ImFeedGroupListItem = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-group-list-item",
|
||||
Description: "List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope, chatReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
{Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"},
|
||||
{Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFeedGroupListOptions(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if err := validateFeedGroupListOptions(runtime); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET(feedGroupListItemPath(runtime)).
|
||||
Params(feedGroupListDryRunParams(runtime)).
|
||||
Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// When --page-token is explicitly provided, the user wants a specific page —
|
||||
// no auto-pagination regardless of --page-all.
|
||||
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
|
||||
return executeFeedGroupListAllPages(runtime)
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enrichFeedGroupItemsChatName(runtime, data)
|
||||
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderFeedGroupItemsTable(w, data, hasMore)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func validateFeedGroupListOptions(rt *common.RuntimeContext) error {
|
||||
if rt.Str("feed-group-id") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-group-id is required").WithParam("--feed-group-id")
|
||||
}
|
||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
|
||||
}
|
||||
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
|
||||
}
|
||||
if v := rt.Str("start-time"); v != "" {
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-time must be Unix milliseconds (a decimal integer string)").WithParam("--start-time")
|
||||
}
|
||||
}
|
||||
if v := rt.Str("end-time"); v != "" {
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-time must be Unix milliseconds (a decimal integer string)").WithParam("--end-time")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// feedGroupListItemPath builds the list_item endpoint path with the feed_group_id
|
||||
// segment safely encoded.
|
||||
func feedGroupListItemPath(rt *common.RuntimeContext) string {
|
||||
return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/list_item"
|
||||
}
|
||||
|
||||
// feedGroupListQuery builds the query parameters, sending only non-empty values.
|
||||
func feedGroupListQuery(rt *common.RuntimeContext) larkcore.QueryParams {
|
||||
params := larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
}
|
||||
if token := rt.Str("page-token"); token != "" {
|
||||
params["page_token"] = []string{token}
|
||||
}
|
||||
if start := rt.Str("start-time"); start != "" {
|
||||
params["start_time"] = []string{start}
|
||||
}
|
||||
if end := rt.Str("end-time"); end != "" {
|
||||
params["end_time"] = []string{end}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// feedGroupListDryRunParams mirrors feedGroupListQuery for dry-run display.
|
||||
func feedGroupListDryRunParams(rt *common.RuntimeContext) map[string]any {
|
||||
params := map[string]any{
|
||||
"page_size": strconv.Itoa(rt.Int("page-size")),
|
||||
}
|
||||
if token := rt.Str("page-token"); token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
if start := rt.Str("start-time"); start != "" {
|
||||
params["start_time"] = start
|
||||
}
|
||||
if end := rt.Str("end-time"); end != "" {
|
||||
params["end_time"] = end
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// executeFeedGroupListAllPages fetches all pages and merges items/deleted_items
|
||||
// into a single response, then enriches the merged result.
|
||||
func executeFeedGroupListAllPages(rt *common.RuntimeContext) error {
|
||||
maxPages := rt.Int("page-limit")
|
||||
if maxPages < 1 {
|
||||
maxPages = 20
|
||||
}
|
||||
if maxPages > 1000 {
|
||||
maxPages = 1000
|
||||
}
|
||||
|
||||
// Use make([]any, 0) so empty arrays serialize as [] not null.
|
||||
allItems := make([]any, 0)
|
||||
allDeletedItems := make([]any, 0)
|
||||
var lastHasMore bool
|
||||
var lastPageToken string
|
||||
prevPageToken := "__START__"
|
||||
|
||||
for page := 0; page < maxPages; page++ {
|
||||
params := larkcore.QueryParams{
|
||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||
}
|
||||
if page > 0 {
|
||||
params["page_token"] = []string{lastPageToken}
|
||||
}
|
||||
if start := rt.Str("start-time"); start != "" {
|
||||
params["start_time"] = []string{start}
|
||||
}
|
||||
if end := rt.Str("end-time"); end != "" {
|
||||
params["end_time"] = []string{end}
|
||||
}
|
||||
|
||||
data, err := rt.DoAPIJSONTyped("GET", feedGroupListItemPath(rt), params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v, ok := data["items"].([]any); ok {
|
||||
allItems = append(allItems, v...)
|
||||
}
|
||||
if v, ok := data["deleted_items"].([]any); ok {
|
||||
allDeletedItems = append(allDeletedItems, v...)
|
||||
}
|
||||
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
|
||||
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d items, %d deleted\n",
|
||||
page+1, len(allItems), len(allDeletedItems))
|
||||
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
if lastPageToken == prevPageToken {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
|
||||
break
|
||||
}
|
||||
prevPageToken = lastPageToken
|
||||
}
|
||||
|
||||
merged := map[string]any{
|
||||
"items": allItems,
|
||||
"deleted_items": allDeletedItems,
|
||||
"has_more": lastHasMore,
|
||||
"page_token": lastPageToken,
|
||||
}
|
||||
|
||||
enrichFeedGroupItemsChatName(rt, merged)
|
||||
|
||||
rt.OutFormat(merged, nil, func(w io.Writer) {
|
||||
renderFeedGroupItemsTable(w, merged, lastHasMore)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fgGroup(id string) map[string]interface{} {
|
||||
return map[string]interface{}{"group_id": id, "name": id, "type": "normal"}
|
||||
}
|
||||
|
||||
// TestFeedGroupListPageAllMergesBothLists is the core regression for the
|
||||
// +feed-group-list shortcut: a dual-list response (groups + deleted_groups) must
|
||||
// have BOTH lists merged across pages — including active groups that appear only
|
||||
// on a later page. This is what the raw `feed.groups list --page-all` gets wrong.
|
||||
func TestFeedGroupListPageAllMergesBothLists(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-all": "true", "page-size": "5"}, &reqs,
|
||||
func(_ string, page int) (int, interface{}) {
|
||||
if page == 1 {
|
||||
// page 1 fills up with mostly deleted groups; the active groups
|
||||
// g1/g2 here plus one more (g3) on page 2.
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"groups": []interface{}{fgGroup("g1"), fgGroup("g2")},
|
||||
"deleted_groups": []interface{}{fgGroup("d1"), fgGroup("d2"), fgGroup("d3")},
|
||||
"page_token": "TKN", "has_more": true,
|
||||
})
|
||||
}
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"groups": []interface{}{fgGroup("g3")},
|
||||
"deleted_groups": []interface{}{fgGroup("d4")},
|
||||
"page_token": "", "has_more": false,
|
||||
})
|
||||
})
|
||||
|
||||
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
if got := countFGRequests(reqs, "/groups"); got != 2 {
|
||||
t.Fatalf("expected 2 groups requests, got %d", got)
|
||||
}
|
||||
if got := firstQueryValue(reqs[1].query, "page_token"); got != "TKN" {
|
||||
t.Errorf("second page token = %q, want TKN", got)
|
||||
}
|
||||
|
||||
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if out == nil {
|
||||
t.Fatal("stdout buffer missing")
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("output not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]interface{})
|
||||
if got := len(data["groups"].([]interface{})); got != 3 {
|
||||
t.Errorf("merged groups = %d, want 3 (active list must include later pages)", got)
|
||||
}
|
||||
if got := len(data["deleted_groups"].([]interface{})); got != 4 {
|
||||
t.Errorf("merged deleted_groups = %d, want 4 (secondary list must also merge)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedGroupListAlwaysSendsPageToken locks the fix for the groups endpoint's
|
||||
// requirement that page_token be present even on the first page (HTTP 400
|
||||
// "Missing required parameter: page_token" otherwise).
|
||||
func TestFeedGroupListAlwaysSendsPageToken(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-size": "10"}, &reqs,
|
||||
func(_ string, _ int) (int, interface{}) {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"groups": []interface{}{}, "deleted_groups": []interface{}{},
|
||||
"page_token": "", "has_more": false,
|
||||
})
|
||||
})
|
||||
|
||||
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
req := findFGRequest(reqs, "/groups")
|
||||
if req == nil {
|
||||
t.Fatal("no /groups request recorded")
|
||||
}
|
||||
if _, ok := req.query["page_token"]; !ok {
|
||||
t.Errorf("first request must carry page_token query param (empty = first page); query=%v", req.query)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedGroupListValidation checks flag validation surfaces clear errors.
|
||||
func TestFeedGroupListValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
want string
|
||||
}{
|
||||
{"page-size too small", map[string]string{"page-size": "0"}, "--page-size"},
|
||||
{"page-size too large", map[string]string{"page-size": "51"}, "--page-size"},
|
||||
{"page-limit too large", map[string]string{"page-limit": "1001"}, "--page-limit"},
|
||||
{"bad start-time", map[string]string{"start-time": "notnum"}, "--start-time"},
|
||||
{"bad end-time", map[string]string{"end-time": "notnum"}, "--end-time"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, tc.flags, nil, nil)
|
||||
err := ImFeedGroupList.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error containing %q, got nil", tc.want)
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte(tc.want)) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedGroupListSinglePageTableOutput covers the non-page-all Execute path:
|
||||
// time-window params must reach the query, and the pretty table must render
|
||||
// group_id / name / type with the summary, pagination hint and deleted count.
|
||||
func TestFeedGroupListSinglePageTableOutput(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{
|
||||
"start-time": "100", "end-time": "200",
|
||||
}, &reqs, func(_ string, _ int) (int, interface{}) {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"groups": []interface{}{fgGroup("g1"), fgGroup("g2")},
|
||||
"deleted_groups": []interface{}{fgGroup("d1")},
|
||||
"page_token": "TKN", "has_more": true,
|
||||
})
|
||||
})
|
||||
runtime.Format = "pretty"
|
||||
|
||||
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got := countFGRequests(reqs, "/groups"); got != 1 {
|
||||
t.Fatalf("expected 1 groups request, got %d", got)
|
||||
}
|
||||
if got := firstQueryValue(reqs[0].query, "start_time"); got != "100" {
|
||||
t.Errorf("start_time query = %q, want 100", got)
|
||||
}
|
||||
if got := firstQueryValue(reqs[0].query, "end_time"); got != "200" {
|
||||
t.Errorf("end_time query = %q, want 200", got)
|
||||
}
|
||||
|
||||
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if out == nil {
|
||||
t.Fatal("stdout buffer missing")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"group_id", "g1", "g2", "2 group(s)", "more available", "(1 deleted)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("table output missing %q; got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedGroupListDryRun locks the dry-run shape: GET /groups with page_size,
|
||||
// page_token (always present) and the optional time-window params.
|
||||
func TestFeedGroupListDryRun(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{
|
||||
"page-size": "10", "page-token": "TKN", "start-time": "100", "end-time": "200",
|
||||
}, nil, nil)
|
||||
d := ImFeedGroupList.DryRun(context.Background(), runtime)
|
||||
calls := dryRunCalls(t, d)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||
}
|
||||
if calls[0]["method"] != "GET" {
|
||||
t.Errorf("method = %v, want GET", calls[0]["method"])
|
||||
}
|
||||
if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/open-apis/im/v1/groups") {
|
||||
t.Errorf("url = %s", url)
|
||||
}
|
||||
params, _ := calls[0]["params"].(map[string]interface{})
|
||||
for key, want := range map[string]string{
|
||||
"page_size": "10", "page_token": "TKN", "start_time": "100", "end_time": "200",
|
||||
} {
|
||||
if params[key] != want {
|
||||
t.Errorf("params %s = %v, want %s", key, params[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGroupListDryRunValidationError(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-size": "0"}, nil, nil)
|
||||
d := ImFeedGroupList.DryRun(context.Background(), runtime)
|
||||
m := dryRunJSON(t, d)
|
||||
errMsg, _ := m["error"].(string)
|
||||
if !strings.Contains(errMsg, "--page-size") {
|
||||
t.Errorf("dry-run error = %q, want --page-size validation message", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedGroupListPageAllStopsOnRepeatedToken locks the infinite-loop guard:
|
||||
// when the server keeps returning the same page_token with has_more=true,
|
||||
// pagination must stop after the repeat and warn on stderr. Also exercises the
|
||||
// defensive page-limit clamping (Execute is called directly, bypassing Validate).
|
||||
func TestFeedGroupListPageAllStopsOnRepeatedToken(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pageLimit string
|
||||
}{
|
||||
{"limit clamped up from 0", "0"},
|
||||
{"limit clamped down from 1001", "1001"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var reqs []recordedFGRequest
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{
|
||||
"page-all": "true", "page-limit": tc.pageLimit, "start-time": "100", "end-time": "200",
|
||||
}, &reqs, func(_ string, _ int) (int, interface{}) {
|
||||
return 200, wrapData(map[string]interface{}{
|
||||
"groups": []interface{}{fgGroup("g1")},
|
||||
"deleted_groups": []interface{}{},
|
||||
"page_token": "SAME", "has_more": true,
|
||||
})
|
||||
})
|
||||
runtime.Format = "pretty" // exercise the page-all table-render path too
|
||||
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if got := countFGRequests(reqs, "/groups"); got != 2 {
|
||||
t.Errorf("expected 2 requests (stop on repeated token), got %d", got)
|
||||
}
|
||||
errOut, _ := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
if !strings.Contains(errOut.String(), "page_token did not change") {
|
||||
t.Errorf("stderr missing loop warning; got:\n%s", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeedGroupListAPIError checks both Execute paths surface API errors.
|
||||
func TestFeedGroupListAPIError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
}{
|
||||
{"single page", map[string]string{}},
|
||||
{"page-all", map[string]string{"page-all": "true"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
runtime := newFGRuntime(t, ImFeedGroupList, tc.flags, nil,
|
||||
func(_ string, _ int) (int, interface{}) {
|
||||
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
|
||||
})
|
||||
if err := ImFeedGroupList.Execute(context.Background(), runtime); err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ImFeedGroupQueryItem provides the +feed-group-query-item shortcut: it looks up
|
||||
// specific feed cards in a feed group by ID and enriches each item with chat_name
|
||||
// resolved from its feed_id.
|
||||
var ImFeedGroupQueryItem = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+feed-group-query-item",
|
||||
Description: "Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id",
|
||||
Risk: "read",
|
||||
UserScopes: []string{feedGroupReadScope, chatReadScope},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
|
||||
{Name: "feed-id", Desc: "comma-separated chat IDs (oc_xxx); feed_type is fixed to chat (required)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildFeedGroupQueryItemBody(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildFeedGroupQueryItemBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(feedGroupQueryItemPath(runtime)).
|
||||
Body(body).
|
||||
Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildFeedGroupQueryItemBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped("POST", feedGroupQueryItemPath(runtime), nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enrichFeedGroupItemsChatName(runtime, data)
|
||||
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderFeedGroupItemsTable(w, data, false)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// feedGroupQueryItemPath builds the batch_query_item endpoint path with the
|
||||
// feed_group_id segment safely encoded.
|
||||
func feedGroupQueryItemPath(rt *common.RuntimeContext) string {
|
||||
return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/batch_query_item"
|
||||
}
|
||||
|
||||
// buildFeedGroupQueryItemBody validates the flags and constructs the request body
|
||||
// {"items":[{"feed_id":"<tok>","feed_type":"chat"}, ...]}.
|
||||
func buildFeedGroupQueryItemBody(rt *common.RuntimeContext) (map[string]any, error) {
|
||||
if rt.Str("feed-group-id") == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-group-id is required").WithParam("--feed-group-id")
|
||||
}
|
||||
tokens := common.SplitCSV(rt.Str("feed-id"))
|
||||
items := make([]any, 0, len(tokens))
|
||||
for _, tok := range tokens {
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"feed_id": tok,
|
||||
"feed_type": "chat",
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-id is required (comma-separated chat IDs)").WithParam("--feed-id")
|
||||
}
|
||||
return map[string]any{"items": items}, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user