Compare commits

...

14 Commits

Author SHA1 Message Date
zhangheng.023
77ad271fce fix: write skills state timestamps in local time without timezone
skills-state.json updated_at now uses a YYYY-MM-DDTHH:mm:ss local wall-clock layout with no timezone suffix, applied in both the incremental sync and full-install fallback write paths via a shared layout constant. Unit tests assert the no-suffix format on both paths.
2026-06-12 18:01:58 +08:00
liangshuo-1
9d845442ce feat: add skills command to read embedded skill content (#1318) 2026-06-08 13:58:45 +08:00
Max Huang
c07a14aa2b feat(lark-shared): document relative-path-only file arguments (#1319) 2026-06-08 13:19:03 +08:00
ethan-zhx
8b39f7243c feat: add iconpark lookup for lark slides (#1123) 2026-06-08 12:28:04 +08:00
liujinkun2025
e40ef66912 docs(lark-wiki): optimize skill guidance and routing boundaries (#1275)
- Add explicit NOT boundaries to the description and a dedicated
  "不在本 skill 范围" section: file upload -> lark-drive, content
  editing -> lark-doc / lark-sheets / lark-base.
- Move the Shortcuts table up, right after 快速决策, so command entry
  points are discoverable first; keep the member-add flow and
  target-semantics sections after it.
- Add an inline reminder under the delete-space guidance that a wiki
  URL / name is not a space_id and must be resolved via
  `wiki spaces get_node` first.
- Remove the duplicated permission (scope) table and the redundant
  schema note so auth/permission guidance stays centralized in
  lark-shared.
- Bump the skill version to 1.0.1.
- Keep skill-template/domains/wiki.md in sync with the SKILL.md
  introduction narrative.

Change-Id: If2b4341f350191ee0a65bf3a2cab9afa2b76d931
2026-06-08 11:10:59 +08:00
zhumiaoxin
e1bb9db552 feat(im): format feed group error handling (#1308) 2026-06-07 21:12:19 +08:00
zhangheng023
7c50b3d9e3 feat: fetch official skills index (#1301)
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.

Changes:

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

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

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

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

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

Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
2026-06-06 18:29:04 +08:00
evandance
5788a6c384 feat(im): return typed error envelopes across the im domain (#1230) 2026-06-06 17:07:57 +08:00
zhumiaoxin
bd07859c90 feat(im): cli support feed group (#1102)
Add IM feed group support documentation for lark-cli, making the raw im feed.groups.* APIs discoverable and easier for agents to use correctly.
2026-06-06 14:25:31 +08:00
evandance
8c3cba17b2 feat(task): emit typed error envelopes across the task domain (#1231)
Task commands now return structured, typed errors instead of the legacy
exit-code envelope: every failure carries a stable category, subtype, and
recovery hint, so callers can branch on the error class instead of parsing
messages. Exit codes derive from the error category — input validation exits 2,
a permission denial exits 3, other API errors exit 1.

Batch operations (adding tasks to a tasklist, creating a tasklist with tasks)
now report partial failure honestly: the per-item successes and failures stay
on stdout and the command exits non-zero instead of masking failures as a
success.
2026-06-05 22:30:45 +08:00
evandance
6367aaa0f5 feat(okr,whiteboard): emit typed error envelopes across both domains (#1236)
The okr and whiteboard commands now report every failure as a typed error
envelope. Invalid flags, malformed input, output-file conflicts, and API or
transport failures alike carry a stable category, subtype, the offending flag
or Lark error code, and a meaningful exit code — so scripts and agents can
branch on the error shape instead of scraping message strings.
2026-06-05 20:00:04 +08:00
qinxiaoyun
37b17f3d37 feat(events): add whiteboard event domain with per-board subscription (#1265)
Wire the board.whiteboard.updated_v1 EventKey into the consume pipeline so that lark-cli event consume automatically calls the per-whiteboard subscribe / unsubscribe OAPIs instead of requiring callers to manage server-side subscriptions out-of-band.

Change-Id: I94323807e8dc649d3296f6922311d2acaf92284e
2026-06-05 17:09:17 +08:00
evandance
be5527ca4e feat(im): add feed shortcut create, list, and remove shortcuts (#1273)
Adds feed shortcut management to the im domain: pin chats to the user's feed sidebar, list pinned entries, and unpin them. Three new shortcuts wrap the im/v2/feed_shortcuts OpenAPI routes, which currently expose CHAT-type entries only and accept user identity only.
2026-06-05 16:42:48 +08:00
fangshuyu-768
a75420f72c docs: add markdown domain template (#1293) 2026-06-05 15:48:01 +08:00
142 changed files with 52384 additions and 1459 deletions

View File

@@ -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/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
- 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/)
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/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|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/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

@@ -6,6 +6,7 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -16,6 +17,7 @@ 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"
@@ -51,6 +53,18 @@ 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
@@ -103,6 +117,7 @@ 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",
@@ -140,6 +155,7 @@ 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)

View File

@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Type: "api",
Code: 230002,
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
Message: "Bot/User can NOT be out of the chat.",
},
})
}

183
cmd/skill/skill.go Normal file
View File

@@ -0,0 +1,183 @@
// 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)
}

306
cmd/skill/skill_test.go Normal file
View File

@@ -0,0 +1,306 @@
// 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)
}
}

View File

@@ -49,12 +49,21 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
return r
}
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -478,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
@@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -862,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
@@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)

View File

@@ -8,6 +8,7 @@ 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"
)
@@ -17,6 +18,7 @@ func init() {
im.Keys(),
minutes.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
for _, k := range keys {

View File

@@ -0,0 +1,23 @@
// 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"`
}

View File

@@ -0,0 +1,48 @@
// 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
}
}

View File

@@ -0,0 +1,198 @@
// 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)

View File

@@ -0,0 +1,48 @@
// 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},
},
}
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -43,6 +44,8 @@ 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.

View File

@@ -10,10 +10,13 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -37,9 +40,15 @@ const (
)
const (
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
skillsIndexMaxBodySize = 1 << 20
verifyTimeout = 10 * time.Second
)
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
)
// DetectResult holds installation detection results.
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
}
r := &NpmResult{}
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
}
client := transport.NewHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
}
return nil
}
resp, err := client.Do(req)
if err != nil {
r.Err = err
return r
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
return r
}
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
if _, err := io.Copy(&r.Stdout, limited); err != nil {
r.Err = err
return r
}
if r.Stdout.Len() > skillsIndexMaxBodySize {
r.Stdout.Reset()
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
return r
}
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {

View File

@@ -4,12 +4,18 @@
package selfupdate
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs"
)
@@ -232,6 +238,113 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}
}
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
}
}
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
}
}
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
}
if result.Stdout.Len() != 0 {
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
}
}
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
oldTimeout := skillsIndexFetchTimeout
officialSkillsIndexURL = server.URL
skillsIndexFetchTimeout = 50 * time.Millisecond
t.Cleanup(func() {
officialSkillsIndexURL = oldURL
skillsIndexFetchTimeout = oldTimeout
})
result := New().ListOfficialSkillsIndex()
var netErr net.Error
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
}
}
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
}
}
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
return r
}}).ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if !strings.Contains(result.Stdout.String(), "override-skill") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{}
updater := &Updater{

View File

@@ -0,0 +1,209 @@
// 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
}

View File

@@ -0,0 +1,290 @@
// 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)
}
}

View File

@@ -14,6 +14,8 @@ 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-?]*[ -/]*[@-~]`)
@@ -80,6 +82,30 @@ func ParseGlobalSkillsJSON(text string) []string {
return sortedKeys(seen)
}
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
type officialSkill struct {
Name string `json:"name"`
}
type officialIndex struct {
Skills []officialSkill `json:"skills"`
}
var index officialIndex
if err := json.Unmarshal([]byte(text), &index); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, skill := range index.Skills {
candidate := strings.TrimSpace(skill.Name)
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
return sortedKeys(seen), nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -160,8 +186,7 @@ func parseOfficialSkillsList(lines []string) []string {
if len(parts) > 0 {
candidate := parts[0]
// Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
@@ -223,6 +248,7 @@ func PlanSync(input SyncInput) SyncPlan {
}
type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
@@ -258,14 +284,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 1: List official skills ---
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
@@ -316,7 +337,7 @@ func SyncSkills(opts SyncOptions) *SyncResult {
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
UpdatedAt: opts.Now().Format(skillsStateUpdatedAtLayout),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
@@ -327,6 +348,40 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
indexResult := runner.ListOfficialSkillsIndex()
if indexResult == nil || indexResult.Err != nil {
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
} else {
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
if err != nil {
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
} else if len(official) > 0 {
return official, "", true
} else {
reasons = append(reasons, "official skills index contains no skills")
}
}
officialResult := runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
return nil, strings.Join(reasons, "; "), false
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) > 0 {
return official, "", true
}
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
} else {
reasons = append(reasons, "official skills list returned no skills")
}
return nil, strings.Join(reasons, "; "), false
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
@@ -375,7 +430,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
UpdatedAt: opts.Now().Format(skillsStateUpdatedAtLayout),
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{

View File

@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
}
}
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
input := `Available Skills
│ lark-calendar
│ official-shared
│ bad/name
`
got := ParseSkillsList(input)
want := []string{"lark-calendar", "official-shared"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
@@ -110,6 +123,43 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
}
}
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
input := `{
"skills": [
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
{"name":" lark-base ","description":"Base","files":[]},
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
{"name":"","description":"empty","files":["SKILL.md"]}
]
}`
got, err := ParseOfficialSkillsIndexJSON(input)
if err != nil {
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
}
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
}
}
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`[{"name":"lark-calendar"}]`,
`{"name":"lark-calendar"}`,
`{"skills":[]}`,
`{"skills":[{"name":"bad skill"}]}`,
} {
got, err := ParseOfficialSkillsIndexJSON(input)
if err == nil && len(got) != 0 {
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialIndexOut string
officialOut string
globalJSONOut string
globalOut string
officialIndexErr error
officialErr error
globalJSONErr error
globalErr error
@@ -166,6 +218,8 @@ type fakeSkillsRunner struct {
installAllErr error
installed [][]string
installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
}
@@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
return b.String()
}
func officialSkillsIndexOutput(names ...string) string {
var b strings.Builder
b.WriteString(`{"skills":[`)
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
}
b.WriteString(`]}`)
return b.String()
}
func globalSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Global Skills\n\n")
@@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string {
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
f.listedIndex++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialIndexOut)
r.Err = f.officialIndexErr
return r
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
f.listedOfficial++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
@@ -255,14 +331,17 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
Now: func() time.Time {
return time.Date(2026, 5, 18, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
},
})
if result.Err != nil {
@@ -284,17 +363,127 @@ 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{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -322,8 +511,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -342,9 +532,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -367,9 +558,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -391,12 +583,19 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
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))
},
})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
@@ -406,6 +605,13 @@ 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) {
@@ -420,9 +626,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -445,11 +652,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -477,11 +685,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -510,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -527,8 +737,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -551,8 +762,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -576,11 +788,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -601,11 +814,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -625,8 +839,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -643,9 +858,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

@@ -16,9 +16,12 @@ import (
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/calendar/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/whiteboard/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

View File

@@ -17,9 +17,13 @@ import (
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/calendar/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/whiteboard/",
"shortcuts/im/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -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 a typed API wrapper (e.g. drive's driveCallAPI) or use
// Migrated code must call the domain's typed API wrapper 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 (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors",
})
}

View File

@@ -618,6 +618,35 @@ 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
@@ -662,7 +691,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/im/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
@@ -801,6 +830,26 @@ 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
@@ -851,14 +900,14 @@ func boom(runtime *common.RuntimeContext) error {
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
@@ -897,6 +946,9 @@ 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 {
@@ -946,7 +998,7 @@ func boom() {
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
src := `package contact
import "github.com/larksuite/cli/shortcuts/common"
@@ -954,7 +1006,7 @@ func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}

View File

@@ -12,6 +12,7 @@ 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"
@@ -198,3 +199,58 @@ 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)
}
}

View File

@@ -492,6 +492,28 @@ 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,
@@ -603,27 +625,6 @@ 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 {
@@ -703,6 +704,9 @@ 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):

View File

@@ -9,18 +9,6 @@ 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.

View File

@@ -194,6 +194,21 @@ 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

View File

@@ -162,7 +162,7 @@ func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []str
}
batch := missingIDs[i:end]
data, err := runtime.DoAPIJSON(http.MethodPost,
data, err := runtime.DoAPIJSONTyped(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.DoAPIJSON(http.MethodGet, apiURL, nil, nil)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, apiURL, nil, nil)
if err != nil {
break
}

View File

@@ -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 DoAPIJSON so the response envelope's code/msg are checked and surfaced
// Uses DoAPIJSONTyped 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.DoAPIJSON(http.MethodGet, mergeForwardMessagesPath(messageID), larkcore.QueryParams{
data, err := runtime.DoAPIJSONTyped(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
}
// DoAPIJSON returns the envelope's `data` field; when the server's JSON
// DoAPIJSONTyped 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

View File

@@ -156,7 +156,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
queries = append(queries, map[string]interface{}{"message_id": id})
}
data, err := runtime.DoAPIJSON(http.MethodPost,
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
"/open-apis/im/v1/messages/reactions/batch_query",
nil,
map[string]interface{}{"queries": queries},

View File

@@ -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.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", larkcore.QueryParams{
data, err := runtime.DoAPIJSONTyped(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)
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)
}
hasMore, _ := data["has_more"].(bool)
rawItems, _ := data["items"].([]interface{})

View File

@@ -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 "", output.ErrValidation("--message-id is required")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required").WithParam("--message-id")
}
if strings.HasPrefix(id, "omt_") {
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 "", 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 validateMessageID(id)
}
@@ -65,10 +65,10 @@ func buildMGetURL(ids []string) string {
func validateMessageID(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", output.ErrValidation("message ID cannot be empty")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id")
}
if !strings.HasPrefix(input, "om_") {
return "", output.ErrValidation("invalid message ID %q: must start with om_", input)
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id")
}
return input, nil
}
@@ -173,14 +173,16 @@ 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 string) (*http.Response, string, error) {
func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawURL, param string) (*http.Response, string, error) {
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
return nil, "", fmt.Errorf("blocked URL: %w", err)
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked URL: %v", err).
WithParam(param).
WithCause(err)
}
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", fmt.Errorf("http client: %w", err)
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
}
httpClient = validate.NewDownloadHTTPClient(httpClient, validate.DownloadHTTPClientOptions{
AllowHTTP: true,
@@ -188,17 +190,19 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", fmt.Errorf("invalid URL: %w", err)
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid URL: %v", err).
WithParam(param).
WithCause(err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("download failed: %w", err)
return nil, "", wrapIMNetworkErr(err, "download failed")
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
}
ext := filepath.Ext(fileNameFromURL(rawURL))
@@ -208,8 +212,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) (io.ReadCloser, string, error) {
resp, ext, err := startURLDownload(ctx, runtime, rawURL) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
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
if err != nil {
return nil, "", err
}
@@ -233,7 +237,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))
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, err
}
@@ -314,7 +318,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)
rc, _, err := downloadURLToReader(ctx, runtime, s.value, s.maxSize, s.flagName)
if err != nil {
return "", err
}
@@ -324,7 +328,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)
mb, err := newMediaBuffer(ctx, runtime, s.value, s.maxSize, s.flagName)
if err != nil {
return "", err
}
@@ -341,7 +345,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")
return uploadImageToIM(ctx, runtime, s.value, "message", s.flagName)
}
ft := detectIMFileType(s.value)
@@ -349,7 +353,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)
return uploadFileToIM(ctx, runtime, s.value, ft, dur, s.flagName)
}
// resolveVideoContent handles the video case which needs both a file_key and
@@ -370,7 +374,7 @@ func resolveVideoContent(ctx context.Context, runtime *common.RuntimeContext, vi
}
coverKey, err := resolveOneMedia(ctx, runtime, coverSpec)
if err != nil {
return "", "", fmt.Errorf("cover image upload failed: %w", err)
return "", "", wrapIMNetworkErr(err, "cover image upload failed")
}
jsonBytes, _ := json.Marshal(map[string]string{"file_key": fKey, "image_key": coverKey})
@@ -386,13 +390,13 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
jsonBytes, _ := json.Marshal(map[string]string{"text": fallbackText})
return "text", string(jsonBytes), nil
}
return "", "", fmt.Errorf("%s upload failed: %w", mediaType, uploadErr)
return "", "", wrapIMNetworkErr(uploadErr, "%s upload failed", mediaType)
}
// resolveP2PChatID resolves user open_id to P2P chat_id.
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
if runtime.IsBot() {
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
@@ -405,11 +409,10 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
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, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", err
}
data, _ := result["data"].(map[string]interface{})
chats, _ := data["p2p_chats"].([]interface{})
for _, item := range chats {
@@ -420,7 +423,7 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user")
}
// resolveThreadID normalizes a message ID to its thread ID when possible.
@@ -429,7 +432,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
return id, nil
}
if !messageIDRe.MatchString(id) {
return "", output.Errorf(output.ExitValidation, "validation", "invalid thread ID format: must start with om_ or omt_")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid thread ID format: must start with om_ or omt_").WithParam("--thread")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
@@ -439,11 +442,10 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
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, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", err
}
data, _ := result["data"].(map[string]interface{})
items, _ := data["items"].([]interface{})
for _, item := range items {
@@ -454,7 +456,7 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "thread ID not found for this message")
return "", errs.NewAPIError(errs.SubtypeNotFound, "thread ID not found for this message")
}
// parseOggOpusDuration parses the duration in milliseconds from an OGG/Opus
@@ -612,8 +614,8 @@ type mediaBuffer struct {
}
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (*mediaBuffer, error) {
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize)
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)
if err != nil {
return nil, err
}
@@ -621,7 +623,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
data, err := io.ReadAll(rc)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
return nil, wrapIMNetworkErr(err, "download failed")
}
return newMediaBufferFromBytes(data, ext, rawURL), nil
}
@@ -927,7 +929,7 @@ func resolveMarkdownImageURLs(ctx context.Context, runtime *common.RuntimeContex
}
imgURL := sub[1]
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize)
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize, "--markdown")
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
return ""
@@ -1049,14 +1051,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 string) (string, error) {
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType, param string) (string, error) {
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize {
return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size()))
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())).WithParam(param)
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", err
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
}
defer f.Close()
@@ -1073,27 +1075,25 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", err
}
data, _ := result["data"].(map[string]interface{})
imageKey, _ := data["image_key"].(string)
if imageKey == "" {
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response")
}
return imageKey, nil
}
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) {
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration, param string) (string, error) {
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize {
return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size()))
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())).WithParam(param)
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return "", err
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
}
defer f.Close()
@@ -1114,15 +1114,13 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", err
}
data, _ := result["data"].(map[string]interface{})
fileKey, _ := data["file_key"].(string)
if fileKey == "" {
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response")
}
return fileKey, nil
}
@@ -1142,15 +1140,13 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", err
}
data, _ := result["data"].(map[string]interface{})
imageKey, _ := data["image_key"].(string)
if imageKey == "" {
return "", fmt.Errorf("image_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "image_key missing from a successful upload response")
}
return imageKey, nil
}
@@ -1174,15 +1170,13 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
return "", err
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", fmt.Errorf("parse error: %w", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", err
}
data, _ := result["data"].(map[string]interface{})
fileKey, _ := data["file_key"].(string)
if fileKey == "" {
return "", fmt.Errorf("file_key not found in response (code: %v, msg: %v)", result["code"], result["msg"])
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "file_key missing from a successful upload response")
}
return fileKey, nil
}
@@ -1237,9 +1231,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 output.ErrWithHint(output.ExitAuth, "auth",
fmt.Sprintf("cannot verify required scope(s): %v", err),
flagScopeLoginHint(required))
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "cannot verify required scope(s): %v", err).
WithHint("%s", flagScopeLoginHint(required)).
WithCause(err)
}
if result == nil || result.Scopes == "" {
fmt.Fprintf(rt.IO().ErrOut,
@@ -1248,9 +1242,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req
return nil
}
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
flagScopeLoginHint(missing))
return errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scope(s): %s", strings.Join(missing, ", ")).
WithMissingScopes(missing...).
WithHint("%s", flagScopeLoginHint(missing))
}
return nil
}
@@ -1276,11 +1270,11 @@ func parseItemID(id string) (ItemType, FlagType, error) {
case strings.HasPrefix(id, "om_"):
return ItemTypeDefault, FlagTypeMessage, nil
case id == "":
return 0, 0, output.ErrValidation("--message-id cannot be empty")
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id cannot be empty").WithParam("--message-id")
default:
return 0, 0, output.ErrValidation(
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
"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)
"pass --item-type and --flag-type explicitly if you are using a different id format", id).WithParam("--message-id")
}
}
@@ -1294,7 +1288,7 @@ func parseItemType(s string) (ItemType, error) {
case "msg_thread":
return ItemTypeMsgThread, nil
}
return 0, output.ErrValidation("invalid --item-type %q: expected one of default|thread|msg_thread", s)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --item-type %q: expected one of default|thread|msg_thread", s).WithParam("--item-type")
}
// parseFlagType converts a user-facing string to the server enum.
@@ -1305,7 +1299,7 @@ func parseFlagType(s string) (FlagType, error) {
case "feed":
return FlagTypeFeed, nil
}
return 0, output.ErrValidation("invalid --flag-type %q: expected one of message|feed", s)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --flag-type %q: expected one of message|feed", s).WithParam("--flag-type")
}
// isValidCombo checks if the (ItemType, FlagType) pair is accepted by the server.
@@ -1363,24 +1357,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.DoAPIJSON("GET", "/open-apis/im/v1/messages/"+validate.EncodePathSegment(messageID), nil, nil)
data, err := rt.DoAPIJSONTyped("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 "", output.ErrValidation("message not found or unexpected API response format")
return "", errs.NewAPIError(errs.SubtypeNotFound, "message not found")
}
msg, ok := items[0].(map[string]any)
if !ok {
return "", output.ErrValidation("unexpected message format in API response")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "unexpected message format in API response")
}
chatID, ok := msg["chat_id"].(string)
if !ok {
return "", output.ErrValidation("message response missing chat_id field")
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "message response missing chat_id field")
}
return chatID, nil
}
@@ -1393,15 +1387,324 @@ 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.DoAPIJSON("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/chats/"+validate.EncodePathSegment(chatID), nil, nil)
if err != nil {
return ItemTypeDefault, fmt.Errorf("failed to query chat_mode for chat %s: %w", chatID, err)
return ItemTypeDefault, wrapIMNetworkErr(err, "failed to query chat_mode for chat %s", chatID)
}
// DoAPIJSON returns envelope.Data, so chat_mode is at the top level
// DoAPIJSONTyped 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
}

View File

@@ -8,6 +8,7 @@ import (
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -22,6 +23,7 @@ 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"
@@ -445,8 +447,15 @@ 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 err != context.Canceled {
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
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)
}
// First attempt is made, then retry checks ctx.Err() and returns
if attempts != 1 {
@@ -600,6 +609,14 @@ 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)
}
@@ -716,7 +733,7 @@ func TestUploadImageToIMSuccess(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}
got, err := uploadImageToIM(context.Background(), runtime, path, "message")
got, err := uploadImageToIM(context.Background(), runtime, path, "message", "--image")
if err != nil {
t.Fatalf("uploadImageToIM() error = %v", err)
}
@@ -754,7 +771,7 @@ func TestUploadFileToIMSuccess(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200")
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200", "--file")
if err != nil {
t.Fatalf("uploadFileToIM() error = %v", err)
}
@@ -784,10 +801,14 @@ 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")
_, err = uploadImageToIM(context.Background(), rt, path, "message", "--image")
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) {
@@ -805,13 +826,21 @@ 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", "")
_, err = uploadFileToIM(context.Background(), rt, path, "stream", "", "--file")
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)
}
}
func TestResolveMediaContentWrapsUploadError(t *testing.T) {
// 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) {
runtime := &common.RuntimeContext{
Factory: &cmdutil.Factory{
FileIOProvider: fileio.GetProvider(),
@@ -826,8 +855,49 @@ func TestResolveMediaContentWrapsUploadError(t *testing.T) {
missing := "missing.png"
_, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "")
if err == nil || !strings.Contains(err.Error(), "image upload failed") {
t.Fatalf("resolveMediaContent() error = %v", err)
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)
}
}
@@ -920,7 +990,7 @@ func TestUploadFileToIMPreservesLocalFileName(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", ""); err != nil {
if _, err := uploadFileToIM(context.Background(), runtime, "./"+localName, "pdf", "", "--file"); err != nil {
t.Fatalf("uploadFileToIM() error = %v", err)
}
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {

View File

@@ -606,6 +606,12 @@ 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)

View File

@@ -10,6 +10,7 @@ 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"
@@ -52,7 +53,7 @@ var ImChatCreate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Bool("set-bot-manager") && !runtime.IsBot() {
return output.ErrValidation("--set-bot-manager is only supported with bot identity (--as bot)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--set-bot-manager is only supported with bot identity (--as bot)").WithParam("--set-bot-manager")
}
name := runtime.Str("name")
@@ -60,25 +61,25 @@ var ImChatCreate = common.Shortcut{
// Public groups must have a name with at least 2 characters.
if chatType == "public" && len([]rune(name)) < 2 {
return output.ErrValidation("--name is required for public groups and must be at least 2 characters")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name is required for public groups and must be at least 2 characters").WithParam("--name")
}
// Group name length must not exceed 60 characters.
if len([]rune(name)) > 60 {
return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name)))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
}
// Description length must not exceed 100 characters.
if desc := runtime.Str("description"); len([]rune(desc)) > 100 {
return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc)))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
}
// Validate users.
if users := runtime.Str("users"); users != "" {
ids := common.SplitCSV(users)
if len(ids) > 50 {
return output.ErrValidation("--users exceeds the maximum of 50 (got %d)", len(ids))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--users exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--users")
}
for _, id := range ids {
if _, err := common.ValidateUserID(id); err != nil {
if _, err := common.ValidateUserIDTyped("--users", id); err != nil {
return err
}
}
@@ -88,18 +89,18 @@ var ImChatCreate = common.Shortcut{
if bots := runtime.Str("bots"); bots != "" {
ids := common.SplitCSV(bots)
if len(ids) > 5 {
return output.ErrValidation("--bots exceeds the maximum of 5 (got %d)", len(ids))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--bots exceeds the maximum of 5 (got %d)", len(ids)).WithParam("--bots")
}
for _, id := range ids {
if !strings.HasPrefix(id, "cli_") {
return output.ErrValidation("invalid bot id %q: expected app ID (cli_xxx)", id)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bot id %q: expected app ID (cli_xxx)", id).WithParam("--bots")
}
}
}
// Validate owner.
if owner := runtime.Str("owner"); owner != "" {
if _, err := common.ValidateUserID(owner); err != nil {
if _, err := common.ValidateUserIDTyped("--owner", owner); err != nil {
return err
}
}
@@ -112,7 +113,7 @@ var ImChatCreate = common.Shortcut{
if runtime.Bool("set-bot-manager") {
qp["set_bot_manager"] = []string{"true"}
}
resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats", qp, body)
if err != nil {
return err
}
@@ -127,7 +128,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.DoAPIJSON(http.MethodPost,
linkData, err := runtime.DoAPIJSONTyped(http.MethodPost,
fmt.Sprintf("/open-apis/im/v1/chats/%s/link", validate.EncodePathSegment(chatID)),
nil, nil)
if err == nil {

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -71,15 +72,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 output.ErrValidation("--page-size must be an integer between 1 and 100")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
return err
}
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
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 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 nil
},
@@ -95,7 +96,7 @@ var ImChatList = common.Shortcut{
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
params := buildChatListParams(runtime, effective)
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
if err != nil {
return err
}
@@ -211,10 +212,10 @@ func normalizeTypes(raw []string) ([]string, error) {
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p == "" {
return nil, output.ErrValidation("--types must contain at least one of p2p, group")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types must contain at least one of p2p, group").WithParam("--types")
}
if p != "p2p" && p != "group" {
return nil, output.ErrValidation("--types contains invalid value %q: expected one of p2p, group", p)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--types contains invalid value %q: expected one of p2p, group", p).WithParam("--types")
}
if _, dup := seen[p]; dup {
continue

View File

@@ -10,6 +10,7 @@ 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"
@@ -66,15 +67,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 common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id requires user identity (--as user); use --chat-id when calling with bot identity").WithParam("--user-id")
}
if runtime.Str("chat-id") == "" {
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --chat-id (bot identity does not support --user-id)").WithParam("--chat-id")
}
} else {
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --chat-id or --user-id")
}
return err
}
@@ -82,12 +83,12 @@ var ImChatMessageList = common.Shortcut{
// Validate ID formats
if chatFlag := runtime.Str("chat-id"); chatFlag != "" {
if _, err := common.ValidateChatID(chatFlag); err != nil {
if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
return err
}
}
if userFlag := runtime.Str("user-id"); userFlag != "" {
if _, err := common.ValidateUserID(userFlag); err != nil {
if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
return err
}
}
@@ -109,7 +110,7 @@ var ImChatMessageList = common.Shortcut{
return err
}
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
if err != nil {
return err
}
@@ -205,14 +206,14 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string)
if startFlag := runtime.Str("start"); startFlag != "" {
startTime, err := common.ParseTime(startFlag)
if err != nil {
return nil, output.ErrValidation("--start: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
params["start_time"] = []string{startTime}
}
if endFlag := runtime.Str("end"); endFlag != "" {
endTime, err := common.ParseTime(endFlag, "end")
if err != nil {
return nil, output.ErrValidation("--end: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
params["end_time"] = []string{endTime}
}
@@ -236,7 +237,7 @@ func resolveChatIDForMessagesList(runtime *common.RuntimeContext, dryRun bool) (
return "", err
}
if chatId == "" {
return "", output.Errorf(output.ExitAPI, "not_found", "P2P chat not found for this user")
return "", errs.NewAPIError(errs.SubtypeNotFound, "P2P chat not found for this user")
}
return chatId, nil
}

View File

@@ -10,6 +10,7 @@ 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"
@@ -53,10 +54,10 @@ var ImChatSearch = common.Shortcut{
query := runtime.Str("query")
memberIDs := runtime.Str("member-ids")
if query == "" && memberIDs == "" {
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\")")
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\")")
}
if query != "" && len([]rune(query)) > 64 {
return output.ErrValidation("--query exceeds the maximum of 64 characters (got %d)", len([]rune(query)))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
}
if st := runtime.Str("search-types"); st != "" {
allowed := map[string]struct{}{
@@ -67,23 +68,23 @@ var ImChatSearch = common.Shortcut{
}
for _, item := range common.SplitCSV(st) {
if _, ok := allowed[item]; !ok {
return output.ErrValidation("invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --search-types value %q: expected one of private, external, public_joined, public_not_joined", item).WithParam("--search-types")
}
}
}
if mi := runtime.Str("member-ids"); mi != "" {
ids := common.SplitCSV(mi)
if len(ids) > 50 {
return output.ErrValidation("--member-ids exceeds the maximum of 50 (got %d)", len(ids))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-ids exceeds the maximum of 50 (got %d)", len(ids)).WithParam("--member-ids")
}
for _, id := range ids {
if _, err := common.ValidateUserID(id); err != nil {
if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil {
return err
}
}
}
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return output.ErrValidation("--page-size must be an integer between 1 and 100")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
}
return nil
},
@@ -94,7 +95,7 @@ var ImChatSearch = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
resData, err := runtime.CallAPI("POST", "/open-apis/im/v2/chats/search", params, body)
resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
if err != nil {
return err
}

View File

@@ -9,7 +9,7 @@ import (
"io"
"net/http"
"github.com/larksuite/cli/internal/output"
"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"
@@ -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.ValidateChatID(chat); err != nil {
if _, err := common.ValidateChatIDTyped("--chat-id", chat); err != nil {
return err
}
// Validate --name length.
name := runtime.Str("name")
if name != "" && len([]rune(name)) > 60 {
return output.ErrValidation("--name exceeds the maximum of 60 characters (got %d)", len([]rune(name)))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds the maximum of 60 characters (got %d)", len([]rune(name))).WithParam("--name")
}
// Validate --description length.
if desc := runtime.Str("description"); desc != "" && len([]rune(desc)) > 100 {
return output.ErrValidation("--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc)))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--description exceeds the maximum of 100 characters (got %d)", len([]rune(desc))).WithParam("--description")
}
// At least one field must be provided for update.
body := buildUpdateChatBody(runtime)
if len(body) == 0 {
return output.ErrValidation("at least one field must be specified to update")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "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.DoAPIJSON(http.MethodPut,
_, err := runtime.DoAPIJSONTyped(http.MethodPut,
fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)),
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
body,

63
shortcuts/im/im_errors.go Normal file
View File

@@ -0,0 +1,63 @@
// 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)
}

View File

@@ -0,0 +1,82 @@
// 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")
}
}

View File

@@ -0,0 +1,713 @@
// 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)
}
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"fmt"
"io"
"strconv"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// feedGroupReadScope is required to read feed-group items.
feedGroupReadScope = "im:feed_group_v1:read"
// chatReadScope is required to resolve chat_name from feed_id via chats/batch_query.
chatReadScope = "im:chat:read"
)
// enrichFeedGroupItemsChatName resolves a human-readable chat_name for each feed
// card in data["items"] and data["deleted_items"] using chats/batch_query.
//
// The feed_id of a v1 feed card is always a chat ID (oc_xxx), so the chat's name
// is the natural display label. Resolution degrades gracefully: any feed_id that
// cannot be resolved simply keeps no chat_name key, and the function never returns
// an error or alters the exit code.
//
// NOTE: This mutates the item maps in place by adding a "chat_name" key.
func enrichFeedGroupItemsChatName(rt *common.RuntimeContext, data map[string]any) {
if data == nil {
return
}
items, _ := data["items"].([]any)
deletedItems, _ := data["deleted_items"].([]any)
// Collect deduped, ordered feed_id strings from both lists.
ids := make([]string, 0, len(items)+len(deletedItems))
seen := make(map[string]bool)
collect := func(list []any) {
for _, it := range list {
m, _ := it.(map[string]any)
if m == nil {
continue
}
id, _ := m["feed_id"].(string)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
}
collect(items)
collect(deletedItems)
if len(ids) == 0 {
return
}
contexts := batchQueryChatContexts(rt, ids)
if len(contexts) == 0 {
// We had feed_ids to resolve but got nothing back — most likely the
// chats/batch_query call failed (it degrades silently). Tell the user so
// an empty chat_name column is not mistaken for chats that simply have no name.
fmt.Fprintf(rt.IO().ErrOut, "warning: could not resolve chat names for %d feed(s); chat_name will be empty\n", len(ids))
return
}
apply := func(list []any) {
for _, it := range list {
m, _ := it.(map[string]any)
if m == nil {
continue
}
id, _ := m["feed_id"].(string)
if id == "" {
continue
}
if ctx, ok := contexts[id]; ok {
if name, _ := ctx["name"].(string); name != "" {
m["chat_name"] = name
}
}
}
}
apply(items)
apply(deletedItems)
}
// renderFeedGroupItemsTable prints the active items[] as a table (feed_id /
// chat_name / update_time), followed by a summary line. When hasMore is true a
// pagination hint is appended; when there are deleted items their count is noted.
func renderFeedGroupItemsTable(w io.Writer, data map[string]any, hasMore bool) {
items, _ := data["items"].([]any)
rows := make([]map[string]interface{}, 0, len(items))
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
chatName, _ := m["chat_name"].(string)
updateTime, _ := m["update_time"].(string)
feedID, _ := m["feed_id"].(string)
rows = append(rows, map[string]interface{}{
"feed_id": feedID,
"chat_name": chatName,
"update_time": formatFeedGroupUpdateTime(updateTime),
})
}
output.PrintTable(w, rows)
moreHint := ""
if hasMore {
moreHint = " (more available, use --page-token to fetch next page)"
}
fmt.Fprintf(w, "\n%d item(s)%s\n", len(items), moreHint)
if deleted, _ := data["deleted_items"].([]any); len(deleted) > 0 {
fmt.Fprintf(w, "(%d deleted)\n", len(deleted))
}
}
// formatFeedGroupUpdateTime renders a Unix-millisecond timestamp string as a
// human-readable local time for the pretty table. The raw value is returned
// unchanged when it is empty or not a valid millisecond integer, so JSON output
// (which never calls this) keeps the original wire value.
func formatFeedGroupUpdateTime(raw string) string {
if raw == "" {
return raw
}
ms, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return raw
}
return time.UnixMilli(ms).Local().Format(time.RFC3339)
}

View File

@@ -0,0 +1,234 @@
// 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))
}
}

View File

@@ -0,0 +1,207 @@
// 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
}

View File

@@ -0,0 +1,257 @@
// 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")
}
})
}
}

View File

@@ -0,0 +1,90 @@
// 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
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedShortcutCreate provides the +feed-shortcut-create shortcut for adding
// chats to the user's feed shortcuts. Currently only CHAT-type shortcuts are
// exposed by the OpenAPI gateway; feed_card_id must be an open_chat_id
// (oc_xxx).
var ImFeedShortcutCreate = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-create",
Description: "Add chats to the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; --head/--tail controls insertion order",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// chat-id is mandatory but intentionally not cobra-Required: the
// requiredness check lives in collectChatIDs so a missing flag is
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to add as a feed shortcut (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
{Name: "head", Type: "bool",
Desc: "insert at the top of the shortcut list (default); mutually exclusive with --tail"},
{Name: "tail", Type: "bool",
Desc: "append at the bottom of the shortcut list; mutually exclusive with --head"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := collectChatIDs(runtime); err != nil {
return err
}
_, err := resolveIsHeader(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := collectChatIDs(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
isHeader, err := resolveIsHeader(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/im/v2/feed_shortcuts").
Body(map[string]any{
"shortcuts": buildShortcutItems(ids),
"is_header": isHeader,
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := collectChatIDs(runtime)
if err != nil {
return err
}
isHeader, err := resolveIsHeader(runtime)
if err != nil {
return err
}
items := buildShortcutItems(ids)
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts", nil,
map[string]any{
"shortcuts": items,
"is_header": isHeader,
})
if err != nil {
return err
}
return emitFeedShortcutWriteResult(runtime, items, data)
},
}
// resolveIsHeader determines the insertion position.
// - default (neither flag set) → true (head)
// - --head → true
// - --tail → false
// - both set → error
func resolveIsHeader(rt *common.RuntimeContext) (bool, error) {
head := rt.Bool("head")
tail := rt.Bool("tail")
if head && tail {
return false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--head and --tail are mutually exclusive")
}
if tail {
return false, nil
}
return true, nil
}

View File

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

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedShortcutRemove provides the +feed-shortcut-remove shortcut for
// removing chats from the user's feed shortcuts. Per-item failures are kept
// in stdout and returned as a partial-failure exit.
var ImFeedShortcutRemove = common.Shortcut{
Service: "im",
Command: "+feed-shortcut-remove",
Description: "Remove chats from the user's feed shortcuts; user-only; batch up to 10 chat IDs per call; per-item failures return ok:false with failed_shortcuts",
Risk: "write",
UserScopes: []string{feedShortcutWriteScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
// chat-id is mandatory but intentionally not cobra-Required: the
// requiredness check lives in collectChatIDs so a missing flag is
// reported through the structured validation envelope (exit 2)
// instead of cobra's plain-text error.
{Name: "chat-id", Type: "string_slice",
Desc: "open_chat_id to remove from feed shortcuts (oc_xxx); required; repeat the flag or pass comma-separated; max 10 per call"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := collectChatIDs(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := collectChatIDs(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/im/v2/feed_shortcuts/remove").
Body(map[string]any{"shortcuts": buildShortcutItems(ids)})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := collectChatIDs(runtime)
if err != nil {
return err
}
items := buildShortcutItems(ids)
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v2/feed_shortcuts/remove", nil,
map[string]any{"shortcuts": items})
if err != nil {
return err
}
return emitFeedShortcutWriteResult(runtime, items, data)
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,21 +8,20 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
var ImFlagCancel = common.Shortcut{
Service: "im",
Command: "+flag-cancel",
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
"performs double-cancel: removes both message and feed layers",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Service: "im",
Command: "+flag-cancel",
Description: "Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)"},
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
@@ -63,11 +62,9 @@ var ImFlagCancel = common.Shortcut{
"item_type": itemType,
"flag_type": flagType,
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags/cancel", nil,
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags/cancel", nil,
map[string]any{"flag_items": []flagItem{item}})
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
itemType, flagType, err)
result["status"] = "failed"
result["error"] = err.Error()
lastErr = err
@@ -78,8 +75,12 @@ var ImFlagCancel = common.Shortcut{
results = append(results, result)
}
runtime.Out(map[string]any{"results": results}, nil)
return lastErr
payload := map[string]any{"results": results}
if lastErr != nil {
return runtime.OutPartialFailure(payload, nil)
}
runtime.Out(payload, nil)
return nil
},
}
@@ -203,20 +204,20 @@ func buildSingleCancelItem(id, itOverride, ftOverride string) (flagItem, error)
// Provide more specific hints for common mistakes
if itOverride != "" && ftOverride == "" {
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
return flagItem{}, output.ErrValidation(
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid combination: --item-type=%s requires --flag-type=feed (feed-layer flags are the only valid type for threads)",
itOverride)
itOverride).WithParam("--item-type")
}
return flagItem{}, output.ErrValidation(
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid combination: --item-type=%s with inferred --flag-type=%s; specify --flag-type explicitly to override",
itOverride, flagTypeString(flagType))
itOverride, flagTypeString(flagType)).WithParam("--item-type")
}
if itOverride == "" && ftOverride != "" {
return flagItem{}, output.ErrValidation(
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid combination: --flag-type=%s with inferred --item-type=%s; specify --item-type explicitly to override",
ftOverride, itemTypeString(itemType))
ftOverride, itemTypeString(itemType)).WithParam("--flag-type")
}
return flagItem{}, output.ErrValidation(
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
}
return newFlagItem(id, itemType, flagType), nil

View File

@@ -8,7 +8,7 @@ import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -16,7 +16,7 @@ import (
var ImFlagCreate = common.Shortcut{
Service: "im",
Command: "+flag-create",
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)",
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode)",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
@@ -50,12 +50,14 @@ var ImFlagCreate = common.Shortcut{
}
// Combo validation already done in Validate, but double-check as a safety net.
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
return output.ErrValidation(
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
"(default, message), (thread, feed), or (msg_thread, feed)",
item.ItemType, item.FlagType)
item.ItemType, item.FlagType).WithParams(
errs.InvalidParam{Name: "--item-type", Reason: "unsupported with the given --flag-type"},
errs.InvalidParam{Name: "--flag-type", Reason: "unsupported with the given --item-type"})
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/im/v1/flags", nil,
data, err := runtime.DoAPIJSONTyped("POST", "/open-apis/im/v1/flags", nil,
map[string]any{"flag_items": []flagItem{item}})
if err != nil {
return err
@@ -138,18 +140,16 @@ func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
chatID, err := getMessageChatID(rt, id)
if err != nil {
return flagItem{}, output.ErrValidation(
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly")
}
if chatID == "" {
return flagItem{}, output.ErrValidation(
return flagItem{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"message does not belong to a chat; feed-layer flags are only for messages in chats")
}
feedIT, err := resolveThreadFeedItemType(rt, chatID)
if err != nil {
return flagItem{}, output.ErrValidation(
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly")
}
return newFlagItem(id, feedIT, FlagTypeFeed), nil
}
@@ -186,18 +186,24 @@ func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, e
if combo.ItemTypeSet && !combo.FlagTypeSet {
switch combo.ItemType {
case ItemTypeThread, ItemTypeMsgThread:
return explicitFlagCombo{}, output.ErrValidation(
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride).WithParams(
errs.InvalidParam{Name: "--item-type", Reason: "requires --flag-type=feed"},
errs.InvalidParam{Name: "--flag-type", Reason: "must be feed for this --item-type"})
case ItemTypeDefault:
return explicitFlagCombo{}, output.ErrValidation(
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--item-type=default requires --flag-type=message; or omit both to use default behavior").WithParams(
errs.InvalidParam{Name: "--item-type", Reason: "default requires --flag-type=message"},
errs.InvalidParam{Name: "--flag-type", Reason: "must be message for --item-type=default"})
}
}
if combo.ItemTypeSet && combo.FlagTypeSet && !isValidCombo(combo.ItemType, combo.FlagType) {
return explicitFlagCombo{}, output.ErrValidation(
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --item-type=%s --flag-type=%s combination; supported pairs are default+message, thread+feed, and msg_thread+feed",
itOverride, ftOverride)
itOverride, ftOverride).WithParams(
errs.InvalidParam{Name: "--item-type", Reason: "unsupported pairing"},
errs.InvalidParam{Name: "--flag-type", Reason: "unsupported pairing"})
}
return combo, nil

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"strconv"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -56,7 +56,7 @@ var ImFlagList = common.Shortcut{
return executeListAllPages(runtime)
}
data, err := runtime.DoAPIJSON("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
data, err := runtime.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags", listQuery(runtime), nil)
if err != nil {
return err
}
@@ -72,10 +72,10 @@ var ImFlagList = common.Shortcut{
func validateListOptions(rt *common.RuntimeContext) error {
if n := rt.Int("page-size"); n < 1 || n > 50 {
return output.ErrValidation("--page-size must be an integer between 1 and 50")
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 output.ErrValidation("--page-limit must be an integer between 1 and 1000")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
return nil
}
@@ -159,7 +159,7 @@ func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error
end = len(ids)
}
batch := ids[i:end]
got, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/messages/mget",
got, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/messages/mget",
larkcore.QueryParams{"message_ids": batch}, nil)
if err != nil {
return err
@@ -244,7 +244,7 @@ func executeListAllPages(rt *common.RuntimeContext) error {
if page > 0 {
token = lastPageToken
}
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags",
larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{token},

View File

@@ -15,6 +15,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -593,18 +594,18 @@ func TestCheckFlagRequiredScopesReportsTokenResolutionError(t *testing.T) {
setRuntimeTokenError(t, rt, errors.New("token cache unavailable"))
err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want ExitError", err, err)
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want *errs.AuthenticationError", err, err)
}
if exitErr.Code != output.ExitAuth || exitErr.Detail.Type != "auth" {
t.Fatalf("checkFlagRequiredScopes() detail = %+v code=%d, want auth exit", exitErr.Detail, exitErr.Code)
if authErr.Subtype != errs.SubtypeTokenMissing {
t.Fatalf("checkFlagRequiredScopes() subtype = %q, want %q", authErr.Subtype, errs.SubtypeTokenMissing)
}
if !strings.Contains(exitErr.Detail.Message, "cannot verify required scope") {
t.Fatalf("message = %q, want scope verification context", exitErr.Detail.Message)
if !strings.Contains(authErr.Message, "cannot verify required scope") {
t.Fatalf("message = %q, want scope verification context", authErr.Message)
}
if !strings.Contains(exitErr.Detail.Hint, strings.Join(flagMessageReadScopes, " ")) {
t.Fatalf("hint = %q, want required scopes", exitErr.Detail.Hint)
if !strings.Contains(authErr.Hint, strings.Join(flagMessageReadScopes, " ")) {
t.Fatalf("hint = %q, want required scopes", authErr.Hint)
}
}
@@ -1337,6 +1338,10 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
if err == nil {
t.Fatalf("Execute() expected partial failure error, got nil")
}
var partialErr *output.PartialFailureError
if !errors.As(err, &partialErr) {
t.Fatalf("Execute() error = %T, want *output.PartialFailureError", err)
}
out := rt.Factory.IOStreams.Out.(*bytes.Buffer).String()
for _, want := range []string{`"results"`, `"item_type": "default"`, `"flag_type": "message"`, `"status": "ok"`, `"item_type": "msg_thread"`, `"flag_type": "feed"`, `"status": "failed"`, "feed failed"} {
@@ -1346,6 +1351,7 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
Results []map[string]any `json:"results"`
} `json:"data"`
@@ -1356,6 +1362,12 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
if len(envelope.Data.Results) != 2 {
t.Fatalf("results len = %d, want 2", len(envelope.Data.Results))
}
if envelope.OK {
t.Fatalf("stdout ok = true, want false for partial failure")
}
if errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String(); errOut != "" {
t.Fatalf("stderr = %q, want empty for partial failure result envelope", errOut)
}
}
func TestBuildCancelItems_OnlyItemTypeOverride(t *testing.T) {

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"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"
@@ -42,10 +43,10 @@ var ImMessagesMGet = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
if len(ids) == 0 {
return output.ErrValidation("--message-ids is required (comma-separated om_xxx)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids")
}
if len(ids) > maxMGetMessageIDs {
return output.ErrValidation("--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids")
}
for _, id := range ids {
if _, err := validateMessageID(id); err != nil {
@@ -58,7 +59,7 @@ var ImMessagesMGet = common.Shortcut{
ids := common.SplitCSV(runtime.Str("message-ids"))
mgetURL := buildMGetURL(ids)
data, err := runtime.DoAPIJSON(http.MethodGet, mgetURL, nil, nil)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil)
if err != nil {
return err
}

View File

@@ -9,7 +9,7 @@ import (
"fmt"
"net/http"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -102,20 +102,20 @@ var ImMessagesReply = common.Shortcut{
}
if messageId == "" {
return output.ErrValidation("--message-id is required (om_xxx)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
}
if _, err := validateMessageID(messageId); err != nil {
return err
}
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
return output.ErrValidation(msg)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
}
if content != "" && !json.Valid([]byte(content)) {
return output.ErrValidation("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
}
if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" {
return output.ErrValidation(msg)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).WithParam("--msg-type")
}
return nil
@@ -167,7 +167,7 @@ var ImMessagesReply = common.Shortcut{
data["uuid"] = idempotencyKey
}
resData, err := runtime.DoAPIJSON(http.MethodPost,
resData, err := runtime.DoAPIJSONTyped(http.MethodPost,
fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", validate.EncodePathSegment(messageId)),
nil, data)
if err != nil {

View File

@@ -14,9 +14,9 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -48,16 +48,16 @@ var ImMessagesResourcesDownload = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if messageId := runtime.Str("message-id"); messageId == "" {
return output.ErrValidation("--message-id is required (om_xxx)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
} else if _, err := validateMessageID(messageId); err != nil {
return err
}
relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
if err != nil {
return output.ErrValidation("%s", err)
return err
}
if _, err := runtime.ResolveSavePath(relPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
return nil
},
@@ -67,10 +67,10 @@ var ImMessagesResourcesDownload = common.Shortcut{
fileType := runtime.Str("type")
relPath, err := normalizeDownloadOutputPath(fileKey, runtime.Str("output"))
if err != nil {
return output.ErrValidation("invalid output path: %s", err)
return err
}
if _, err := runtime.ResolveSavePath(relPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
}
userSpecifiedOutput := runtime.Str("output") != ""
@@ -87,23 +87,23 @@ var ImMessagesResourcesDownload = common.Shortcut{
func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
fileKey = strings.TrimSpace(fileKey)
if fileKey == "" {
return "", fmt.Errorf("file-key cannot be empty")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file-key cannot be empty").WithParam("--file-key")
}
if strings.ContainsAny(fileKey, "/\\") {
return "", fmt.Errorf("file-key cannot contain path separators")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "file-key cannot contain path separators").WithParam("--file-key")
}
if outputPath == "" {
return fileKey, nil
}
outputPath = filepath.Clean(strings.TrimSpace(outputPath))
if outputPath == "." {
return "", fmt.Errorf("path cannot be empty")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "path cannot be empty").WithParam("--output")
}
if filepath.IsAbs(outputPath) {
return "", fmt.Errorf("absolute paths are not allowed")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "absolute paths are not allowed").WithParam("--output")
}
if outputPath == ".." || strings.HasPrefix(outputPath, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path cannot escape the current working directory")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "path cannot escape the current working directory").WithParam("--output")
}
return outputPath, nil
}
@@ -192,7 +192,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
return 0, closeErr
}
}
return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
}
switch err {
@@ -222,7 +222,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
if r.delivered == r.totalSize {
return 0, io.EOF
}
return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
}
end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1)
@@ -238,7 +238,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
}
if resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode)
return 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "unexpected status code: %d", resp.StatusCode)
}
r.current = resp.Body
@@ -270,7 +270,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
return "", 0, err
}
if downloadResp == nil {
return "", 0, output.ErrNetwork("download failed: empty response")
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: empty response")
}
if downloadResp.StatusCode >= 400 {
@@ -289,7 +289,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
if err != nil {
downloadResp.Body.Close()
return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err)
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid Content-Range header on range response: %s", err)
}
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
sizeBytes = totalSize
@@ -300,7 +300,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
default:
downloadResp.Body.Close()
return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode)
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "unexpected status code: %d", downloadResp.StatusCode)
}
defer body.Close()
@@ -309,10 +309,10 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
ContentLength: sizeBytes,
}, body)
if err != nil {
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
return "", 0, common.WrapSaveErrorTyped(err)
}
if sizeBytes >= 0 && result.Size() != sizeBytes {
return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size())
return "", 0, errs.NewNetworkError(errs.SubtypeNetworkTransport, "file size mismatch: expected %d, got %d", sizeBytes, result.Size())
}
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
if resolveErr != nil || savedPath == "" {
@@ -404,7 +404,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon
return resp, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
return nil, imContextError(ctx.Err())
}
lastErr = err
if attempt == imDownloadRequestRetries {
@@ -415,7 +415,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon
if lastErr != nil {
return nil, lastErr
}
return nil, output.ErrNetwork("download request failed")
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download request failed")
}
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
@@ -431,37 +431,37 @@ func sleepIMDownloadRetry(ctx context.Context, attempt int) {
func downloadResponseError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if len(body) > 0 {
return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
}
func parseTotalSize(contentRange string) (int64, error) {
contentRange = strings.TrimSpace(contentRange)
if contentRange == "" {
return 0, fmt.Errorf("content-range is empty")
return 0, fmt.Errorf("content-range is empty") //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
if !strings.HasPrefix(contentRange, "bytes ") {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
if parts[0] == "*" {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
return 0, fmt.Errorf("unsupported content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
if parts[1] == "*" {
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange)
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("parse total size: %w", err)
return 0, fmt.Errorf("parse total size: %w", err) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
if totalSize <= 0 {
return 0, fmt.Errorf("invalid total size: %d", totalSize)
return 0, fmt.Errorf("invalid total size: %d", totalSize) //nolint:forbidigo // intermediate Content-Range parse; caller wraps it as a typed network error
}
return totalSize, nil
}

View File

@@ -10,6 +10,7 @@ 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"
@@ -22,7 +23,6 @@ const (
messagesSearchDefaultPageLimit = 20
messagesSearchMaxPageLimit = 40
messagesSearchMGetBatchSize = 50
messagesSearchChatBatchSize = 50
)
var ImMessagesSearch = common.Shortcut{
@@ -269,7 +269,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
pageLimit := runtime.Int("page-limit")
if pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit {
return nil, output.ErrValidation("--page-limit must be an integer between 1 and 40")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 40").WithParam("--page-limit")
}
}
@@ -279,7 +279,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
if startFlag != "" {
ts, err := common.ParseTime(startFlag)
if err != nil {
return nil, output.ErrValidation("--start: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startTs = ts
start := startFlag
@@ -288,7 +288,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
if endFlag != "" {
ts, err := common.ParseTime(endFlag, "end")
if err != nil {
return nil, output.ErrValidation("--end: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endTs = ts
end := endFlag
@@ -298,7 +298,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
sv, _ := strconv.ParseInt(startTs, 10, 64)
ev, _ := strconv.ParseInt(endTs, 10, 64)
if sv > ev {
return nil, output.ErrValidation("--start cannot be later than --end")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start cannot be later than --end")
}
}
if len(timeRange) > 0 {
@@ -307,12 +307,12 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
if senderTypeFlag != "" && excludeSenderTypeFlag != "" {
if senderTypeFlag == excludeSenderTypeFlag {
return nil, output.ErrValidation("--sender-type and --exclude-sender-type cannot be the same value")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--sender-type and --exclude-sender-type cannot be the same value")
}
}
if chatFlag != "" {
for _, chatID := range common.SplitCSV(chatFlag) {
if _, err := common.ValidateChatID(chatID); err != nil {
if _, err := common.ValidateChatIDTyped("--chat-id", chatID); err != nil {
return nil, err
}
}
@@ -320,7 +320,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
}
if senderFlag != "" {
for _, userID := range common.SplitCSV(senderFlag) {
if _, err := common.ValidateUserID(userID); err != nil {
if _, err := common.ValidateUserIDTyped("--sender", userID); err != nil {
return nil, err
}
}
@@ -344,7 +344,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
if atChatterIdsFlag != "" {
ids := common.SplitCSV(atChatterIdsFlag)
for _, id := range ids {
if _, err := common.ValidateUserID(id); err != nil {
if _, err := common.ValidateUserIDTyped("--at-chatter-ids", id); err != nil {
return nil, err
}
}
@@ -358,7 +358,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
pageSize := runtime.Int("page-size")
if pageSize < 1 {
return nil, output.ErrValidation("--page-size must be an integer between 1 and 50")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
}
if pageSize > messagesSearchMaxPageSize {
pageSize = messagesSearchMaxPageSize
@@ -421,7 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
params["page_token"] = []string{pageToken}
}
searchData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
if err != nil {
return nil, false, "", false, pageLimit, err
}
@@ -447,7 +447,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
var items []interface{}
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
mgetData, err := runtime.DoAPIJSON(http.MethodGet, buildMGetURL(batch), nil, nil)
mgetData, err := runtime.DoAPIJSONTyped(http.MethodGet, buildMGetURL(batch), nil, nil)
if err != nil {
return nil, err
}
@@ -459,23 +459,9 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) {
chatRes, chatErr := runtime.DoAPIJSON(
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
map[string]interface{}{"chat_ids": batch},
)
if chatErr != nil {
continue
}
if chatItems, ok := chatRes["items"].([]interface{}); ok {
for _, ci := range chatItems {
cm, _ := ci.(map[string]interface{})
if cid, _ := cm["chat_id"].(string); cid != "" {
chatContexts[cid] = cm
}
}
}
// Best-effort: a failed chunk only loses its own entries.
for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) {
_ = queryChatBatch(runtime, batch, chatContexts)
}
return chatContexts
}

View File

@@ -10,8 +10,8 @@ import (
"os"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -113,30 +113,30 @@ var ImMessagesSend = common.Shortcut{
}
}
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
return err
}
// Validate ID formats
if chatFlag != "" {
if _, err := common.ValidateChatID(chatFlag); err != nil {
if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
return err
}
}
if userFlag != "" {
if _, err := common.ValidateUserID(userFlag); err != nil {
if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
return err
}
}
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
return common.FlagErrorf(msg)
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg)
}
if content != "" && !json.Valid([]byte(content)) {
return common.FlagErrorf("--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '{\"text\":\"hello\"}' or --text 'hello'", content).WithParam("--content")
}
if msg := validateExplicitMsgType(runtime.Cmd, msgType, text, markdown, imageKey, fileKey, videoKey, audioKey); msg != "" {
return common.FlagErrorf(msg)
return errs.NewValidationError(errs.SubtypeInvalidArgument, msg).WithParam("--msg-type")
}
return nil
@@ -193,7 +193,7 @@ var ImMessagesSend = common.Shortcut{
data["uuid"] = idempotencyKey
}
resData, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/im/v1/messages",
resData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages",
larkcore.QueryParams{"receive_id_type": []string{receiveIdType}}, data)
if err != nil {
return err
@@ -220,7 +220,7 @@ func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error {
return nil
}
if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) {
return output.ErrValidation("%s: %v", flagName, err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", flagName, err).WithParam(flagName)
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"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"
@@ -46,7 +47,7 @@ var ImThreadsMessagesList = common.Shortcut{
sortType = "ByCreateTimeDesc"
}
pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
d := common.NewDryRunAPI()
containerID := threadFlag
@@ -79,12 +80,12 @@ var ImThreadsMessagesList = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
threadId := runtime.Str("thread")
if threadId == "" {
return output.ErrValidation("--thread is required (om_xxx or omt_xxx)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread")
}
if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") {
return output.ErrValidation("invalid --thread %q: must start with om_ or omt_", threadId)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread")
}
_, err := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
_, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -100,7 +101,7 @@ var ImThreadsMessagesList = common.Shortcut{
sortType = "ByCreateTimeDesc"
}
pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
params := map[string][]string{
"container_id_type": []string{"thread"},
@@ -113,7 +114,7 @@ var ImThreadsMessagesList = common.Shortcut{
params["page_token"] = []string{pageToken}
}
data, err := runtime.DoAPIJSON(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
if err != nil {
return err
}

View File

@@ -16,7 +16,7 @@ package im
import (
"fmt"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -240,14 +240,14 @@ func FetchMuteStatus(runtime *common.RuntimeContext, chatIDs []string) (map[stri
return map[string]bool{}, nil, nil
}
if len(chatIDs) > MaxMuteStatusBatchSize {
return nil, nil, output.ErrValidation(
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
MaxMuteStatusBatchSize, len(chatIDs))
}
body := BuildBatchGetMuteStatusBody(chatIDs)
resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body)
resp, err := runtime.CallAPITyped("POST", BatchGetMuteStatusPath, nil, body)
if err != nil {
return nil, nil, fmt.Errorf("fetch mute status: %w", err)
return nil, nil, wrapIMNetworkErr(err, "fetch mute status")
}
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
return muted, unknown, nil

View File

@@ -22,5 +22,11 @@ func Shortcuts() []common.Shortcut {
ImFlagCreate,
ImFlagCancel,
ImFlagList,
ImFeedShortcutCreate,
ImFeedShortcutRemove,
ImFeedShortcutList,
ImFeedGroupList,
ImFeedGroupListItem,
ImFeedGroupQueryItem,
}
}

View File

@@ -11,8 +11,8 @@ import (
"strconv"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
@@ -30,10 +30,10 @@ var OKRCycleDetail = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if cycleID == "" {
return common.FlagErrorf("--cycle-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--cycle-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
return nil
},
@@ -52,8 +52,7 @@ var OKRCycleDetail = common.Shortcut{
cycleID := runtime.Str("cycle-id")
// Paginate objectives under the cycle.
queryParams := make(larkcore.QueryParams)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{"page_size": "100"}
var objectives []Objective
page := 0
@@ -71,7 +70,7 @@ var OKRCycleDetail = common.Shortcut{
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return err
}
@@ -93,7 +92,7 @@ var OKRCycleDetail = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}
// For each objective, paginate key results and convert to response format.
@@ -104,8 +103,7 @@ var OKRCycleDetail = common.Shortcut{
}
obj := &objectives[i]
krQuery := make(larkcore.QueryParams)
krQuery.Set("page_size", "100")
krQuery := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
krPage := 0
@@ -123,7 +121,7 @@ var OKRCycleDetail = common.Shortcut{
krPage++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
if err != nil {
return err
}
@@ -145,7 +143,7 @@ var OKRCycleDetail = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
krQuery.Set("page_token", pageToken)
krQuery["page_token"] = pageToken
}
respObj := obj.ToResp()

View File

@@ -6,11 +6,13 @@ package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -106,6 +108,31 @@ func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
}
}
// TestCycleDetailValidate_TypedError locks the typed-envelope contract shared by
// every okr flag check: an invalid flag surfaces as *errs.ValidationError carrying
// SubtypeInvalidArgument and the offending --flag (readable via errors.As /
// errs.ProblemOf), and maps to the validation exit code rather than a legacy api error.
func TestCycleDetailValidate_TypedError(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--cycle-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--cycle-id")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf category = %v (ok=%v), want %q", p, ok, errs.CategoryValidation)
}
}
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))

View File

@@ -12,9 +12,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
@@ -22,20 +21,20 @@ import (
func parseTimeRange(s string) (start, end time.Time, err error) {
parts := strings.SplitN(s, "--", 2)
if len(parts) != 2 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range format %q, expected YYYY-MM--YYYY-MM", s).WithParam("--time-range")
}
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range start month %q: %v", parts[0], err).WithParam("--time-range").WithCause(err)
}
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range end month %q: %v", parts[1], err).WithParam("--time-range").WithCause(err)
}
// end is the last moment of the end month
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
if start.After(end) {
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range: start month %s is after end month %s", parts[0], parts[1]).WithParam("--time-range")
}
return start, end, nil
}
@@ -69,20 +68,20 @@ var OKRListCycles = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
userID := runtime.Str("user-id")
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
if err := common.RejectDangerousCharsTyped("--user-id", userID); err != nil {
return err
}
tr := runtime.Str("time-range")
if tr != "" {
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
if err := common.RejectDangerousCharsTyped("--time-range", tr); err != nil {
return err
}
if _, _, err := parseTimeRange(tr); err != nil {
return common.FlagErrorf("--time-range: %s", err)
return err
}
}
return nil
@@ -110,16 +109,17 @@ var OKRListCycles = common.Shortcut{
var err error
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
if err != nil {
return common.FlagErrorf("--time-range: %s", err)
return err
}
hasRange = true
}
// Paginated fetch of all cycles
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id", userID)
queryParams.Set("user_id_type", userIDType)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{
"user_id": userID,
"user_id_type": userIDType,
"page_size": "100",
}
var allCycles []Cycle
page := 0
@@ -136,7 +136,7 @@ var OKRListCycles = common.Shortcut{
}
page++
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
if err != nil {
return err
}
@@ -158,7 +158,7 @@ var OKRListCycles = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}
// Filter by time-range overlap

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// okrInputStatError maps a FileIO.Stat/Open error for input file validation to
// a typed validation error: path validation failures read as "unsafe file
// path", other errors as "cannot read file".
func okrInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
WithParam("--file").
WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).
WithParam("--file").
WithCause(err)
}
// wrapOkrNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving subtype / code / log_id from the runtime boundary) and only
// wraps a raw, unclassified error as a transport-level network error.
func wrapOkrNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
func TestOkrInputStatError(t *testing.T) {
if okrInputStatError(nil) != nil {
t.Fatal("nil error should map to nil")
}
var ve *errs.ValidationError
pathCause := errors.New("traversal")
pathErr := okrInputStatError(&fileio.PathValidationError{Err: pathCause})
if !errors.As(pathErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("path validation: got %T (%v)", pathErr, pathErr)
}
if ve.Param != "--file" {
t.Fatalf("path validation param = %q, want --file", ve.Param)
}
if !errors.Is(pathErr, fileio.ErrPathValidation) || !errors.Is(pathErr, pathCause) {
t.Fatal("path validation cause should be retained")
}
genericCause := errors.New("permission denied")
genericErr := okrInputStatError(genericCause)
if !errors.As(genericErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("generic: got %T (%v)", genericErr, genericErr)
}
if ve.Param != "--file" {
t.Fatalf("generic param = %q, want --file", ve.Param)
}
if !errors.Is(genericErr, genericCause) {
t.Fatal("generic cause should be retained")
}
}
func TestWrapOkrNetworkErr(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "already typed")
if got := wrapOkrNetworkErr(typed, "wrap %v", typed); got != error(typed) {
t.Fatalf("typed error must pass through unchanged, got %v", got)
}
raw := errors.New("dial tcp: i/o timeout")
got := wrapOkrNetworkErr(raw, "upload failed: %v", raw)
var ne *errs.NetworkError
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("raw error: got %T (%v)", got, got)
}
if !errors.Is(got, raw) {
t.Fatal("raw cause should be retained via WithCause")
}
}

View File

@@ -5,8 +5,6 @@ package okr
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strconv"
@@ -14,7 +12,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -43,24 +41,24 @@ var OKRUploadImage = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
filePath := runtime.Str("file")
if filePath == "" {
return common.FlagErrorf("--file is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
ext := strings.ToLower(filepath.Ext(filePath))
if !allowedImageExts[ext] {
return common.FlagErrorf("--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext).WithParam("--file")
}
targetID := runtime.Str("target-id")
if targetID == "" {
return common.FlagErrorf("--target-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--target-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
targetType := runtime.Str("target-type")
if _, ok := targetTypeAllowed[targetType]; !ok {
return common.FlagErrorf("--target-type must be one of: objective | key_result")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
}
return nil
},
@@ -87,12 +85,12 @@ var OKRUploadImage = common.Shortcut{
info, err := runtime.FileIO().Stat(filePath)
if err != nil {
return common.WrapInputStatError(err)
return okrInputStatError(err)
}
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return common.WrapInputStatError(err)
return okrInputStatError(err)
}
defer f.Close()
@@ -110,30 +108,22 @@ var OKRUploadImage = common.Shortcut{
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("upload failed: %v", err)
// The DoAPI boundary already returns typed errs.* (auth →
// AuthenticationError, transport → NetworkError, etc.); wrapOkrNetworkErr
// passes those through via ProblemOf and only wraps a still-untyped error.
return wrapOkrNetworkErr(err, "upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return err
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
url, _ := data["url"].(string)
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
url := common.GetString(data, "url")
runtime.Out(map[string]interface{}{
"file_token": fileToken,

View File

@@ -5,6 +5,7 @@ package okr
import (
"bytes"
"errors"
"mime"
"mime/multipart"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -360,6 +362,15 @@ func TestUploadImageExecute_APIError(t *testing.T) {
if err == nil {
t.Fatal("expected error for API failure")
}
// The upload boundary now classifies the Lark error code into a typed
// envelope carrying the numeric code, instead of a flat legacy error.
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not a typed errs.* envelope: %T (%v)", err, err)
}
if p.Code != 1001001 {
t.Errorf("Problem.Code = %d, want 1001001", p.Code)
}
}
func TestUploadImageExecute_FileNotFound(t *testing.T) {
@@ -407,6 +418,15 @@ func TestUploadImageExecute_NoFileTokenInResponse(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file_token in response")
}
// A 2xx response that omits the expected file_token is a malformed response,
// surfaced as a typed internal/invalid_response error.
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(err.Error(), "no file_token returned") {
t.Fatalf("unexpected error: %v", err)
}

View File

@@ -11,10 +11,9 @@ import (
"math"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// targetTypeAllowed values for --target-type flag
@@ -39,7 +38,7 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro
content := runtime.Str("content")
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
@@ -60,13 +59,13 @@ func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createPro
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
progressRate = &ProgressRateV1{Percent: &percent}
if s := runtime.Str("progress-status"); s != "" {
status, ok := ParseProgressStatus(s)
if !ok {
return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
progressRate.Status = int32Ptr(int32(status))
}
@@ -105,40 +104,40 @@ var OKRCreateProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if content == "" {
return common.FlagErrorf("--content is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if err := validate.RejectControlChars(content, "content"); err != nil {
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
// Validate content is valid JSON and can be parsed as ContentBlock
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
targetID := runtime.Str("target-id")
if targetID == "" {
return common.FlagErrorf("--target-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := validate.RejectControlChars(targetID, "target-id"); err != nil {
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--target-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
targetType := runtime.Str("target-type")
if _, ok := targetTypeAllowed[targetType]; !ok {
return common.FlagErrorf("--target-type must be one of: objective | key_result")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
}
if v := runtime.Str("source-title"); v != "" {
if err := validate.RejectControlChars(v, "source-title"); err != nil {
if err := common.RejectDangerousCharsTyped("--source-title", v); err != nil {
return err
}
}
if v := runtime.Str("source-url"); v != "" {
if err := validate.RejectControlChars(v, "source-url"); err != nil {
if err := common.RejectDangerousCharsTyped("--source-url", v); err != nil {
return err
}
}
@@ -146,21 +145,21 @@ var OKRCreateProgressRecord = common.Shortcut{
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
}
if v := runtime.Str("progress-status"); v != "" {
if _, ok := ParseProgressStatus(v); !ok {
return common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
if v := runtime.Str("progress-percent"); v == "" {
return common.FlagErrorf("--progress-percent must provided with --progress-status")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must provided with --progress-status").WithParam("--progress-percent")
}
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
@@ -202,10 +201,9 @@ var OKRCreateProgressRecord = common.Shortcut{
body["progress_rate"] = p.ProgressRate
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", p.UserIDType)
queryParams := map[string]interface{}{"user_id_type": p.UserIDType}
data, err := runtime.DoAPIJSON("POST", "/open-apis/okr/v1/progress_records/", queryParams, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/okr/v1/progress_records/", queryParams, body)
if err != nil {
return err
}

View File

@@ -9,8 +9,8 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRDeleteProgressRecord deletes a progress by ID.
@@ -28,10 +28,10 @@ var OKRDeleteProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
if progressID == "" {
return common.FlagErrorf("--progress-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
}
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--progress-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
}
return nil
},
@@ -46,7 +46,7 @@ var OKRDeleteProgressRecord = common.Shortcut{
progressID := runtime.Str("progress-id")
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID)
_, err := runtime.DoAPIJSON("DELETE", path, larkcore.QueryParams{}, nil)
_, err := runtime.CallAPITyped("DELETE", path, nil, nil)
if err != nil {
return err
}

View File

@@ -10,8 +10,8 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRGetProgressRecord gets a progress by ID.
@@ -30,14 +30,14 @@ var OKRGetProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
if progressID == "" {
return common.FlagErrorf("--progress-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
}
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--progress-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
@@ -56,11 +56,10 @@ var OKRGetProgressRecord = common.Shortcut{
progressID := runtime.Str("progress-id")
userIDType := runtime.Str("user-id-type")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", userIDType)
queryParams := map[string]interface{}{"user_id_type": userIDType}
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID)
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return err
}
@@ -93,11 +92,11 @@ var OKRGetProgressRecord = common.Shortcut{
func parseProgressRecord(data map[string]any) (*ProgressV1, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, err
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid progress response: marshal failed: %s", err).WithCause(err)
}
var record ProgressV1
if err := json.Unmarshal(raw, &record); err != nil {
return nil, err
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid progress response: unmarshal failed: %s", err).WithCause(err)
}
return &record, nil
}

View File

@@ -5,11 +5,13 @@ package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -190,3 +192,19 @@ func TestProgressGetExecute_APIError(t *testing.T) {
t.Fatal("expected error for API failure")
}
}
func TestParseProgressRecord_InvalidResponseTypedError(t *testing.T) {
_, err := parseProgressRecord(map[string]any{
"progress_rate": "not-an-object",
})
if err == nil {
t.Fatal("expected invalid response error")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error is not *errs.InternalError: %T (%v)", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -10,9 +10,8 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// OKRListProgress lists progress for an objective or key result.
@@ -33,28 +32,28 @@ var OKRListProgress = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
targetID := runtime.Str("target-id")
if targetID == "" {
return common.FlagErrorf("--target-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := validate.RejectControlChars(targetID, "target-id"); err != nil {
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--target-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
targetType := runtime.Str("target-type")
if _, ok := targetTypeAllowed[targetType]; !ok {
return common.FlagErrorf("--target-type must be one of: objective | key_result")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
deptIDType := runtime.Str("department-id-type")
if deptIDType != "department_id" && deptIDType != "open_department_id" {
return common.FlagErrorf("--department-id-type must be one of: department_id | open_department_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
}
return nil
},
@@ -89,10 +88,11 @@ var OKRListProgress = common.Shortcut{
userIDType := runtime.Str("user-id-type")
deptIDType := runtime.Str("department-id-type")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", userIDType)
queryParams.Set("department_id_type", deptIDType)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{
"user_id_type": userIDType,
"department_id_type": deptIDType,
"page_size": "100",
}
var apiPath string
switch targetType {
@@ -108,7 +108,7 @@ var OKRListProgress = common.Shortcut{
return err
}
data, err := runtime.DoAPIJSON("GET", apiPath, queryParams, nil)
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
if err != nil {
return err
}
@@ -130,7 +130,7 @@ var OKRListProgress = common.Shortcut{
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}
// Convert to response format

View File

@@ -11,9 +11,8 @@ import (
"math"
"strconv"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// updateProgressRecordParams holds the parsed parameters for updating a progress.
@@ -29,7 +28,7 @@ func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updatePro
content := runtime.Str("content")
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 := cb.ToV1()
@@ -37,13 +36,13 @@ func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updatePro
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
progressRate = &ProgressRateV1{Percent: &percent}
if s := runtime.Str("progress-status"); s != "" {
status, ok := ParseProgressStatus(s)
if !ok {
return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
progressRate.Status = int32Ptr(int32(status))
}
@@ -76,42 +75,42 @@ var OKRUpdateProgressRecord = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id")
if progressID == "" {
return common.FlagErrorf("--progress-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id is required").WithParam("--progress-id")
}
if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--progress-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-id must be a positive int64").WithParam("--progress-id")
}
content := runtime.Str("content")
if content == "" {
return common.FlagErrorf("--content is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if err := validate.RejectControlChars(content, "content"); err != nil {
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err
}
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
if v := runtime.Str("progress-percent"); v != "" {
percent, err := strconv.ParseFloat(v, 64)
if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 {
return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must be a number between -99999999999 and 99999999999").WithParam("--progress-percent")
}
}
if v := runtime.Str("progress-status"); v != "" {
if _, ok := ParseProgressStatus(v); !ok {
return common.FlagErrorf("--progress-status must be one of: normal | overdue | done")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-status must be one of: normal | overdue | done").WithParam("--progress-status")
}
if v := runtime.Str("progress-percent"); v == "" {
return common.FlagErrorf("--progress-percent must provided with --progress-status")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--progress-percent must provided with --progress-status").WithParam("--progress-percent")
}
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
@@ -146,11 +145,10 @@ var OKRUpdateProgressRecord = common.Shortcut{
body["progress_rate"] = p.ProgressRate
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", p.UserIDType)
queryParams := map[string]interface{}{"user_id_type": p.UserIDType}
path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", p.ProgressID)
data, err := runtime.DoAPIJSON("PUT", path, queryParams, body)
data, err := runtime.CallAPITyped("PUT", path, queryParams, body)
if err != nil {
return err
}

View File

@@ -13,9 +13,8 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
func inferTaskMemberType(id string) string {
@@ -107,7 +106,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
// Handle generic JSON payload if provided
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
}
}
@@ -143,7 +142,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
if dueStr := runtime.Str("due"); dueStr != "" {
dueObj, err := parseTaskTime(dueStr)
if err != nil {
return nil, output.ErrValidation("failed to parse due time: %v", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
}
body["due"] = dueObj
}
@@ -154,7 +153,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
summary, _ := body["summary"].(string)
if strings.TrimSpace(summary) == "" {
return nil, output.ErrValidation("task summary is required")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
}
return body, nil
@@ -194,27 +193,11 @@ var CreateTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskCreateBody(runtime)
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "create task")
return err
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response: %v", parseErr)
}
}
data, err := HandleTaskApiResult(result, err, "create task")
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, body)
if err != nil {
return err
}

View File

@@ -5,15 +5,13 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,7 +33,7 @@ var AssignTask = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate assign")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
}
return nil
},
@@ -62,28 +60,13 @@ var AssignTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
}
}
data, err := HandleTaskApiResult(result, err, "add task members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
if err != nil {
return err
}
@@ -92,21 +75,7 @@ var AssignTask = common.Shortcut{
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "assignee", "")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
}
}
data, err := HandleTaskApiResult(result, err, "remove task members")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
if err != nil {
return err
}

View File

@@ -4,14 +4,100 @@
package task
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
// TestAssignTask_RequiresAddOrRemove covers the Validate guard: neither --add
// nor --remove yields a typed validation error (exit 2) before any API call.
func TestAssignTask_RequiresAddOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
// TestAssignTask_MalformedResponse covers the Execute parse-response arm: a
// 200 with an unparseable body surfaces a typed internal invalid_response
// error (exit 5).
func TestAssignTask_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_members",
Status: 200,
RawBody: []byte("{not-json"),
})
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--add", "ou_user_1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
// TestAssignTask_MalformedResponse_RemoveArm covers the Execute remove-members
// parse arm: with only --remove set, the add arm is skipped and the
// remove_members POST returns a 200 with an unparseable body, which must
// surface a typed internal invalid_response error (exit 5).
func TestAssignTask_MalformedResponse_RemoveArm(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/remove_members",
Status: 200,
RawBody: []byte("{not-json"),
})
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--remove", "ou_user_1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
func TestBuildMembersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildMembersBody("u1, u2 , ", "assignee", "token1")

View File

@@ -5,8 +5,10 @@ package task
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
@@ -19,33 +21,25 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
data string
summary string
due string
wantCode int
wantType string
wantSubstr string
}{
{
name: "invalid JSON data returns ErrValidation",
name: "invalid JSON data returns validation error",
data: "not-json",
summary: "test",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "--data must be a valid JSON object",
},
{
name: "missing summary returns ErrValidation",
name: "missing summary returns validation error",
data: "",
summary: "",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "task summary is required",
},
{
name: "invalid due time returns ErrValidation",
name: "invalid due time returns validation error",
data: "",
summary: "test task",
due: "not-a-valid-time",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "failed to parse due time",
},
}
@@ -68,18 +62,22 @@ func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != tt.wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) returned !ok", err)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if exitErr.Detail.Type != tt.wantType {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), tt.wantSubstr) {
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
}
})
}
@@ -91,35 +89,27 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
data string
summary string
due string
wantCode int
wantType string
wantSubstr string
}{
{
name: "invalid JSON data returns ErrValidation",
name: "invalid JSON data returns validation error",
data: "not-json",
summary: "",
due: "",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "--data must be a valid JSON object",
},
{
name: "no fields to update returns ErrValidation",
name: "no fields to update returns validation error",
data: "",
summary: "",
due: "",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "no fields to update",
},
{
name: "invalid due time returns ErrValidation",
name: "invalid due time returns validation error",
data: "",
summary: "",
due: "not-a-valid-time",
wantCode: output.ExitValidation,
wantType: "validation",
wantSubstr: "failed to parse due time",
},
}
@@ -138,18 +128,22 @@ func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != tt.wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, tt.wantCode)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) returned !ok", err)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if exitErr.Detail.Type != tt.wantType {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, tt.wantType)
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), tt.wantSubstr) {
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
}
})
}

View File

@@ -5,13 +5,10 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -48,24 +45,9 @@ var CommentTask = common.Shortcut{
"resource_type": "task",
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/comments",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse comment response")
}
}
data, err := HandleTaskApiResult(result, err, "add task comment")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/comments", params, body)
if err != nil {
return err
}

View File

@@ -5,15 +5,12 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,28 +44,14 @@ var CompleteTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getResp, getErr := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
})
var getResult map[string]interface{}
if getErr == nil {
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse get response: %v", parseErr), "parse get response")
}
}
getData, getErr := HandleTaskApiResult(getResult, getErr, "get task")
if getErr != nil {
return getErr
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
@@ -80,21 +63,7 @@ var CompleteTask = common.Shortcut{
} else {
// 3. Complete the task
body := buildCompleteBody()
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPatch,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse complete response")
}
}
data, err = HandleTaskApiResult(result, err, "complete task")
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// wrapTaskNetworkErr 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 wrapTaskNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// taskInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → readMsg prefix (default "cannot read file")
//
// param names the input flag/path field that failed (for example "--file").
// Pass an optional readMsg to override the non-path-validation message prefix,
// mirroring the shared input-stat helper so call-site context is preserved.
func taskInputStatError(err error, param string, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
if param != "" {
validationErr = validationErr.WithParam(param)
}
return validationErr
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
if param != "" {
validationErr = validationErr.WithParam(param)
}
return validationErr
}

View File

@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
func TestTaskInputStatError(t *testing.T) {
t.Run("nil error returns nil", func(t *testing.T) {
if err := taskInputStatError(nil, "--file"); err != nil {
t.Errorf("taskInputStatError(nil) = %v, want nil", err)
}
})
t.Run("path validation failure maps to unsafe file path", func(t *testing.T) {
err := taskInputStatError(fmt.Errorf("bad: %w", fileio.ErrPathValidation), "--file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(err.Error(), "unsafe file path") {
t.Errorf("message = %q, want 'unsafe file path'", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
t.Run("generic error uses readMsg prefix", func(t *testing.T) {
err := taskInputStatError(errors.New("permission denied"), "--file", "cannot access file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), "cannot access file") {
t.Errorf("message = %q, want 'cannot access file' prefix", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
t.Run("default prefix when no readMsg", func(t *testing.T) {
err := taskInputStatError(errors.New("boom"), "--file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("message = %q, want default 'cannot read file'", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
}
func TestWrapTaskNetworkErr(t *testing.T) {
// wrapTaskNetworkErr is only ever called inside an `if err != nil` guard
// (DoAPIStream failure), mirroring drive's wrapDriveNetworkErr, so it does
// not special-case a nil cause.
t.Run("untyped cause becomes typed network error wrapping the cause", func(t *testing.T) {
cause := errors.New("dial timeout")
err := wrapTaskNetworkErr(cause, "upload failed")
var ne *errs.NetworkError
if !errors.As(err, &ne) {
t.Fatalf("err = %T, want *errs.NetworkError", err)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(err, cause) {
t.Error("expected the original cause to be wrapped (errors.Is)")
}
})
t.Run("already-typed cause is passed through unchanged", func(t *testing.T) {
typed := errs.NewAPIError(errs.SubtypeNotFound, "missing")
err := wrapTaskNetworkErr(typed, "upload failed")
var ae *errs.APIError
if !errors.As(err, &ae) {
t.Fatalf("err = %T, want the original *errs.APIError passed through", err)
}
if ae.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want %q (not re-wrapped as network)", ae.Subtype, errs.SubtypeNotFound)
}
})
}

View File

@@ -5,15 +5,13 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,7 +33,7 @@ var FollowersTask = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate followers")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
}
return nil
},
@@ -63,28 +61,13 @@ var FollowersTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add followers")
}
}
data, err := HandleTaskApiResult(result, err, "add task followers")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
if err != nil {
return err
}
@@ -93,21 +76,7 @@ var FollowersTask = common.Shortcut{
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildFollowersBody(removeStr, "")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove followers")
}
}
data, err := HandleTaskApiResult(result, err, "remove task followers")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
if err != nil {
return err
}

View File

@@ -4,11 +4,36 @@
package task
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/smartystreets/goconvey/convey"
)
// TestFollowersTask_RequiresAddOrRemove covers the Validate guard: neither
// --add nor --remove yields a typed validation error (exit 2) before any API
// call.
func TestFollowersTask_RequiresAddOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := FollowersTask
args := []string{"+followers", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestBuildFollowersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildFollowersBody("u1, u2 , ", "token1")

View File

@@ -5,15 +5,14 @@ package task
import (
"context"
"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/shortcuts/common"
)
@@ -59,19 +58,20 @@ var GetMyTasks = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime := time.Now()
queryParams := make(larkcore.QueryParams)
queryParams.Set("type", "my_tasks")
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", "50")
params := map[string]interface{}{
"type": "my_tasks",
"user_id_type": "open_id",
"page_size": 50,
}
if runtime.Cmd.Flags().Changed("complete") {
if runtime.Bool("complete") {
queryParams.Set("completed", "true")
params["completed"] = "true"
} else {
queryParams.Set("completed", "false")
params["completed"] = "false"
}
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
params["page_token"] = pageToken
}
// parse time flags to ms timestamp if provided
@@ -79,7 +79,7 @@ var GetMyTasks = common.Shortcut{
if createdStr := runtime.Str("created_at"); createdStr != "" {
tStr, err := parseTimeFlagSec(createdStr, "start")
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid created_at: %v", err), "parse created_at")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid created_at: %v", err).WithParam("--created_at")
}
createdAfterMs, _ = strconv.ParseInt(tStr, 10, 64)
createdAfterMs *= 1000 // Convert sec to ms
@@ -88,7 +88,7 @@ var GetMyTasks = common.Shortcut{
if dueStartStr := runtime.Str("due-start"); dueStartStr != "" {
tStr, err := parseTimeFlagSec(dueStartStr, "start")
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due-start: %v", err), "parse due-start")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-start: %v", err).WithParam("--due-start")
}
dueStartMs, _ = strconv.ParseInt(tStr, 10, 64)
dueStartMs *= 1000
@@ -97,7 +97,7 @@ var GetMyTasks = common.Shortcut{
if dueEndStr := runtime.Str("due-end"); dueEndStr != "" {
tStr, err := parseTimeFlagSec(dueEndStr, "end")
if err != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due-end: %v", err), "parse due-end")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-end: %v", err).WithParam("--due-end")
}
dueEndMs, _ = strconv.ParseInt(tStr, 10, 64)
dueEndMs *= 1000
@@ -114,22 +114,7 @@ var GetMyTasks = common.Shortcut{
for {
pageCount++
apiReq := &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: "/open-apis/task/v2/tasks",
QueryParams: queryParams,
}
apiResp, err := runtime.DoAPI(apiReq)
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse my tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list tasks")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks", params, nil)
if err != nil {
return err
}
@@ -150,7 +135,7 @@ var GetMyTasks = common.Shortcut{
}
// Set page_token for next iteration
queryParams.Set("page_token", lastPageToken)
params["page_token"] = lastPageToken
}
var filteredItems []map[string]interface{}

View File

@@ -4,12 +4,15 @@
package task
import (
"errors"
"strconv"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
@@ -106,3 +109,50 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
})
}
}
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
// Execute (--created_at / --due-start / --due-end). The parse runs before any
// API call, so a malformed value deterministically surfaces a typed
// *errs.ValidationError (exit 2) regardless of credentials — the command runs
// as user with a throwaway token. Each error carries the corresponding --flag
// param so the caller can point at the offending input.
func TestGetMyTasks_InvalidTimeFlags(t *testing.T) {
tests := []struct {
name string
flag string
wantParam string
}{
{name: "created_at", flag: "--created_at", wantParam: "--created_at"},
{name: "due-start", flag: "--due-start", wantParam: "--due-start"},
{name: "due-end", flag: "--due-end", wantParam: "--due-end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
args := []string{"+get-my-tasks", tt.flag, "not-a-time", "--as", "user"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.flag)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != tt.wantParam {
t.Errorf("param = %q, want %q", ve.Param, tt.wantParam)
}
})
}
}

View File

@@ -5,14 +5,11 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -55,14 +52,15 @@ var GetRelatedTasks = common.Shortcut{
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
queryParams.Set("completed", "false")
params["completed"] = "false"
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
params["page_token"] = pageToken
}
pageLimit := runtime.Int("page-limit")
@@ -80,18 +78,7 @@ var GetRelatedTasks = common.Shortcut{
var lastPageToken string
var lastHasMore bool
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list related tasks")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/task_v2/list_related_task", params, nil)
if err != nil {
return err
}
@@ -103,7 +90,7 @@ var GetRelatedTasks = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
queryParams.Set("page_token", lastPageToken)
params["page_token"] = lastPageToken
}
userOpenID := runtime.UserOpenId()

View File

@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
)
func splitAndTrimCSV(input string) []string {
@@ -46,7 +46,7 @@ func parseTimeRangeMillis(input string) (string, string, error) {
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
hasStart = true
startMillis = startSec + "000"
@@ -58,13 +58,13 @@ func parseTimeRangeMillis(input string) (string, string, error) {
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
hasEnd = true
endMillis = endSec + "000"
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", output.ErrValidation("start time must be earlier than or equal to end time")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
}
return startMillis, endMillis, nil
}
@@ -91,7 +91,7 @@ func parseTimeRangeRFC3339(input string) (string, string, error) {
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid start timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
hasStart = true
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
@@ -103,13 +103,13 @@ func parseTimeRangeRFC3339(input string) (string, string, error) {
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", output.ErrValidation("invalid end timestamp: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
hasEnd = true
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", output.ErrValidation("start time must be earlier than or equal to end time")
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
}
return startTime, endTime, nil
}
@@ -220,7 +220,7 @@ func requireSearchFilter(query string, filter map[string]interface{}, action str
if len(filter) > 0 {
return nil
}
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: query is empty and no filter is provided", action)
}
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
@@ -88,6 +89,7 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
@@ -99,15 +101,16 @@ func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
}
if tt.name == "reversed range fails fast" {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "validation" {
t.Errorf("error detail type = %q, want %q", exitErr.Detail.Type, "validation")
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
return
@@ -264,6 +267,7 @@ func TestRenderRelatedTasksPretty(t *testing.T) {
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
@@ -276,12 +280,16 @@ func TestRenderRelatedTasksPretty(t *testing.T) {
t.Fatal("expected error, got nil")
}
if tt.name == "reversed range fails fast" {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
return

View File

@@ -5,7 +5,6 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -13,8 +12,7 @@ import (
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,10 +33,10 @@ var ReminderTask = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("set") == "" && !runtime.Bool("remove") {
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --set or --remove", "validate reminder")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --set or --remove")
}
if runtime.Str("set") != "" && runtime.Bool("remove") {
return WrapTaskError(ErrCodeTaskInvalidParams, "cannot specify both --set and --remove", "validate reminder")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot specify both --set and --remove")
}
return nil
},
@@ -66,24 +64,10 @@ var ReminderTask = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
// First, get the task to find existing reminders
getResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
})
var getResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(getResp.RawBody, &getResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task details: %v", parseErr), "parse task details")
}
}
data, err := HandleTaskApiResult(getResult, err, "get task reminders")
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
@@ -112,21 +96,7 @@ var ReminderTask = common.Shortcut{
body := map[string]interface{}{
"reminder_ids": reminderIds,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_reminders",
QueryParams: queryParams,
Body: body,
})
var removeResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove response")
}
}
if _, err := HandleTaskApiResult(removeResult, err, "remove task reminders"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
return err
}
}
@@ -155,7 +125,7 @@ var ReminderTask = common.Shortcut{
}
if parseErr != nil {
return WrapTaskError(ErrCodeTaskInvalidParams, parseErr.Error(), "set reminder")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", parseErr)
}
// If any reminders exist, remove them first
@@ -173,21 +143,7 @@ var ReminderTask = common.Shortcut{
body := map[string]interface{}{
"reminder_ids": reminderIds,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_reminders",
QueryParams: queryParams,
Body: body,
})
var removeResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &removeResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove response")
}
}
if _, err := HandleTaskApiResult(removeResult, err, "remove existing task reminders before setting new one"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
return err
}
}
@@ -200,21 +156,7 @@ var ReminderTask = common.Shortcut{
},
},
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_reminders",
QueryParams: queryParams,
Body: body,
})
var addResult map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &addResult); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add response")
}
}
if _, err := HandleTaskApiResult(addResult, err, "add task reminder"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_reminders", params, body); err != nil {
return err
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// TestReminderTask_RequiresSetOrRemove covers the first Validate guard: neither
// --set nor --remove yields a typed validation error (exit 2) before any API
// call.
func TestReminderTask_RequiresSetOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := ReminderTask
args := []string{"+reminder", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
// TestReminderTask_CannotSpecifyBoth covers the second Validate guard: passing
// both --set and --remove yields a typed validation error (exit 2).
func TestReminderTask_CannotSpecifyBoth(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := ReminderTask
args := []string{"+reminder", "--task-id", "task-1", "--set", "15m", "--remove", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}

View File

@@ -5,14 +5,11 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -42,24 +39,8 @@ var ReopenTask = common.Shortcut{
taskId := url.PathEscape(runtime.Str("task-id"))
body := buildReopenBody()
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPatch,
ApiPath: "/open-apis/task/v2/tasks/" + taskId,
QueryParams: queryParams,
Body: body,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse reopen response")
}
}
data, err := HandleTaskApiResult(result, err, "reopen task")
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}

View File

@@ -5,14 +5,12 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
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"
)
@@ -77,18 +75,7 @@ var SearchTask = common.Shortcut{
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasks")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
if err != nil {
return err
}
@@ -173,7 +160,7 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if dueRange := runtime.Str("due"); dueRange != "" {
start, end, err := parseTimeRangeRFC3339(dueRange)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due: %v", err).WithParam("--due")
}
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
mergeIntoFilter(filter, dueFilter)
@@ -196,27 +183,15 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
}
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
}
}
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, nil)
if err != nil {
return nil, err
}
task, _ := data["task"].(map[string]interface{})
if task == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "task detail response missing task object")
}
return task, nil
}

View File

@@ -4,12 +4,17 @@
package task
import (
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -298,3 +303,129 @@ func TestSearchTask_Execute(t *testing.T) {
})
}
}
// TestSearchTask_InvalidDue_Validation drives the --due validation arm through
// the mounted command. buildTaskSearchBody runs before any API call, so a
// malformed range deterministically surfaces a typed *errs.ValidationError
// (invalid_argument, exit 2) carrying the --due param.
func TestSearchTask_InvalidDue_Validation(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
args := []string{"+search", "--query", "release", "--due", "not-a-time", "--as", "user"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatal("expected validation error for malformed --due, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != "--due" {
t.Errorf("param = %q, want %q", ve.Param, "--due")
}
}
// TestSearchTask_MalformedSearchResponse covers the search raw-body parse arm:
// the SDK returns a 200 with a non-JSON body and nil error, so the shortcut's
// own json.Unmarshal fails and must surface a typed *errs.InternalError
// (invalid_response, exit 5).
func TestSearchTask_MalformedSearchResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
RawBody: []byte("{not-json"),
})
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
args := []string{"+search", "--query", "release", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatal("expected internal error for malformed response, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
// TestGetTaskDetail_MalformedResponse exercises getTaskDetail directly. In the
// +search Execute loop a detail-fetch error is intentionally swallowed (the hit
// falls back to its app_link), so the only way to lock the helper's two
// internal arms — a non-JSON body and a code-0 response missing the task object
// — is to call it directly. Both must surface a typed *errs.InternalError
// (invalid_response, exit 5).
func TestGetTaskDetail_MalformedResponse(t *testing.T) {
tests := []struct {
name string
stub *httpmock.Stub
}{
{
name: "body not json",
stub: &httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
RawBody: []byte("{not-json"),
},
},
{
name: "missing task object",
stub: &httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(tt.stub)
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, taskTestConfig(t), f, core.AsBot)
_, err := getTaskDetail(runtime, "task-123")
if err == nil {
t.Fatal("expected internal error, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
})
}
}

View File

@@ -5,14 +5,11 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,22 +34,9 @@ var SetAncestorTask = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
QueryParams: queryParams,
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
}
}
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID)+"/set_ancestor_task", params, buildSetAncestorBody(runtime.Str("ancestor-id"))); err != nil {
return err
}

View File

@@ -5,13 +5,10 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -29,23 +26,8 @@ var SubscribeTaskEvent = common.Shortcut{
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
QueryParams: queryParams,
})
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
// Parse and validate the envelope to avoid false-success output.
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
}
}
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
params := map[string]interface{}{"user_id_type": "open_id"}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/task_v2/task_subscription", params, nil); err != nil {
return err
}

View File

@@ -4,12 +4,15 @@
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -129,3 +132,32 @@ func TestSubscribeTaskEvent(t *testing.T) {
})
}
}
// TestSubscribeTaskEvent_MalformedResponse covers the parse-response arm: a 200
// with an unparseable body surfaces a typed internal invalid_response error
// (exit 5).
func TestSubscribeTaskEvent_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Status: 200,
RawBody: []byte("{not-json"),
})
args := []string{"+subscribe-event", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}

View File

@@ -5,14 +5,12 @@ package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
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"
)
@@ -74,18 +72,7 @@ var SearchTasklist = common.Shortcut{
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasklists")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
if err != nil {
return err
}
@@ -160,7 +147,7 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if createTime := runtime.Str("create-time"); createTime != "" {
start, end, err := parseTimeRangeRFC3339(createTime)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid create-time: %v", err).WithParam("--create-time")
}
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
mergeIntoFilter(filter, timeFilter)
@@ -183,27 +170,15 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
}
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
params := map[string]interface{}{"user_id_type": "open_id"}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
}
}
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+url.PathEscape(tasklistID), params, nil)
if err != nil {
return nil, err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
if tasklist == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "tasklist detail response missing tasklist object")
}
return tasklist, nil
}

View File

@@ -4,12 +4,15 @@
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -261,3 +264,35 @@ func TestSearchTasklist_Execute(t *testing.T) {
})
}
}
// TestSearchTasklist_MalformedResponse covers the search parse arm: a 200 with
// an unparseable search body surfaces a typed internal invalid_response error
// (exit 5). The detail parse arm is swallowed into the fallback path, so only
// the top-level search parse propagates.
func TestSearchTasklist_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Status: 200,
RawBody: []byte("{not-json"),
})
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}

Some files were not shown because too many files have changed in this diff Show More