mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
16 Commits
sun/pre
...
codex/insp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54abe817c | ||
|
|
076f4d579f | ||
|
|
0c2fd08d5a | ||
|
|
9d845442ce | ||
|
|
c07a14aa2b | ||
|
|
8b39f7243c | ||
|
|
e40ef66912 | ||
|
|
e1bb9db552 | ||
|
|
7c50b3d9e3 | ||
|
|
5788a6c384 | ||
|
|
bd07859c90 | ||
|
|
8c3cba17b2 | ||
|
|
6367aaa0f5 | ||
|
|
37b17f3d37 | ||
|
|
be5527ca4e | ||
|
|
a75420f72c |
@@ -73,20 +73,20 @@ linters:
|
|||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||||
# Add a path when its migration is complete.
|
# 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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||||
text: errs-typed-only
|
text: errs-typed-only
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
# 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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||||
text: errs-no-bare-wrap
|
text: errs-no-bare-wrap
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||||
# helpers have migrated to typed final errors.
|
# 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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||||
text: errs-no-legacy-helper
|
text: errs-no-legacy-helper
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
|||||||
16
cmd/build.go
16
cmd/build.go
@@ -6,6 +6,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/profile"
|
"github.com/larksuite/cli/cmd/profile"
|
||||||
"github.com/larksuite/cli/cmd/schema"
|
"github.com/larksuite/cli/cmd/schema"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/cmd/skill"
|
||||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||||
_ "github.com/larksuite/cli/events"
|
_ "github.com/larksuite/cli/events"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"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.
|
// HideProfile sets the visibility policy for the root-level --profile flag.
|
||||||
// When hide is true the flag stays registered (so existing invocations still
|
// When hide is true the flag stays registered (so existing invocations still
|
||||||
// parse) but is omitted from help and shell completion. Typically called as
|
// 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 {
|
if cfg.keychain != nil {
|
||||||
f.Keychain = cfg.keychain
|
f.Keychain = cfg.keychain
|
||||||
}
|
}
|
||||||
|
f.SkillContent = embeddedSkillContent
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "lark-cli",
|
Use: "lark-cli",
|
||||||
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
|
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(completion.NewCmdCompletion(f))
|
||||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||||
|
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||||
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
|
||||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||||
|
|
||||||
|
|||||||
@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
|||||||
OK: false,
|
OK: false,
|
||||||
Identity: "bot",
|
Identity: "bot",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "api_error",
|
Type: "api",
|
||||||
Code: 230002,
|
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
183
cmd/skill/skill.go
Normal 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
306
cmd/skill/skill_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,12 +49,21 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
u.NpmInstallOverride = npmFn
|
u.NpmInstallOverride = npmFn
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { newUpdater = origNew })
|
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 {
|
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||||
return func(args ...string) *selfupdate.NpmResult {
|
return func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &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.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
|
||||||
u.RestoreAvailableOverride = func() bool { return false }
|
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 {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
t.Fatal("skills sync should not run when binary verification fails")
|
t.Fatal("skills sync should not run when binary verification fails")
|
||||||
return nil
|
return nil
|
||||||
@@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
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 {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
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.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
|
||||||
u.VerifyOverride = func(string) error { return nil }
|
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 {
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stderr.WriteString("npx: command not found")
|
r.Stderr.WriteString("npx: command not found")
|
||||||
@@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
@@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{
|
return selfupdate.DetectResult{
|
||||||
Method: selfupdate.InstallManual,
|
Method: selfupdate.InstallManual,
|
||||||
@@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{
|
return selfupdate.DetectResult{
|
||||||
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
Method: selfupdate.InstallNpm, NpmAvailable: true,
|
||||||
@@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|||||||
DetectOverride: func() selfupdate.DetectResult {
|
DetectOverride: func() selfupdate.DetectResult {
|
||||||
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
|
||||||
},
|
},
|
||||||
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||||
|
skillsCalled = true
|
||||||
|
return successfulSkillsIndexFetch()()
|
||||||
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
@@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
newUpdater = func() *selfupdate.Updater {
|
newUpdater = func() *selfupdate.Updater {
|
||||||
return &selfupdate.Updater{
|
return &selfupdate.Updater{
|
||||||
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
||||||
|
skillsCalled = true
|
||||||
|
return successfulSkillsIndexFetch()()
|
||||||
|
},
|
||||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||||
skillsCalled = true
|
skillsCalled = true
|
||||||
return successfulSkillsCommand()(args...)
|
return successfulSkillsCommand()(args...)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/larksuite/cli/events/im"
|
"github.com/larksuite/cli/events/im"
|
||||||
"github.com/larksuite/cli/events/minutes"
|
"github.com/larksuite/cli/events/minutes"
|
||||||
"github.com/larksuite/cli/events/vc"
|
"github.com/larksuite/cli/events/vc"
|
||||||
|
"github.com/larksuite/cli/events/whiteboard"
|
||||||
"github.com/larksuite/cli/internal/event"
|
"github.com/larksuite/cli/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ func init() {
|
|||||||
im.Keys(),
|
im.Keys(),
|
||||||
minutes.Keys(),
|
minutes.Keys(),
|
||||||
vc.Keys(),
|
vc.Keys(),
|
||||||
|
whiteboard.Keys(),
|
||||||
}
|
}
|
||||||
for _, keys := range all {
|
for _, keys := range all {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
|||||||
23
events/whiteboard/native.go
Normal file
23
events/whiteboard/native.go
Normal 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"`
|
||||||
|
}
|
||||||
48
events/whiteboard/preconsume.go
Normal file
48
events/whiteboard/preconsume.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
198
events/whiteboard/preconsume_test.go
Normal file
198
events/whiteboard/preconsume_test.go
Normal 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)
|
||||||
48
events/whiteboard/register.go
Normal file
48
events/whiteboard/register.go
Normal 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},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ type Factory struct {
|
|||||||
Credential *credential.CredentialProvider
|
Credential *credential.CredentialProvider
|
||||||
|
|
||||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
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.
|
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||||
|
|||||||
18
internal/errclass/codemeta_minutes.go
Normal file
18
internal/errclass/codemeta_minutes.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
|
||||||
|
// Only codes whose meaning is stable across minutes endpoints are registered;
|
||||||
|
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
|
||||||
|
// Command-specific messages, hints, and subtypes are layered on top via
|
||||||
|
// per-command enrichment.
|
||||||
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
|
var minutesCodeMeta = map[int]CodeMeta{
|
||||||
|
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }
|
||||||
@@ -70,6 +70,12 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
|
||||||
|
if got, ok := LookupCodeMeta(2091001); ok {
|
||||||
|
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
|
||||||
got, ok := LookupCodeMeta(20050)
|
got, ok := LookupCodeMeta(20050)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
19
internal/errclass/codemeta_vc.go
Normal file
19
internal/errclass/codemeta_vc.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
|
||||||
|
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||||
|
// ambiguous codes (e.g. 124002 "recording still generating", which has no
|
||||||
|
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
|
||||||
|
// per-command enrichment for a retry hint.
|
||||||
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
|
var vcCodeMeta = map[int]CodeMeta{
|
||||||
|
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
|
||||||
|
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(vcCodeMeta, "vc") }
|
||||||
@@ -10,10 +10,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/transport"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +40,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
npmInstallTimeout = 10 * time.Minute
|
npmInstallTimeout = 10 * time.Minute
|
||||||
skillsUpdateTimeout = 2 * time.Minute
|
skillsUpdateTimeout = 2 * time.Minute
|
||||||
verifyTimeout = 10 * time.Second
|
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.
|
// DetectResult holds installation detection results.
|
||||||
@@ -83,6 +92,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
RestoreAvailableOverride func() bool
|
RestoreAvailableOverride func() bool
|
||||||
@@ -153,6 +163,53 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
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 {
|
func (u *Updater) ListOfficialSkills() *NpmResult {
|
||||||
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
r := u.runSkillsListOfficial("https://open.feishu.cn")
|
||||||
if r.Err != nil {
|
if r.Err != nil {
|
||||||
|
|||||||
@@ -4,12 +4,18 @@
|
|||||||
package selfupdate
|
package selfupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"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) {
|
func TestListOfficialSkillsFallsBack(t *testing.T) {
|
||||||
called := []string{}
|
called := []string{}
|
||||||
updater := &Updater{
|
updater := &Updater{
|
||||||
|
|||||||
209
internal/skillcontent/reader.go
Normal file
209
internal/skillcontent/reader.go
Normal 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
|
||||||
|
}
|
||||||
290
internal/skillcontent/reader_test.go
Normal file
290
internal/skillcontent/reader_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,30 @@ func ParseGlobalSkillsJSON(text string) []string {
|
|||||||
return sortedKeys(seen)
|
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"
|
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
|
||||||
func parseGlobalSkillsList(lines []string) []string {
|
func parseGlobalSkillsList(lines []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -160,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
|
|||||||
|
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
candidate := parts[0]
|
candidate := parts[0]
|
||||||
// Check if it's a valid official skill name
|
if skillNamePattern.MatchString(candidate) {
|
||||||
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
|
|
||||||
seen[candidate] = true
|
seen[candidate] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +246,7 @@ func PlanSync(input SyncInput) SyncPlan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SkillsRunner interface {
|
type SkillsRunner interface {
|
||||||
|
ListOfficialSkillsIndex() *selfupdate.NpmResult
|
||||||
ListOfficialSkills() *selfupdate.NpmResult
|
ListOfficialSkills() *selfupdate.NpmResult
|
||||||
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
ListGlobalSkillsJSON() *selfupdate.NpmResult
|
||||||
ListGlobalSkills() *selfupdate.NpmResult
|
ListGlobalSkills() *selfupdate.NpmResult
|
||||||
@@ -258,14 +282,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 1: List official skills ---
|
// --- Step 1: List official skills ---
|
||||||
officialResult := opts.Runner.ListOfficialSkills()
|
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||||
if officialResult == nil || officialResult.Err != nil {
|
if !ok {
|
||||||
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
|
return fallbackFullInstall(opts, reason, nil)
|
||||||
}
|
|
||||||
official := ParseSkillsList(officialResult.Stdout.String())
|
|
||||||
|
|
||||||
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
|
|
||||||
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Step 2: List local (installed) skills ---
|
// --- Step 2: List local (installed) skills ---
|
||||||
@@ -327,6 +346,40 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
|||||||
return result
|
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) {
|
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||||
jsonResult := runner.ListGlobalSkillsJSON()
|
jsonResult := runner.ListGlobalSkillsJSON()
|
||||||
if jsonResult != nil && jsonResult.Err == nil {
|
if jsonResult != nil && jsonResult.Err == nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestParseGlobalSkillsList(t *testing.T) {
|
||||||
input := `Global Skills
|
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) {
|
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
|
||||||
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
|
||||||
got := PlanSync(SyncInput{
|
got := PlanSync(SyncInput{
|
||||||
@@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeSkillsRunner struct {
|
type fakeSkillsRunner struct {
|
||||||
|
officialIndexOut string
|
||||||
officialOut string
|
officialOut string
|
||||||
globalJSONOut string
|
globalJSONOut string
|
||||||
globalOut string
|
globalOut string
|
||||||
|
officialIndexErr error
|
||||||
officialErr error
|
officialErr error
|
||||||
globalJSONErr error
|
globalJSONErr error
|
||||||
globalErr error
|
globalErr error
|
||||||
@@ -166,6 +218,8 @@ type fakeSkillsRunner struct {
|
|||||||
installAllErr error
|
installAllErr error
|
||||||
installed [][]string
|
installed [][]string
|
||||||
installedAll int
|
installedAll int
|
||||||
|
listedIndex int
|
||||||
|
listedOfficial int
|
||||||
listedGlobalJSON int
|
listedGlobalJSON int
|
||||||
listedGlobalText int
|
listedGlobalText int
|
||||||
}
|
}
|
||||||
@@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
|
|||||||
return b.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 {
|
func globalSkillsOutput(names ...string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("Global Skills\n\n")
|
b.WriteString("Global Skills\n\n")
|
||||||
@@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string {
|
|||||||
return b.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 {
|
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
|
||||||
|
f.listedOfficial++
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
r.Stdout.WriteString(f.officialOut)
|
r.Stdout.WriteString(f.officialOut)
|
||||||
r.Err = f.officialErr
|
r.Err = f.officialErr
|
||||||
@@ -255,9 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||||
globalOut: globalSkillsOutput("lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
|
||||||
|
globalOut: globalSkillsOutput("lark-mail"),
|
||||||
}
|
}
|
||||||
result := SyncSkills(SyncOptions{
|
result := SyncSkills(SyncOptions{
|
||||||
Version: "1.0.33",
|
Version: "1.0.33",
|
||||||
@@ -289,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
|
||||||
|
officialOut: officialSkillsOutput("lark-should-not-be-used"),
|
||||||
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||||
|
globalOut: globalSkillsOutput("lark-mail"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||||
|
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
|
||||||
|
if runner.listedIndex != 1 {
|
||||||
|
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
|
||||||
|
}
|
||||||
|
if runner.listedOfficial != 0 {
|
||||||
|
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||||
|
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||||
|
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||||
|
}
|
||||||
|
if runner.installedAll != 0 {
|
||||||
|
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexOut: `{"skills":[]}`,
|
||||||
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Err != nil {
|
||||||
|
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||||
|
}
|
||||||
|
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
|
||||||
|
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
|
||||||
|
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Action != "fallback_synced" {
|
||||||
|
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||||
|
}
|
||||||
|
if runner.installedAll != 1 {
|
||||||
|
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
|
||||||
|
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
|
runner := &fakeSkillsRunner{
|
||||||
|
officialIndexOut: `{"skills":[]}`,
|
||||||
|
installAllErr: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
|
if result.Action != "fallback_synced" {
|
||||||
|
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||||
|
}
|
||||||
|
if runner.installedAll != 1 {
|
||||||
|
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
|
||||||
|
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialErr: fmt.Errorf("list failed"),
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -322,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialErr: fmt.Errorf("list failed"),
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: fmt.Errorf("full install failed"),
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: fmt.Errorf("full install failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -342,9 +527,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONErr: fmt.Errorf("json list failed"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar"),
|
globalJSONErr: fmt.Errorf("json list failed"),
|
||||||
|
globalOut: globalSkillsOutput("lark-calendar"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -367,9 +553,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
|
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})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -391,9 +578,10 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: `[]`,
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: "Some unrecognized output format\n",
|
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: time.Now})
|
||||||
@@ -420,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput(),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
globalOut: globalSkillsOutput(),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -445,11 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -477,11 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: fmt.Errorf("full install boom"),
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: fmt.Errorf("full install boom"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -510,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: "Some unrecognized output format\n",
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialOut: "Some unrecognized output format\n",
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -527,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: "Some unrecognized output format\n",
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: fmt.Errorf("full install failed"),
|
officialOut: "Some unrecognized output format\n",
|
||||||
|
installAllErr: fmt.Errorf("full install failed"),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -551,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: "Some unrecognized output format\n",
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialOut: "Some unrecognized output format\n",
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -576,11 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -601,11 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
||||||
installErr: fmt.Errorf("incremental boom"),
|
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
installAllErr: nil,
|
installErr: fmt.Errorf("incremental boom"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -625,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||||
runner := &fakeSkillsRunner{
|
runner := &fakeSkillsRunner{
|
||||||
officialErr: fmt.Errorf("list failed"),
|
officialIndexErr: fmt.Errorf("index unavailable"),
|
||||||
installAllErr: nil,
|
officialErr: fmt.Errorf("list failed"),
|
||||||
|
installAllErr: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||||
@@ -643,9 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner2 := &fakeSkillsRunner{
|
runner2 := &fakeSkillsRunner{
|
||||||
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
|
||||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
|
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
|
||||||
globalOut: globalSkillsOutput("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})
|
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
|
||||||
if result2.Action != "synced" {
|
if result2.Action != "synced" {
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ import (
|
|||||||
// common replacements or construct an errs.* typed error directly.
|
// common replacements or construct an errs.* typed error directly.
|
||||||
var migratedCommonHelperPaths = []string{
|
var migratedCommonHelperPaths = []string{
|
||||||
"shortcuts/base/",
|
"shortcuts/base/",
|
||||||
|
"shortcuts/calendar/",
|
||||||
"shortcuts/drive/",
|
"shortcuts/drive/",
|
||||||
"shortcuts/mail/",
|
"shortcuts/mail/",
|
||||||
"shortcuts/calendar/",
|
"shortcuts/minutes/",
|
||||||
|
"shortcuts/okr/",
|
||||||
|
"shortcuts/task/",
|
||||||
|
"shortcuts/vc/",
|
||||||
|
"shortcuts/whiteboard/",
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ import (
|
|||||||
// appending their path prefix here.
|
// appending their path prefix here.
|
||||||
var migratedEnvelopePaths = []string{
|
var migratedEnvelopePaths = []string{
|
||||||
"shortcuts/base/",
|
"shortcuts/base/",
|
||||||
|
"shortcuts/calendar/",
|
||||||
"shortcuts/drive/",
|
"shortcuts/drive/",
|
||||||
"shortcuts/mail/",
|
"shortcuts/mail/",
|
||||||
"shortcuts/calendar/",
|
"shortcuts/minutes/",
|
||||||
|
"shortcuts/okr/",
|
||||||
|
"shortcuts/task/",
|
||||||
|
"shortcuts/vc/",
|
||||||
|
"shortcuts/whiteboard/",
|
||||||
|
"shortcuts/im/",
|
||||||
}
|
}
|
||||||
|
|
||||||
// legacyOutputImportPath is the import path of the package that declares the
|
// legacyOutputImportPath is the import path of the package that declares the
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
// forbidigo's errs-typed-only ban does not see them because they are method
|
// 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.
|
// 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
|
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
|
||||||
// typed errs.* errors.
|
// typed errs.* errors.
|
||||||
//
|
//
|
||||||
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
|||||||
File: path,
|
File: path,
|
||||||
Line: fset.Position(call.Pos()).Line,
|
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",
|
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",
|
"so failures classify into typed errs.* errors",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
||||||
src := `package drive
|
src := `package drive
|
||||||
|
|
||||||
@@ -662,7 +691,7 @@ func boom() error {
|
|||||||
return &output.ExitError{Code: 1}
|
return &output.ExitError{Code: 1}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/im/foo.go", src)
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||||
if len(v) != 0 {
|
if len(v) != 0 {
|
||||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
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) {
|
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||||
src := `package drive
|
src := `package drive
|
||||||
|
|
||||||
@@ -851,14 +900,14 @@ func boom(runtime *common.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
|
||||||
src := `package im
|
src := `package contact
|
||||||
|
|
||||||
func boom(runtime *common.RuntimeContext) error {
|
func boom(runtime *common.RuntimeContext) error {
|
||||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||||
if len(v) != 0 {
|
if len(v) != 0 {
|
||||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||||
}
|
}
|
||||||
@@ -897,6 +946,9 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
|||||||
paths := []string{
|
paths := []string{
|
||||||
"shortcuts/drive/drive_search.go",
|
"shortcuts/drive/drive_search.go",
|
||||||
"shortcuts/mail/mail_send.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 _, path := range paths {
|
||||||
for _, helper := range helpers {
|
for _, helper := range helpers {
|
||||||
@@ -946,7 +998,7 @@ func boom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||||
src := `package im
|
src := `package contact
|
||||||
|
|
||||||
import "github.com/larksuite/cli/shortcuts/common"
|
import "github.com/larksuite/cli/shortcuts/common"
|
||||||
|
|
||||||
@@ -954,7 +1006,7 @@ func boom() {
|
|||||||
common.FlagErrorf("legacy allowed until domain migrates")
|
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 {
|
if len(v) != 0 {
|
||||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
|
|||||||
body["with_url"] = true
|
body["with_url"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := runtime.CallAPI(
|
data, err := runtime.CallAPITyped(
|
||||||
"POST",
|
"POST",
|
||||||
"/open-apis/drive/v1/metas/batch_query",
|
"/open-apis/drive/v1/metas/batch_query",
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
|
||||||
}
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if p.Code != 99991668 {
|
||||||
|
t.Fatalf("code = %d, want 99991668", p.Code)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -492,6 +492,28 @@ func (ctx *RuntimeContext) DoAPIJSONWithLogID(method, apiPath string, query lark
|
|||||||
return ctx.doAPIJSON(method, apiPath, query, body, true)
|
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) {
|
func (ctx *RuntimeContext) doAPIJSON(method, apiPath string, query larkcore.QueryParams, body any, includeLogID bool) (map[string]any, error) {
|
||||||
req := &larkcore.ApiReq{
|
req := &larkcore.ApiReq{
|
||||||
HttpMethod: method,
|
HttpMethod: method,
|
||||||
@@ -603,27 +625,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
|||||||
return resolved, nil
|
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
|
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
||||||
// caller-provided message prefix.
|
// caller-provided message prefix.
|
||||||
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||||
@@ -703,6 +704,9 @@ func WrapSaveErrorTyped(err error) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var me *fileio.MkdirError
|
var me *fileio.MkdirError
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fileio.ErrPathValidation):
|
case errors.Is(err, fileio.ErrPathValidation):
|
||||||
|
|||||||
@@ -106,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
|
|||||||
return MutuallyExclusiveTyped(rt, flags...)
|
return MutuallyExclusiveTyped(rt, flags...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
|
|
||||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
|
||||||
//
|
|
||||||
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
|
|
||||||
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
|
||||||
s := rt.Str(flagName)
|
|
||||||
if s == "" {
|
|
||||||
return defaultVal, nil
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(s)
|
|
||||||
if err != nil {
|
|
||||||
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
|
|
||||||
}
|
|
||||||
if n < minVal || n > maxVal {
|
|
||||||
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
|
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
|
||||||
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
|
||||||
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
|
||||||
|
|||||||
@@ -9,18 +9,6 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/output"
|
"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).
|
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
|
||||||
// Also extracts token from URL if provided. param names the flag being
|
// Also extracts token from URL if provided. param names the flag being
|
||||||
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
// validated (e.g. "--chat-ids") and is recorded on the typed error.
|
||||||
|
|||||||
@@ -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) {
|
func TestAtLeastOne(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -5,35 +5,13 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
|
// v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||||
func v1CreateFlags() []common.Flag {
|
func v1CreateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
|
||||||
{Name: "title", Desc: "document title", Hidden: true},
|
|
||||||
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
|
||||||
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
|
|
||||||
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
|
|
||||||
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
|
|
||||||
|
|
||||||
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
|
|
||||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
|
||||||
func useV2Create(runtime *common.RuntimeContext) bool {
|
|
||||||
if runtime.Str("api-version") == "v2" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return runtime.Str("content") != "" ||
|
|
||||||
runtime.Str("parent-token") != "" ||
|
|
||||||
runtime.Str("parent-position") != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DocsCreate = common.Shortcut{
|
var DocsCreate = common.Shortcut{
|
||||||
@@ -43,213 +21,25 @@ var DocsCreate = common.Shortcut{
|
|||||||
Risk: "write",
|
Risk: "write",
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Scopes: []string{"docx:document:create"},
|
Scopes: []string{"docx:document:create"},
|
||||||
Tips: docsVersionSelectionTips,
|
PostMount: installDocsShortcutHelp("+create"),
|
||||||
Flags: concatFlags(
|
Flags: concatFlags(
|
||||||
[]common.Flag{
|
[]common.Flag{
|
||||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
docsAPIVersionCompatFlag(),
|
||||||
},
|
},
|
||||||
v1CreateFlags(),
|
|
||||||
v2CreateFlags(),
|
v2CreateFlags(),
|
||||||
|
v1CreateFlags(),
|
||||||
),
|
),
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Create(runtime) {
|
return validateCreateV2(ctx, runtime)
|
||||||
return validateCreateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return validateCreateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
if useV2Create(runtime) {
|
return dryRunCreateV2(ctx, runtime)
|
||||||
return dryRunCreateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return dryRunCreateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Create(runtime) {
|
return executeCreateV2(ctx, runtime)
|
||||||
return executeCreateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return executeCreateV1(ctx, runtime)
|
|
||||||
},
|
|
||||||
PostMount: func(cmd *cobra.Command) {
|
|
||||||
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── V1 (MCP) implementation ──
|
|
||||||
|
|
||||||
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
||||||
if runtime.Str("markdown") == "" {
|
|
||||||
return common.FlagErrorf("--markdown is required")
|
|
||||||
}
|
|
||||||
count := 0
|
|
||||||
if runtime.Str("folder-token") != "" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if runtime.Str("wiki-node") != "" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if runtime.Str("wiki-space") != "" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if count > 1 {
|
|
||||||
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
||||||
args := buildCreateArgsV1(runtime)
|
|
||||||
d := common.NewDryRunAPI().
|
|
||||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
|
||||||
Desc("MCP tool: create-doc").
|
|
||||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
|
|
||||||
Set("mcp_tool", "create-doc").Set("args", args)
|
|
||||||
if runtime.IsBot() {
|
|
||||||
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
||||||
warnDeprecatedV1(runtime, "+create")
|
|
||||||
// Surface callout type= hint so users know to switch to background-color/
|
|
||||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
|
||||||
if md := runtime.Str("markdown"); md != "" {
|
|
||||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
|
||||||
}
|
|
||||||
args := buildCreateArgsV1(runtime)
|
|
||||||
result, err := common.CallMCPTool(runtime, "create-doc", args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
augmentCreateResultV1(runtime, result)
|
|
||||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
|
||||||
runtime.Out(result, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
|
||||||
md := runtime.Str("markdown")
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"markdown": md,
|
|
||||||
}
|
|
||||||
if v := runtime.Str("title"); v != "" {
|
|
||||||
args["title"] = v
|
|
||||||
}
|
|
||||||
if v := runtime.Str("folder-token"); v != "" {
|
|
||||||
args["folder_token"] = v
|
|
||||||
}
|
|
||||||
if v := runtime.Str("wiki-node"); v != "" {
|
|
||||||
args["wiki_node"] = v
|
|
||||||
}
|
|
||||||
if v := runtime.Str("wiki-space"); v != "" {
|
|
||||||
args["wiki_space"] = v
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
type docsPermissionTarget struct {
|
|
||||||
Token string
|
|
||||||
Type string
|
|
||||||
}
|
|
||||||
|
|
||||||
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
|
||||||
target := selectPermissionTarget(result)
|
|
||||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
|
|
||||||
result["permission_grant"] = grant
|
|
||||||
}
|
|
||||||
fallbackDocURLV1(runtime, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
|
|
||||||
// response did not include one but did include a doc_id. This protects against
|
|
||||||
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
|
|
||||||
// drops structured fields.
|
|
||||||
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
|
||||||
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
|
||||||
if docID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
|
|
||||||
result["doc_url"] = u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
|
||||||
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
|
||||||
if docID != "" {
|
|
||||||
return docsPermissionTarget{Token: docID, Type: "docx"}
|
|
||||||
}
|
|
||||||
return docsPermissionTarget{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
|
|
||||||
if strings.TrimSpace(docURL) == "" {
|
|
||||||
return docsPermissionTarget{}, false
|
|
||||||
}
|
|
||||||
ref, err := parseDocumentRef(docURL)
|
|
||||||
if err != nil {
|
|
||||||
return docsPermissionTarget{}, false
|
|
||||||
}
|
|
||||||
switch ref.Kind {
|
|
||||||
case "wiki":
|
|
||||||
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
|
|
||||||
case "doc", "docx":
|
|
||||||
return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true
|
|
||||||
default:
|
|
||||||
return docsPermissionTarget{}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
|
|
||||||
// whiteboard creation markdown is detected.
|
|
||||||
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
|
|
||||||
if !isWhiteboardCreateMarkdown(markdown) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func isWhiteboardCreateMarkdown(markdown string) bool {
|
|
||||||
lower := strings.ToLower(markdown)
|
|
||||||
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.Contains(lower, "<whiteboard") &&
|
|
||||||
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeBoardTokens(raw interface{}) []string {
|
|
||||||
switch v := raw.(type) {
|
|
||||||
case nil:
|
|
||||||
return []string{}
|
|
||||||
case []string:
|
|
||||||
return v
|
|
||||||
case []interface{}:
|
|
||||||
tokens := make([]string, 0, len(v))
|
|
||||||
for _, item := range v {
|
|
||||||
if s, ok := item.(string); ok && s != "" {
|
|
||||||
tokens = append(tokens, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tokens
|
|
||||||
case string:
|
|
||||||
if v == "" {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
return []string{v}
|
|
||||||
default:
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared helpers ──
|
|
||||||
|
|
||||||
// concatFlags combines multiple flag slices into one.
|
// concatFlags combines multiple flag slices into one.
|
||||||
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
func concatFlags(slices ...[]common.Flag) []common.Flag {
|
||||||
var out []common.Flag
|
var out []common.Flag
|
||||||
@@ -258,15 +48,3 @@ func concatFlags(slices ...[]common.Flag) []common.Flag {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
|
|
||||||
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
|
|
||||||
m := make(map[string]string, len(v1)+len(v2))
|
|
||||||
for _, f := range v1 {
|
|
||||||
m[f.Name] = "v1"
|
|
||||||
}
|
|
||||||
for _, f := range v2 {
|
|
||||||
m[f.Name] = "v2"
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
|||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
"+create",
|
"+create",
|
||||||
"--api-version", "v2",
|
|
||||||
"--content", "<title>项目计划</title><h1>目标</h1>",
|
"--content", "<title>项目计划</title><h1>目标</h1>",
|
||||||
"--as", "bot",
|
"--as", "bot",
|
||||||
})
|
})
|
||||||
@@ -249,148 +248,63 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── V1 (MCP) tests ──
|
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||||
|
|
||||||
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
|
||||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
|
||||||
"doc_id": "doxcn_new_doc",
|
|
||||||
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
|
||||||
"message": "文档创建成功",
|
|
||||||
})
|
|
||||||
|
|
||||||
permStub := &httpmock.Stub{
|
|
||||||
Method: "POST",
|
|
||||||
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"member": map[string]interface{}{
|
|
||||||
"member_id": "ou_current_user",
|
|
||||||
"member_type": "openid",
|
|
||||||
"perm": "full_access",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
reg.Register(permStub)
|
|
||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
|
||||||
"+create",
|
|
||||||
"--title", "项目计划",
|
|
||||||
"--markdown", "## 目标",
|
|
||||||
"--as", "bot",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := decodeDocsCreateEnvelope(t, stdout)
|
|
||||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
||||||
if grant["status"] != common.PermissionGrantGranted {
|
|
||||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
|
|
||||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
|
||||||
"doc_id": "doxcn_new_doc",
|
|
||||||
"doc_url": "https://example.feishu.cn/wiki/wikcn_new_node",
|
|
||||||
"message": "文档创建成功",
|
|
||||||
})
|
|
||||||
|
|
||||||
permStub := &httpmock.Stub{
|
|
||||||
Method: "POST",
|
|
||||||
URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 230001,
|
|
||||||
"msg": "no permission",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
reg.Register(permStub)
|
|
||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
|
||||||
"+create",
|
|
||||||
"--markdown", "## 内容",
|
|
||||||
"--wiki-space", "my_library",
|
|
||||||
"--as", "bot",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := decodeDocsCreateEnvelope(t, stdout)
|
|
||||||
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
||||||
if grant["status"] != common.PermissionGrantFailed {
|
|
||||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body map[string]interface{}
|
|
||||||
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
|
|
||||||
t.Fatalf("failed to parse permission request body: %v", err)
|
|
||||||
}
|
|
||||||
if body["perm_type"] != "container" {
|
|
||||||
t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||||
"doc_id": "doxcn_new_doc",
|
"document": map[string]interface{}{
|
||||||
"message": "文档创建成功",
|
"document_id": "doxcn_new_doc",
|
||||||
// "doc_url" deliberately omitted to exercise the fallback.
|
"revision_id": float64(1),
|
||||||
|
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
"+create",
|
"+create",
|
||||||
"--api-version", "v1",
|
"--api-version", "v1",
|
||||||
"--title", "项目计划",
|
"--content", "<title>项目计划</title>",
|
||||||
"--markdown", "## 目标",
|
|
||||||
"--as", "user",
|
"--as", "user",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data := decodeDocsCreateEnvelope(t, stdout)
|
data := decodeDocsCreateEnvelope(t, stdout)
|
||||||
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
|
doc, _ := data["document"].(map[string]interface{})
|
||||||
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
|
if got, want := doc["document_id"], "doxcn_new_doc"; got != want {
|
||||||
|
t.Fatalf("document.document_id = %#v, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
|
func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
|
||||||
"doc_id": "doxcn_new_doc",
|
|
||||||
"doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
|
|
||||||
"message": "文档创建成功",
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||||
"+create",
|
"+create",
|
||||||
"--api-version", "v1",
|
|
||||||
"--title", "项目计划",
|
"--title", "项目计划",
|
||||||
"--markdown", "## 目标",
|
"--markdown", "## 目标",
|
||||||
"--as", "user",
|
"--as", "user",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatal("expected legacy v1 flags to be rejected")
|
||||||
}
|
}
|
||||||
|
for _, want := range []string{
|
||||||
data := decodeDocsCreateEnvelope(t, stdout)
|
"docs +create is v2-only",
|
||||||
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
|
"the old v1 interface has been shut down",
|
||||||
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
"legacy v1 flag(s) --title, --markdown are no longer supported",
|
||||||
|
"--title -> put the title in --content",
|
||||||
|
"--markdown -> use --content with --doc-format markdown",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||||
|
"follow the latest format rules",
|
||||||
|
"MUST NOT grep/open local SKILL.md files",
|
||||||
|
"lark-cli docs +create --help",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error missing %q: %v", want, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,24 +335,6 @@ func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
|
|
||||||
payload, _ := json.Marshal(result)
|
|
||||||
reg.Register(&httpmock.Stub{
|
|
||||||
Method: "POST",
|
|
||||||
URL: "/mcp",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"content": []map[string]interface{}{
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": string(payload),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ import (
|
|||||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||||
func v2CreateFlags() []common.Flag {
|
func v2CreateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return []common.Flag{
|
||||||
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||||
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
|
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||||
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
|
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if runtime.Str("content") == "" {
|
if runtime.Str("content") == "" {
|
||||||
return common.FlagErrorf("--content is required")
|
return common.FlagErrorf("--content is required")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,40 +5,13 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
|
// v1FetchFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||||
func v1FetchFlags() []common.Flag {
|
func v1FetchFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return docsLegacyFlagDefinitions(docsFetchLegacyFlags())
|
||||||
{Name: "offset", Desc: "pagination offset", Hidden: true},
|
|
||||||
{Name: "limit", Desc: "pagination limit", Hidden: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
|
|
||||||
|
|
||||||
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
|
|
||||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by the
|
|
||||||
// presence of any v2-only flag on the command line — we check pflag.Changed
|
|
||||||
// rather than the value so that explicitly typing `--detail simple` (equal
|
|
||||||
// to the default) still routes to v2.
|
|
||||||
func useV2Fetch(runtime *common.RuntimeContext) bool {
|
|
||||||
if runtime.Str("api-version") == "v2" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, name := range []string{"detail", "doc-format", "scope", "revision-id", "start-block-id", "end-block-id", "keyword", "context-before", "context-after", "max-depth"} {
|
|
||||||
if runtime.Changed(name) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DocsFetch = common.Shortcut{
|
var DocsFetch = common.Shortcut{
|
||||||
@@ -49,88 +22,22 @@ var DocsFetch = common.Shortcut{
|
|||||||
Scopes: []string{"docx:document:readonly"},
|
Scopes: []string{"docx:document:readonly"},
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Tips: docsVersionSelectionTips,
|
PostMount: installDocsShortcutHelp("+fetch"),
|
||||||
Flags: concatFlags(
|
Flags: concatFlags(
|
||||||
[]common.Flag{
|
[]common.Flag{
|
||||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
docsAPIVersionCompatFlag(),
|
||||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
},
|
},
|
||||||
v1FetchFlags(),
|
|
||||||
v2FetchFlags(),
|
v2FetchFlags(),
|
||||||
|
v1FetchFlags(),
|
||||||
),
|
),
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Fetch(runtime) {
|
return validateFetchV2(ctx, runtime)
|
||||||
return validateFetchV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
if useV2Fetch(runtime) {
|
return dryRunFetchV2(ctx, runtime)
|
||||||
return dryRunFetchV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return dryRunFetchV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Fetch(runtime) {
|
return executeFetchV2(ctx, runtime)
|
||||||
return executeFetchV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return executeFetchV1(ctx, runtime)
|
|
||||||
},
|
|
||||||
PostMount: func(cmd *cobra.Command) {
|
|
||||||
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── V1 (MCP) implementation ──
|
|
||||||
|
|
||||||
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
||||||
args := buildFetchArgsV1(runtime)
|
|
||||||
return common.NewDryRunAPI().
|
|
||||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
|
||||||
Desc("MCP tool: fetch-doc").
|
|
||||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
|
|
||||||
Set("mcp_tool", "fetch-doc").Set("args", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
||||||
warnDeprecatedV1(runtime, "+fetch")
|
|
||||||
args := buildFetchArgsV1(runtime)
|
|
||||||
|
|
||||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if md, ok := result["markdown"].(string); ok {
|
|
||||||
result["markdown"] = fixExportedMarkdown(md)
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
|
||||||
if title, ok := result["title"].(string); ok && title != "" {
|
|
||||||
fmt.Fprintf(w, "# %s\n\n", title)
|
|
||||||
}
|
|
||||||
if md, ok := result["markdown"].(string); ok {
|
|
||||||
fmt.Fprintln(w, md)
|
|
||||||
}
|
|
||||||
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
|
|
||||||
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"doc_id": runtime.Str("doc"),
|
|
||||||
"skip_task_detail": true,
|
|
||||||
}
|
|
||||||
if v := runtime.Str("offset"); v != "" {
|
|
||||||
n, _ := strconv.Atoi(v)
|
|
||||||
args["offset"] = n
|
|
||||||
}
|
|
||||||
if v := runtime.Str("limit"); v != "" {
|
|
||||||
n, _ := strconv.Atoi(v)
|
|
||||||
args["limit"] = n
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ import (
|
|||||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||||
func v2FetchFlags() []common.Flag {
|
func v2FetchFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return []common.Flag{
|
||||||
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||||
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||||
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||||
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||||
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
|
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||||
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
|
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
|
||||||
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
|
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
|
||||||
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
|
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
|
||||||
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
|
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
|
||||||
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
|
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ func v2FetchFlags() []common.Flag {
|
|||||||
// --dry-run so that invalid input fails with a structured exit code (2) and
|
// --dry-run so that invalid input fails with a structured exit code (2) and
|
||||||
// JSON envelope instead of slipping through dry-run as a "success".
|
// JSON envelope instead of slipping through dry-run as a "success".
|
||||||
func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := validateDocsV2Only(runtime, "+fetch", docsFetchLegacyFlags()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||||
return common.FlagErrorf("invalid --doc: %v", err)
|
return common.FlagErrorf("invalid --doc: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -58,6 +59,82 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runtime := newFetchShortcutTestRuntime(t, "", nil)
|
||||||
|
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||||
|
t.Fatalf("validateFetchV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||||
|
if len(dry.API) != 1 {
|
||||||
|
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||||
|
}
|
||||||
|
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want {
|
||||||
|
t.Fatalf("dry-run URL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := dry.API[0].Body["format"], "xml"; got != want {
|
||||||
|
t.Fatalf("dry-run format = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runtime := newFetchShortcutTestRuntime(t, "v1", nil)
|
||||||
|
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||||
|
t.Fatalf("validateFetchV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||||
|
if len(dry.API) != 1 {
|
||||||
|
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||||
|
}
|
||||||
|
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want {
|
||||||
|
t.Fatalf("dry-run URL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setFlags map[string]string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "legacy offset",
|
||||||
|
setFlags: map[string]string{"offset": "10"},
|
||||||
|
want: []string{
|
||||||
|
"docs +fetch is v2-only",
|
||||||
|
"the old v1 interface has been shut down",
|
||||||
|
"legacy v1 flag(s) --offset are no longer supported",
|
||||||
|
"--offset -> use --scope outline/range/keyword/section",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||||
|
"MUST NOT grep/open local SKILL.md files",
|
||||||
|
"lark-cli docs +fetch --help",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
|
||||||
|
err := validateFetchV2(context.Background(), runtime)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected v2-only validation error")
|
||||||
|
}
|
||||||
|
for _, want := range tt.want {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error missing %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||||
cmd := &cobra.Command{Use: "+fetch"}
|
cmd := &cobra.Command{Use: "+fetch"}
|
||||||
cmd.Flags().String("doc-format", "xml", "")
|
cmd.Flags().String("doc-format", "xml", "")
|
||||||
@@ -73,6 +150,37 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
|||||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := &cobra.Command{Use: "+fetch"}
|
||||||
|
cmd.Flags().String("api-version", "", "")
|
||||||
|
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||||
|
cmd.Flags().String("doc-format", "xml", "")
|
||||||
|
cmd.Flags().String("detail", "simple", "")
|
||||||
|
cmd.Flags().Int("revision-id", -1, "")
|
||||||
|
cmd.Flags().String("scope", "full", "")
|
||||||
|
cmd.Flags().String("start-block-id", "", "")
|
||||||
|
cmd.Flags().String("end-block-id", "", "")
|
||||||
|
cmd.Flags().String("keyword", "", "")
|
||||||
|
cmd.Flags().Int("context-before", 0, "")
|
||||||
|
cmd.Flags().Int("context-after", 0, "")
|
||||||
|
cmd.Flags().Int("max-depth", -1, "")
|
||||||
|
cmd.Flags().String("offset", "", "")
|
||||||
|
cmd.Flags().String("limit", "", "")
|
||||||
|
if apiVersion != "" {
|
||||||
|
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
|
||||||
|
t.Fatalf("set api-version: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, value := range setFlags {
|
||||||
|
if err := cmd.Flags().Set(name, value); err != nil {
|
||||||
|
t.Fatalf("set %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return common.TestNewRuntimeContext(cmd, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||||
cmd := &cobra.Command{Use: "+create"}
|
cmd := &cobra.Command{Use: "+create"}
|
||||||
cmd.Flags().String("doc-format", "xml", "")
|
cmd.Flags().String("doc-format", "xml", "")
|
||||||
|
|||||||
@@ -5,57 +5,13 @@ package doc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validModesV1 = map[string]bool{
|
// v1UpdateFlags returns hidden parse-only compatibility flags for old v1 commands.
|
||||||
"append": true,
|
|
||||||
"overwrite": true,
|
|
||||||
"replace_range": true,
|
|
||||||
"replace_all": true,
|
|
||||||
"insert_before": true,
|
|
||||||
"insert_after": true,
|
|
||||||
"delete_range": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var needsSelectionV1 = map[string]bool{
|
|
||||||
"replace_range": true,
|
|
||||||
"replace_all": true,
|
|
||||||
"insert_before": true,
|
|
||||||
"insert_after": true,
|
|
||||||
"delete_range": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
|
|
||||||
func v1UpdateFlags() []common.Flag {
|
func v1UpdateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
|
||||||
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
|
|
||||||
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
|
||||||
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
|
|
||||||
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
|
|
||||||
{Name: "new-title", Desc: "also update document title", Hidden: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
|
|
||||||
|
|
||||||
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
|
|
||||||
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
|
|
||||||
func useV2Update(runtime *common.RuntimeContext) bool {
|
|
||||||
if runtime.Str("api-version") == "v2" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return runtime.Str("command") != "" ||
|
|
||||||
runtime.Str("content") != "" ||
|
|
||||||
runtime.Str("pattern") != "" ||
|
|
||||||
runtime.Str("block-id") != "" ||
|
|
||||||
runtime.Str("src-block-ids") != ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var DocsUpdate = common.Shortcut{
|
var DocsUpdate = common.Shortcut{
|
||||||
@@ -65,225 +21,22 @@ var DocsUpdate = common.Shortcut{
|
|||||||
Risk: "write",
|
Risk: "write",
|
||||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Tips: docsVersionSelectionTips,
|
PostMount: installDocsShortcutHelp("+update"),
|
||||||
Flags: concatFlags(
|
Flags: concatFlags(
|
||||||
[]common.Flag{
|
[]common.Flag{
|
||||||
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
|
docsAPIVersionCompatFlag(),
|
||||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
},
|
},
|
||||||
v1UpdateFlags(),
|
|
||||||
v2UpdateFlags(),
|
v2UpdateFlags(),
|
||||||
|
v1UpdateFlags(),
|
||||||
),
|
),
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Update(runtime) {
|
return validateUpdateV2(ctx, runtime)
|
||||||
return validateUpdateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return validateUpdateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
if useV2Update(runtime) {
|
return dryRunUpdateV2(ctx, runtime)
|
||||||
return dryRunUpdateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return dryRunUpdateV1(ctx, runtime)
|
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if useV2Update(runtime) {
|
return executeUpdateV2(ctx, runtime)
|
||||||
return executeUpdateV2(ctx, runtime)
|
|
||||||
}
|
|
||||||
return executeUpdateV1(ctx, runtime)
|
|
||||||
},
|
|
||||||
PostMount: func(cmd *cobra.Command) {
|
|
||||||
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── V1 (MCP) implementation ──
|
|
||||||
|
|
||||||
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
||||||
mode := runtime.Str("mode")
|
|
||||||
if mode == "" {
|
|
||||||
return common.FlagErrorf("--mode is required")
|
|
||||||
}
|
|
||||||
if !validModesV1[mode] {
|
|
||||||
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if mode != "delete_range" && runtime.Str("markdown") == "" {
|
|
||||||
return common.FlagErrorf("--%s mode requires --markdown", mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
selEllipsis := runtime.Str("selection-with-ellipsis")
|
|
||||||
selTitle := runtime.Str("selection-by-title")
|
|
||||||
if selEllipsis != "" && selTitle != "" {
|
|
||||||
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
|
|
||||||
return common.FlagErrorf(selectionRequiredMessageV1(mode))
|
|
||||||
}
|
|
||||||
if err := validateSelectionByTitleV1(selTitle); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectionRequiredMessageV1(mode string) string {
|
|
||||||
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
|
|
||||||
if mode == "replace_all" {
|
|
||||||
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSelectionByTitleV1(title string) error {
|
|
||||||
if title == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
trimmed := strings.TrimSpace(title)
|
|
||||||
if strings.Contains(trimmed, "\n") || strings.Contains(trimmed, "\r") {
|
|
||||||
return common.FlagErrorf("--selection-by-title must be a single heading line (for example: '## Section')")
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(trimmed, "#") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return common.FlagErrorf("--selection-by-title must include markdown heading prefix '#'. Example: --selection-by-title '## Section'")
|
|
||||||
}
|
|
||||||
|
|
||||||
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
||||||
args := buildUpdateArgsV1(runtime)
|
|
||||||
return common.NewDryRunAPI().
|
|
||||||
POST(common.MCPEndpoint(runtime.Config.Brand)).
|
|
||||||
Desc("MCP tool: update-doc").
|
|
||||||
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
|
|
||||||
Set("mcp_tool", "update-doc").Set("args", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
|
|
||||||
warnDeprecatedV1(runtime, "+update")
|
|
||||||
|
|
||||||
// Static semantic checks run before the MCP call so users see
|
|
||||||
// warnings even if the subsequent request fails. They never block
|
|
||||||
// execution — the update still proceeds.
|
|
||||||
for _, w := range docsUpdateWarnings(runtime.Str("mode"), runtime.Str("markdown")) {
|
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrite replaces the entire document, silently discarding any
|
|
||||||
// whiteboard or file-attachment blocks that cannot be re-created from
|
|
||||||
// Markdown. Pre-fetch the current content and warn when such blocks
|
|
||||||
// are present so the caller can take a backup before proceeding.
|
|
||||||
if runtime.Str("mode") == "overwrite" {
|
|
||||||
if w := warnOverwriteResourceBlocks(runtime); w != "" {
|
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Surface callout type= hint so users know to switch to background-color/
|
|
||||||
// border-color when they want a colored callout. Non-blocking, advisory.
|
|
||||||
if md := runtime.Str("markdown"); md != "" {
|
|
||||||
WarnCalloutType(md, runtime.IO().ErrOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
args := buildUpdateArgsV1(runtime)
|
|
||||||
|
|
||||||
result, err := common.CallMCPTool(runtime, "update-doc", args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeWhiteboardResult(result, runtime.Str("markdown"))
|
|
||||||
runtime.Out(result, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"doc_id": runtime.Str("doc"),
|
|
||||||
"mode": runtime.Str("mode"),
|
|
||||||
}
|
|
||||||
if v := runtime.Str("markdown"); v != "" {
|
|
||||||
args["markdown"] = v
|
|
||||||
}
|
|
||||||
if v := runtime.Str("selection-with-ellipsis"); v != "" {
|
|
||||||
args["selection_with_ellipsis"] = v
|
|
||||||
}
|
|
||||||
if v := runtime.Str("selection-by-title"); v != "" {
|
|
||||||
args["selection_by_title"] = v
|
|
||||||
}
|
|
||||||
if v := runtime.Str("new-title"); v != "" {
|
|
||||||
args["new_title"] = v
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
|
|
||||||
// (followed by whitespace, > or /) to avoid false positives on tag names like
|
|
||||||
// <file-view> or prose that merely mentions the word "whiteboard".
|
|
||||||
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
|
|
||||||
|
|
||||||
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
|
|
||||||
// non-empty warning string when the document contains whiteboard or file
|
|
||||||
// attachment blocks that would be permanently deleted by an overwrite. Returns
|
|
||||||
// an empty string (no warning) when the document is clean or the fetch fails
|
|
||||||
// (we never block the overwrite on a best-effort check).
|
|
||||||
//
|
|
||||||
// This function is not unit-tested because it depends on an external MCP call
|
|
||||||
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
|
|
||||||
// which has full table-driven coverage.
|
|
||||||
//
|
|
||||||
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
|
|
||||||
// call, even when the document has no resource blocks. The cost is intentional:
|
|
||||||
// the guard is best-effort and silent on failure, so the latency is bounded and
|
|
||||||
// the trade-off is acceptable to avoid silent data loss.
|
|
||||||
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"doc_id": runtime.Str("doc"),
|
|
||||||
// skip_task_detail reduces response payload by omitting per-block task
|
|
||||||
// metadata, making the pre-fetch faster and cheaper.
|
|
||||||
"skip_task_detail": true,
|
|
||||||
}
|
|
||||||
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
|
|
||||||
if err != nil {
|
|
||||||
// Fetch failed — silently skip the guard rather than blocking overwrite.
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
md, _ := result["markdown"].(string)
|
|
||||||
return checkOverwriteResourceBlocks(md)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
|
|
||||||
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
|
|
||||||
// warning string listing the counts if any are found, empty string otherwise.
|
|
||||||
func checkOverwriteResourceBlocks(markdown string) string {
|
|
||||||
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
|
|
||||||
whiteboards, files := 0, 0
|
|
||||||
for _, m := range matches {
|
|
||||||
switch m[1] {
|
|
||||||
case "whiteboard":
|
|
||||||
whiteboards++
|
|
||||||
case "file":
|
|
||||||
files++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var found []string
|
|
||||||
if whiteboards == 1 {
|
|
||||||
found = append(found, "1 whiteboard block")
|
|
||||||
} else if whiteboards > 1 {
|
|
||||||
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
|
|
||||||
}
|
|
||||||
if files == 1 {
|
|
||||||
found = append(found, "1 file attachment block")
|
|
||||||
} else if files > 1 {
|
|
||||||
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
|
|
||||||
}
|
|
||||||
if len(found) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"the document contains %s that cannot be reconstructed from Markdown; "+
|
|
||||||
"overwrite will permanently delete them. "+
|
|
||||||
"Consider fetching a backup with `docs +fetch` before overwriting.",
|
|
||||||
strings.Join(found, " and "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// docsUpdateWarnings returns a list of human-readable warnings for a
|
|
||||||
// `docs +update` invocation based on static analysis of the mode and
|
|
||||||
// Markdown payload. The warnings describe CLI/MCP contract edges that
|
|
||||||
// commonly surprise users; the update is still executed — callers
|
|
||||||
// decide whether to stop at a warning.
|
|
||||||
//
|
|
||||||
// Both checks ignore fenced code blocks (```…``` and ~~~…~~~, with up
|
|
||||||
// to 3 leading spaces per CommonMark §4.5), inline code spans, and
|
|
||||||
// backslash-escaped emphasis markers so that literal Markdown content
|
|
||||||
// embedded in code samples or escaped prose does not produce false
|
|
||||||
// positives.
|
|
||||||
//
|
|
||||||
// Warnings emitted (current):
|
|
||||||
//
|
|
||||||
// 1. replace_* modes do not split blocks. A Markdown payload containing
|
|
||||||
// a blank line (\n\n) in prose implies the caller expects multiple
|
|
||||||
// paragraphs, but replace_range / replace_all only swap in-block
|
|
||||||
// text. The resulting block will contain the blank line as literal
|
|
||||||
// text and appear as a single paragraph in the UI.
|
|
||||||
//
|
|
||||||
// 2. Lark does not round-trip bold+italic. Six shapes are detected:
|
|
||||||
// ***text*** ___text___
|
|
||||||
// **_text_** __*text*__
|
|
||||||
// _**text**_ *__text__*
|
|
||||||
// Lark stores only one of the two emphases (usually italic), silently
|
|
||||||
// dropping the other. The user wanted both; they will get one.
|
|
||||||
func docsUpdateWarnings(mode, markdown string) []string {
|
|
||||||
var warnings []string
|
|
||||||
if w := checkDocsUpdateReplaceMultilineMarkdown(mode, markdown); w != "" {
|
|
||||||
warnings = append(warnings, w)
|
|
||||||
}
|
|
||||||
if w := checkDocsUpdateBoldItalic(markdown); w != "" {
|
|
||||||
warnings = append(warnings, w)
|
|
||||||
}
|
|
||||||
return warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDocsUpdateReplaceMultilineMarkdown flags markdown that contains a
|
|
||||||
// blank-line paragraph break outside fenced code blocks under a replace_*
|
|
||||||
// mode. Blank lines inside code fences are literal content and don't
|
|
||||||
// imply paragraph semantics, so they are deliberately ignored.
|
|
||||||
func checkDocsUpdateReplaceMultilineMarkdown(mode, markdown string) string {
|
|
||||||
if mode != "replace_range" && mode != "replace_all" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// A CR/LF-robust check: both "\n\n" and "\r\n\r\n" count as paragraph
|
|
||||||
// separators. We normalize line endings once before detection.
|
|
||||||
normalized := strings.ReplaceAll(markdown, "\r\n", "\n")
|
|
||||||
if !proseHasBlankLine(normalized) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "--mode=" + mode + " does not split a block into multiple paragraphs; " +
|
|
||||||
"the blank line in --markdown will render as literal text. " +
|
|
||||||
"For multiple paragraphs, use --mode=delete_range followed by --mode=insert_before."
|
|
||||||
}
|
|
||||||
|
|
||||||
// combinedEmphasisPatterns holds the six documented combined-emphasis shapes
|
|
||||||
// that Lark downgrades to a single emphasis. Each entry pairs a regex with a
|
|
||||||
// short shape label for the warning message. The two forms per shape (with
|
|
||||||
// and without `[^…]*?`) are there because the lazy quantifier needs at least
|
|
||||||
// one non-delimiter character to match; single-rune payloads (e.g. `***X***`)
|
|
||||||
// take the second alternation.
|
|
||||||
var combinedEmphasisPatterns = []struct {
|
|
||||||
shape string
|
|
||||||
re *regexp.Regexp
|
|
||||||
}{
|
|
||||||
// Bold+italic with a single delimiter char.
|
|
||||||
{"***text***", regexp.MustCompile(`\*\*\*\S[^*]*?\S\*\*\*|\*\*\*\S\*\*\*`)},
|
|
||||||
{"___text___", regexp.MustCompile(`___\S[^_]*?\S___|___\S___`)},
|
|
||||||
|
|
||||||
// Bold wrapping italic (asterisk outside).
|
|
||||||
{"**_text_**", regexp.MustCompile(`\*\*_\S[^_*]*?\S_\*\*|\*\*_\S_\*\*`)},
|
|
||||||
{"__*text*__", regexp.MustCompile(`__\*\S[^_*]*?\S\*__|__\*\S\*__`)},
|
|
||||||
|
|
||||||
// Italic wrapping bold (asterisk inside).
|
|
||||||
{"_**text**_", regexp.MustCompile(`_\*\*\S[^_*]*?\S\*\*_|_\*\*\S\*\*_`)},
|
|
||||||
{"*__text__*", regexp.MustCompile(`\*__\S[^_*]*?\S__\*|\*__\S__\*`)},
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDocsUpdateBoldItalic flags Markdown emphases that attempt to
|
|
||||||
// combine bold and italic in a way Lark cannot represent. Fenced code
|
|
||||||
// blocks, inline code spans, and backslash-escaped emphasis markers are
|
|
||||||
// stripped first so that literal markdown examples ("here is a
|
|
||||||
// `***keyword***` to flag") do not trigger the warning.
|
|
||||||
func checkDocsUpdateBoldItalic(markdown string) string {
|
|
||||||
if markdown == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sanitized := stripEscapedEmphasisMarkers(stripMarkdownCodeRegions(markdown))
|
|
||||||
for _, p := range combinedEmphasisPatterns {
|
|
||||||
if p.re.MatchString(sanitized) {
|
|
||||||
return "Lark does not support combined bold+italic markers " +
|
|
||||||
"(e.g. ***text***, ___text___, **_text_**, _**text**_, __*text*__, *__text__*); " +
|
|
||||||
"the emphasis will be downgraded to either bold or italic. " +
|
|
||||||
"Split into two separate emphases or drop one of them."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// proseHasBlankLine reports whether markdown contains a blank line outside
|
|
||||||
// of fenced code blocks. Blank lines inside ```...``` or ~~~...~~~ fences
|
|
||||||
// are code content, not paragraph separators, and must not trip the
|
|
||||||
// "replace_* cannot split paragraphs" warning.
|
|
||||||
//
|
|
||||||
// A blank line counts only when it sits between two non-blank boundaries
|
|
||||||
// (other prose, or a fence open/close). A trailing empty line at EOF is
|
|
||||||
// not treated as "\n\n".
|
|
||||||
func proseHasBlankLine(markdown string) bool {
|
|
||||||
lines := strings.Split(markdown, "\n")
|
|
||||||
inFence := false
|
|
||||||
var fenceMarker string
|
|
||||||
for i, line := range lines {
|
|
||||||
if inFence {
|
|
||||||
if isCodeFenceClose(line, fenceMarker) {
|
|
||||||
inFence = false
|
|
||||||
fenceMarker = ""
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
|
||||||
inFence = true
|
|
||||||
fenceMarker = marker
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(line) == "" && i > 0 && i+1 < len(lines) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripMarkdownCodeRegions returns markdown with fenced code blocks blanked
|
|
||||||
// out and inline code spans replaced by whitespace of equivalent length.
|
|
||||||
// Byte offsets outside the masked regions are preserved, so follow-on
|
|
||||||
// regex matches still point at real prose positions.
|
|
||||||
func stripMarkdownCodeRegions(markdown string) string {
|
|
||||||
lines := strings.Split(markdown, "\n")
|
|
||||||
inFence := false
|
|
||||||
var fenceMarker string
|
|
||||||
for i, line := range lines {
|
|
||||||
if inFence {
|
|
||||||
if isCodeFenceClose(line, fenceMarker) {
|
|
||||||
inFence = false
|
|
||||||
fenceMarker = ""
|
|
||||||
}
|
|
||||||
lines[i] = ""
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
|
||||||
inFence = true
|
|
||||||
fenceMarker = marker
|
|
||||||
lines[i] = ""
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines[i] = maskInlineCodeSpans(line)
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskInlineCodeSpans replaces the byte ranges of any inline code spans in
|
|
||||||
// line with space characters of equal length. Uses scanInlineCodeSpans from
|
|
||||||
// markdown_fix.go, which implements the CommonMark §6.1 matching-backtick-run
|
|
||||||
// rule (so “ `a`b` “ is a single span).
|
|
||||||
func maskInlineCodeSpans(line string) string {
|
|
||||||
spans := scanInlineCodeSpans(line)
|
|
||||||
if len(spans) == 0 {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
pos := 0
|
|
||||||
for _, loc := range spans {
|
|
||||||
sb.WriteString(line[pos:loc[0]])
|
|
||||||
sb.WriteString(strings.Repeat(" ", loc[1]-loc[0]))
|
|
||||||
pos = loc[1]
|
|
||||||
}
|
|
||||||
sb.WriteString(line[pos:])
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripEscapedEmphasisMarkers removes backslash-escaped '*' and '_' so the
|
|
||||||
// bold/italic regexes don't treat literal sequences like `\***text***` as
|
|
||||||
// real combined emphasis. CommonMark renders "\*" as a literal "*" with no
|
|
||||||
// emphasis semantics; dropping the escape + its target from the detection
|
|
||||||
// input keeps the heuristic aligned with what the renderer actually does.
|
|
||||||
//
|
|
||||||
// Known limitation: a doubled backslash escape ("\\" followed by a real
|
|
||||||
// emphasis marker, e.g. `\\***text***`) renders as a literal backslash
|
|
||||||
// followed by genuine combined emphasis, but this strip is not a proper
|
|
||||||
// parser and will instead consume the second backslash as the opener for
|
|
||||||
// another escape. That hides the real emphasis from the check, producing
|
|
||||||
// a false negative. Practical impact is small (this shape is rare in the
|
|
||||||
// kind of AI-Agent prompts we target) and the alternative — a full
|
|
||||||
// CommonMark escape parser — is not worth the code surface here.
|
|
||||||
func stripEscapedEmphasisMarkers(s string) string {
|
|
||||||
s = strings.ReplaceAll(s, `\*`, "")
|
|
||||||
s = strings.ReplaceAll(s, `\_`, "")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// codeFenceOpenMarker returns the fence marker (e.g. "```" or "~~~~") if
|
|
||||||
// line opens a fenced code block, otherwise "". Applies CommonMark §4.5
|
|
||||||
// rules: up to 3 leading spaces are tolerated; 4+ leading spaces (or any
|
|
||||||
// leading tab, which expands to 4 columns) make the line an indented code
|
|
||||||
// block rather than a fence.
|
|
||||||
func codeFenceOpenMarker(line string) string {
|
|
||||||
body, ok := fenceIndentOK(line)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(body, "```"):
|
|
||||||
return leadingRun(body, '`')
|
|
||||||
case strings.HasPrefix(body, "~~~"):
|
|
||||||
return leadingRun(body, '~')
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// isCodeFenceClose reports whether line closes a fence opened with marker.
|
|
||||||
// Per CommonMark §4.5 the closer must use the same fence character, be at
|
|
||||||
// least as long as the opener, sit within 0..3 leading spaces, and carry
|
|
||||||
// no info-string text.
|
|
||||||
func isCodeFenceClose(line, marker string) bool {
|
|
||||||
if marker == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
body, ok := fenceIndentOK(line)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
fenceChar := marker[0]
|
|
||||||
run := leadingRun(body, fenceChar)
|
|
||||||
if len(run) < len(marker) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(body[len(run):]) == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// fenceIndentOK returns (bodyWithoutLeadingSpaces, true) when line has
|
|
||||||
// 0..3 leading spaces and no leading tab — i.e. the indentation is
|
|
||||||
// permissible for a CommonMark fence. Returns ("", false) otherwise
|
|
||||||
// (4+ leading spaces or any tab), meaning the line must be treated as
|
|
||||||
// indented code block content rather than a fence boundary.
|
|
||||||
func fenceIndentOK(line string) (string, bool) {
|
|
||||||
for i := 0; i < len(line) && i < 4; i++ {
|
|
||||||
switch line[i] {
|
|
||||||
case ' ':
|
|
||||||
continue
|
|
||||||
case '\t':
|
|
||||||
return "", false
|
|
||||||
default:
|
|
||||||
return line[i:], true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reached index 4 without hitting a non-space character: too indented.
|
|
||||||
if len(line) >= 4 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
// Line shorter than 4 chars and all spaces — still valid (empty content).
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
|
|
||||||
// leadingRun returns the longest prefix of s made up of the byte c.
|
|
||||||
func leadingRun(s string, c byte) string {
|
|
||||||
i := 0
|
|
||||||
for i < len(s) && s[i] == c {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return s[:i]
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCheckDocsUpdateReplaceMultilineMarkdown(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mode string
|
|
||||||
markdown string
|
|
||||||
wantHint bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "replace_range with blank line emits hint",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "new paragraph\n\nsecond paragraph",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "replace_all with blank line emits hint",
|
|
||||||
mode: "replace_all",
|
|
||||||
markdown: "first\n\nsecond",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "replace_range single paragraph is fine",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "just a single paragraph of text",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single newline is not a paragraph break",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "line one\nline two",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "crlf paragraph break is also detected",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "first\r\n\r\nsecond",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "other modes are not flagged",
|
|
||||||
mode: "insert_before",
|
|
||||||
markdown: "first\n\nsecond",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "append mode is not flagged",
|
|
||||||
mode: "append",
|
|
||||||
markdown: "first\n\nsecond",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty markdown is fine",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The check must ignore blank lines inside fenced code; otherwise
|
|
||||||
// a user replacing one block with a legitimate code sample that
|
|
||||||
// contains blank lines would see a spurious warning.
|
|
||||||
name: "blank line inside backtick fenced code is not flagged",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "```\nline1\n\nline2\n```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "blank line inside tilde fenced code is not flagged",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "~~~\ncode line one\n\ncode line two\n~~~",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Mixed prose + fenced code: any blank line in prose still wins,
|
|
||||||
// even if the fenced content also contains blanks.
|
|
||||||
name: "blank line in prose outside fence still flags even when fence has blanks",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "first paragraph\n\nsecond paragraph\n\n```\ncode\n\nmore\n```",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Fenced code with no blank lines inside must not trip on the
|
|
||||||
// fence markers themselves.
|
|
||||||
name: "fenced code with no blank lines does not flag",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "prose before\n```go\nfmt.Println(\"hi\")\n```\nprose after",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// CommonMark §4.5: the closing fence must be ≥ opening fence length.
|
|
||||||
// A 4-backtick close for a 3-backtick open is a legitimate way to
|
|
||||||
// embed triple-backticks in a code sample; the check must see the
|
|
||||||
// fence as properly closed and not treat the rest of the document
|
|
||||||
// as still-inside-fence.
|
|
||||||
name: "longer close marker closes fence correctly",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "```\nsome code\n````\n\nprose paragraph after",
|
|
||||||
wantHint: true, // the blank line AFTER the fence is real prose
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "longer close marker still hides blank line inside fence",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "```\nbefore\n\nafter\n````",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 4+ leading spaces make the line an indented code block, not a
|
|
||||||
// fence open. The "fence"-looking line is code content; the
|
|
||||||
// surrounding blank must still be detected.
|
|
||||||
name: "four-space indented fence-like line is not a fence open",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "first paragraph\n\n ```\n code\n ```",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A tab in the leading whitespace is always ≥4 columns and thus
|
|
||||||
// forces indented-code-block semantics.
|
|
||||||
name: "tab-indented fence-like line is not a fence open",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: "first paragraph\n\n\t```\n\tcode\n\t```",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 3 leading spaces is still within the fence-tolerance window.
|
|
||||||
name: "three-space indented fence is still a fence",
|
|
||||||
mode: "replace_range",
|
|
||||||
markdown: " ```\ncode\n\nmore\n ```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
got := checkDocsUpdateReplaceMultilineMarkdown(tt.mode, tt.markdown)
|
|
||||||
hasHint := got != ""
|
|
||||||
if hasHint != tt.wantHint {
|
|
||||||
t.Fatalf("checkDocsUpdateReplaceMultilineMarkdown(%q, %q) = %q, wantHint=%v",
|
|
||||||
tt.mode, tt.markdown, got, tt.wantHint)
|
|
||||||
}
|
|
||||||
if tt.wantHint && (!strings.Contains(got, "delete_range") || !strings.Contains(got, "insert_before")) {
|
|
||||||
t.Errorf("hint should suggest delete_range/insert_before remediation, got: %s", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckDocsUpdateBoldItalic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantHint bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "triple asterisks flagged",
|
|
||||||
input: "a ***key insight*** here",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "triple asterisks single char flagged",
|
|
||||||
input: "a ***X*** here",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bold wrapping underscore italic flagged",
|
|
||||||
input: "note: **_important_** detail",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underscore wrapping double asterisk flagged",
|
|
||||||
input: "note: _**important**_ detail",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain bold is fine",
|
|
||||||
input: "this is **bold** text",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "plain italic is fine",
|
|
||||||
input: "this is *italic* or _italic_ text",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "horizontal rule is not flagged",
|
|
||||||
input: "paragraph\n\n---\n\nnext",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bold followed by italic with space is not flagged",
|
|
||||||
input: "**bold** and *italic*",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input is fine",
|
|
||||||
input: "",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The emphasis check must not fire on literal Markdown samples
|
|
||||||
// inside a fenced code block — the canonical use case is docs
|
|
||||||
// authors pasting tutorials that demonstrate these exact patterns.
|
|
||||||
name: "triple asterisks inside backtick fenced code is not flagged",
|
|
||||||
input: "example:\n```\nthe shape ***keyword*** downgrades\n```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underscore-bold inside fenced code is not flagged",
|
|
||||||
input: "example:\n```markdown\nuse **_strong italic_** carefully\n```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bold-underscore inside fenced code is not flagged",
|
|
||||||
input: "example:\n~~~\n_**outside-underscore**_ is a bad shape\n~~~",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "triple asterisks inside inline code span is not flagged",
|
|
||||||
input: "the literal `***text***` marker is just a sample",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underscore-bold inside inline code is not flagged",
|
|
||||||
input: "the shape `**_italic_**` would downgrade, but only if it were real",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "escaped triple asterisks rendered as literal text is not flagged",
|
|
||||||
input: `the literal \***text*** with escaped opener`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "escaped bold inside underscore-italic is not flagged",
|
|
||||||
input: `shape \*\*_text_\*\* is literal, not emphasis`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Real emphasis outside the code span must still be detected —
|
|
||||||
// the strip step must not over-sanitize.
|
|
||||||
name: "real triple asterisks outside inline code still flags",
|
|
||||||
input: "real ***strong*** and literal `***keyword***` — the first one counts",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "real triple asterisks outside fenced code still flags",
|
|
||||||
input: "real ***strong***\n\n```\nliteral ***keyword*** in code\n```",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
// --- Triple-underscore combined emphasis: ___text___ ---
|
|
||||||
{
|
|
||||||
name: "triple underscores flagged",
|
|
||||||
input: "a ___key insight___ here",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "triple underscores single char flagged",
|
|
||||||
input: "a ___X___ here",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "triple underscores inside fenced code not flagged",
|
|
||||||
input: "sample:\n```\nuse ___keyword___ carefully\n```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "triple underscores inside inline code not flagged",
|
|
||||||
input: "the literal `___phrase___` marker",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "escaped triple underscores not flagged",
|
|
||||||
input: `literal \___phrase___ with escaped opener`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
// --- Underscore-bold wrapping asterisk-italic: __*text*__ ---
|
|
||||||
{
|
|
||||||
name: "underscore-bold wrapping asterisk-italic flagged",
|
|
||||||
input: "note: __*important*__ text",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underscore-bold wrapping asterisk-italic inside fenced code not flagged",
|
|
||||||
input: "```\nnote: __*important*__ sample\n```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underscore-bold wrapping asterisk-italic inside inline code not flagged",
|
|
||||||
input: "literal `__*important*__` marker",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
// --- Asterisk-italic wrapping underscore-bold: *__text__* ---
|
|
||||||
{
|
|
||||||
name: "asterisk-italic wrapping underscore-bold flagged",
|
|
||||||
input: "note: *__phrase__* text",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "asterisk-italic wrapping underscore-bold inside fenced code not flagged",
|
|
||||||
input: "```md\nnote: *__phrase__* sample\n```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
// --- Positive tests: real emphasis in prose coexisting with fake in code ---
|
|
||||||
{
|
|
||||||
// Underscore-variant in prose must still fire when an asterisk
|
|
||||||
// variant appears inside a code span — verifies the strip does
|
|
||||||
// not over-sanitize across the six regex alternatives.
|
|
||||||
name: "real triple underscores outside inline code still flag when asterisk variant is in code",
|
|
||||||
input: "real ___strong___ and literal `***shape***` in code",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Longer close fence closes properly; real ***emphasis*** after
|
|
||||||
// the fence must fire.
|
|
||||||
name: "real emphasis after a fence closed by longer marker still flags",
|
|
||||||
input: "```\nliteral ***phrase*** in code\n````\n\nand then real ***phrase*** after",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 4-space indented "```" is an indented code block, not a fence
|
|
||||||
// open. The fence helper should refuse it; emphasis outside the
|
|
||||||
// (non-existent) fence must still be detected.
|
|
||||||
name: "four-space indented fence-like line does not open a fence for the emphasis check",
|
|
||||||
input: "prose\n\n ```\n not a fence\n ```\n\nreal ***strong*** here",
|
|
||||||
wantHint: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 3-space indented fence is valid per CommonMark. Emphasis inside
|
|
||||||
// must be sanitized away, so the check must not fire.
|
|
||||||
name: "three-space indented fence still hides triple-asterisk inside",
|
|
||||||
input: " ```\n literal ***text*** inside\n ```",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
got := checkDocsUpdateBoldItalic(tt.input)
|
|
||||||
hasHint := got != ""
|
|
||||||
if hasHint != tt.wantHint {
|
|
||||||
t.Fatalf("checkDocsUpdateBoldItalic(%q) = %q, wantHint=%v", tt.input, got, tt.wantHint)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocsUpdateWarningsAggregates(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Both flags trigger: replace_range with blank line AND triple-asterisk.
|
|
||||||
warnings := docsUpdateWarnings("replace_range", "***opening***\n\nsecond paragraph")
|
|
||||||
if len(warnings) != 2 {
|
|
||||||
t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDocsUpdateWarningsEmpty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Clean markdown in a non-replace mode produces zero warnings.
|
|
||||||
warnings := docsUpdateWarnings("insert_before", "plain paragraph text")
|
|
||||||
if len(warnings) != 0 {
|
|
||||||
t.Fatalf("expected no warnings, got: %v", warnings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,12 @@
|
|||||||
package doc
|
package doc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── V2 tests ──
|
// ── V2 tests ──
|
||||||
@@ -31,199 +34,102 @@ func TestValidCommandsV2(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── V1 tests ──
|
func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) {
|
||||||
|
for _, apiVersion := range []string{"v1", "v2"} {
|
||||||
|
t.Run(apiVersion, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
|
runtime := newUpdateShortcutTestRuntime(t, apiVersion, nil)
|
||||||
t.Parallel()
|
if err := validateUpdateV2(context.Background(), runtime); err != nil {
|
||||||
|
t.Fatalf("validateUpdateV2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
msg := selectionRequiredMessageV1("replace_all")
|
dry := decodeDocDryRun(t, DocsUpdate.DryRun(context.Background(), runtime))
|
||||||
for _, needle := range []string{
|
if len(dry.API) != 1 {
|
||||||
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
|
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
|
||||||
"replace the entire document body",
|
}
|
||||||
"--mode overwrite",
|
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnUpdateDryRun"; got != want {
|
||||||
} {
|
t.Fatalf("dry-run URL = %q, want %q", got, want)
|
||||||
if !strings.Contains(msg, needle) {
|
}
|
||||||
t.Fatalf("message missing %q: %s", needle, msg)
|
if got, want := dry.API[0].Body["command"], "block_insert_after"; got != want {
|
||||||
}
|
t.Fatalf("dry-run command = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := dry.API[0].Body["block_id"], "-1"; got != want {
|
||||||
|
t.Fatalf("dry-run block_id = %#v, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
|
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
msg := selectionRequiredMessageV1("replace_range")
|
|
||||||
if strings.Contains(msg, "--mode overwrite") {
|
|
||||||
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
|
|
||||||
}
|
|
||||||
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
|
|
||||||
t.Fatalf("unexpected message: %s", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
|
|
||||||
t.Run("blank whiteboard tags", func(t *testing.T) {
|
|
||||||
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
|
|
||||||
if !isWhiteboardCreateMarkdown(markdown) {
|
|
||||||
t.Fatalf("expected blank whiteboard markdown to be treated as whiteboard creation")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("mermaid code block", func(t *testing.T) {
|
|
||||||
markdown := "```mermaid\ngraph TD\nA-->B\n```"
|
|
||||||
if !isWhiteboardCreateMarkdown(markdown) {
|
|
||||||
t.Fatalf("expected mermaid markdown to be treated as whiteboard creation")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("plain markdown", func(t *testing.T) {
|
|
||||||
markdown := "## plain text"
|
|
||||||
if isWhiteboardCreateMarkdown(markdown) {
|
|
||||||
t.Fatalf("did not expect plain markdown to be treated as whiteboard creation")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckOverwriteResourceBlocks(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
markdown string
|
setFlags map[string]string
|
||||||
wantWarn bool
|
want []string
|
||||||
wantSubs []string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty markdown is clean",
|
name: "legacy mode",
|
||||||
markdown: "",
|
setFlags: map[string]string{"mode": "overwrite"},
|
||||||
wantWarn: false,
|
want: []string{
|
||||||
},
|
"docs +update is v2-only",
|
||||||
{
|
"the old v1 interface has been shut down",
|
||||||
name: "plain prose is clean",
|
"legacy v1 flag(s) --mode are no longer supported",
|
||||||
markdown: "## Heading\n\nsome text",
|
"--mode -> use --command",
|
||||||
wantWarn: false,
|
"lark-cli skills read lark-doc references/lark-doc-update.md",
|
||||||
},
|
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||||
{
|
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||||
name: "single whiteboard triggers warning",
|
"follow the latest format rules",
|
||||||
markdown: `<whiteboard token="abc123"/>`,
|
"MUST NOT grep/open local SKILL.md files",
|
||||||
wantWarn: true,
|
"lark-cli docs +update --help",
|
||||||
wantSubs: []string{"1 whiteboard block", "overwrite"},
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple whiteboards counted",
|
|
||||||
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
|
|
||||||
wantWarn: true,
|
|
||||||
wantSubs: []string{"2 whiteboard blocks"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single file attachment triggers warning",
|
|
||||||
markdown: `<file token="tok" name="report.pdf"/>`,
|
|
||||||
wantWarn: true,
|
|
||||||
wantSubs: []string{"1 file attachment block"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple file attachments counted",
|
|
||||||
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
|
|
||||||
wantWarn: true,
|
|
||||||
wantSubs: []string{"3 file attachment blocks"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whiteboard and file together both counted",
|
|
||||||
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
|
|
||||||
wantWarn: true,
|
|
||||||
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
got := checkOverwriteResourceBlocks(tt.markdown)
|
|
||||||
if (got != "") != tt.wantWarn {
|
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
|
||||||
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
|
err := validateUpdateV2(context.Background(), runtime)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected v2-only validation error")
|
||||||
}
|
}
|
||||||
for _, sub := range tt.wantSubs {
|
for _, want := range tt.want {
|
||||||
if !strings.Contains(got, sub) {
|
if !strings.Contains(err.Error(), want) {
|
||||||
t.Errorf("expected warning to contain %q, got: %s", sub, got)
|
t.Fatalf("error missing %q: %v", want, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeWhiteboardResult(t *testing.T) {
|
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||||
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
|
t.Helper()
|
||||||
result := map[string]interface{}{
|
|
||||||
"success": true,
|
cmd := &cobra.Command{Use: "+update"}
|
||||||
|
cmd.Flags().String("api-version", "", "")
|
||||||
|
cmd.Flags().String("doc", "doxcnUpdateDryRun", "")
|
||||||
|
cmd.Flags().String("doc-format", "xml", "")
|
||||||
|
cmd.Flags().String("command", "append", "")
|
||||||
|
cmd.Flags().Int("revision-id", -1, "")
|
||||||
|
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||||
|
cmd.Flags().String("pattern", "", "")
|
||||||
|
cmd.Flags().String("block-id", "", "")
|
||||||
|
cmd.Flags().String("src-block-ids", "", "")
|
||||||
|
cmd.Flags().String("mode", "", "")
|
||||||
|
cmd.Flags().String("markdown", "", "")
|
||||||
|
cmd.Flags().String("selection-with-ellipsis", "", "")
|
||||||
|
cmd.Flags().String("selection-by-title", "", "")
|
||||||
|
cmd.Flags().String("new-title", "", "")
|
||||||
|
if apiVersion != "" {
|
||||||
|
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
|
||||||
|
t.Fatalf("set api-version: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
|
||||||
|
|
||||||
got, ok := result["board_tokens"].([]string)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"])
|
|
||||||
}
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Fatalf("expected empty board_tokens, got %#v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("normalizes board_tokens to string slice", func(t *testing.T) {
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"board_tokens": []interface{}{"board_1", "board_2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
|
|
||||||
|
|
||||||
want := []string{"board_1", "board_2"}
|
|
||||||
got, ok := result["board_tokens"].([]string)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"])
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("board_tokens mismatch: got %#v want %#v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("leaves non whiteboard response unchanged", func(t *testing.T) {
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizeWhiteboardResult(result, "## plain text")
|
|
||||||
|
|
||||||
if _, ok := result["board_tokens"]; ok {
|
|
||||||
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateSelectionByTitleV1(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
title string
|
|
||||||
wantErr bool
|
|
||||||
errSub string
|
|
||||||
}{
|
|
||||||
{name: "empty title is valid", title: "", wantErr: false},
|
|
||||||
{name: "single heading is valid", title: "## Section", wantErr: false},
|
|
||||||
{name: "h1 heading is valid", title: "# Top", wantErr: false},
|
|
||||||
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
|
|
||||||
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
|
|
||||||
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
|
|
||||||
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
|
|
||||||
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for name, value := range setFlags {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
if err := cmd.Flags().Set(name, value); err != nil {
|
||||||
t.Parallel()
|
t.Fatalf("set %s: %v", name, err)
|
||||||
err := validateSelectionByTitleV1(tt.title)
|
}
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
|
|
||||||
}
|
|
||||||
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
|
|
||||||
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return common.TestNewRuntimeContext(cmd, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ var validCommandsV2 = map[string]bool{
|
|||||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||||
func v2UpdateFlags() []common.Flag {
|
func v2UpdateFlags() []common.Flag {
|
||||||
return []common.Flag{
|
return []common.Flag{
|
||||||
{Name: "command", Desc: "operation: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
|
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||||
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
|
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||||
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
|
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||||
{Name: "pattern", Desc: "regex pattern for str_replace", Hidden: true},
|
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||||
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
|
{Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"},
|
||||||
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
|
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||||
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
|
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ func validCommandsV2Keys() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
|
||||||
return common.FlagErrorf("invalid --doc: %v", err)
|
return common.FlagErrorf("invalid --doc: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,649 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fixExportedMarkdown applies post-processing to Lark-exported Markdown to
|
|
||||||
// improve round-trip fidelity on re-import:
|
|
||||||
//
|
|
||||||
// 1. fixBoldSpacing: removes trailing whitespace before closing ** / *,
|
|
||||||
// and strips redundant ** from ATX headings. Applied only outside fenced
|
|
||||||
// code blocks, and skips inline code spans.
|
|
||||||
//
|
|
||||||
// 2. normalizeNestedListIndentation: rewrites space-pair-indented nested list
|
|
||||||
// markers to tab-indented markers. This avoids nested ordered list items
|
|
||||||
// being flattened or interpreted as plain text/code on re-import.
|
|
||||||
//
|
|
||||||
// 3. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
|
|
||||||
// follows a non-empty line, preventing it from being parsed as a Setext H2.
|
|
||||||
// Applied only outside fenced code blocks.
|
|
||||||
//
|
|
||||||
// 4. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
|
|
||||||
// consecutive blockquote content lines so create-doc preserves line breaks.
|
|
||||||
// Applied only outside fenced code blocks.
|
|
||||||
//
|
|
||||||
// 5. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
|
|
||||||
// lines at the top level and inside content containers (callout,
|
|
||||||
// quote-container, lark-td). Code fences are left untouched, and
|
|
||||||
// consecutive list items / continuations are not separated.
|
|
||||||
//
|
|
||||||
// 6. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
|
|
||||||
// actual Unicode emoji characters that create-doc understands. Applied only
|
|
||||||
// outside fenced code blocks.
|
|
||||||
func fixExportedMarkdown(md string) string {
|
|
||||||
md = applyOutsideCodeFences(md, fixBoldSpacing)
|
|
||||||
md = applyOutsideCodeFences(md, normalizeNestedListIndentation)
|
|
||||||
md = applyOutsideCodeFences(md, fixSetextAmbiguity)
|
|
||||||
md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks)
|
|
||||||
md = fixTopLevelSoftbreaks(md)
|
|
||||||
md = applyOutsideCodeFences(md, fixCalloutEmoji)
|
|
||||||
// Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line),
|
|
||||||
// but only outside fenced code blocks to preserve intentional blank lines in code.
|
|
||||||
md = applyOutsideCodeFences(md, func(s string) string {
|
|
||||||
for strings.Contains(s, "\n\n\n") {
|
|
||||||
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
})
|
|
||||||
md = strings.TrimRight(md, "\n") + "\n"
|
|
||||||
return md
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyOutsideCodeFences applies fn only to content outside fenced code blocks.
|
|
||||||
// Lines inside fenced code blocks (``` ... ```) are passed through unchanged,
|
|
||||||
// preventing transforms from corrupting literal code content.
|
|
||||||
func applyOutsideCodeFences(md string, fn func(string) string) string {
|
|
||||||
lines := strings.Split(md, "\n")
|
|
||||||
var out []string
|
|
||||||
var chunk []string
|
|
||||||
inCode := false
|
|
||||||
|
|
||||||
flush := func() {
|
|
||||||
if len(chunk) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out = append(out, strings.Split(fn(strings.Join(chunk, "\n")), "\n")...)
|
|
||||||
chunk = chunk[:0]
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(trimmed, "```") {
|
|
||||||
if !inCode {
|
|
||||||
flush()
|
|
||||||
inCode = true
|
|
||||||
} else if trimmed == "```" {
|
|
||||||
inCode = false
|
|
||||||
}
|
|
||||||
out = append(out, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inCode {
|
|
||||||
out = append(out, line)
|
|
||||||
} else {
|
|
||||||
chunk = append(chunk, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixBlockquoteHardBreaks inserts a blank blockquote line (">") between
|
|
||||||
// consecutive blockquote content lines. This forces each line into its own
|
|
||||||
// paragraph within the blockquote, so MCP create-doc preserves line breaks
|
|
||||||
// instead of collapsing them into a single paragraph.
|
|
||||||
//
|
|
||||||
// Before: "> line1\n> line2" → After: "> line1\n>\n> line2"
|
|
||||||
func fixBlockquoteHardBreaks(md string) string {
|
|
||||||
lines := strings.Split(md, "\n")
|
|
||||||
out := make([]string, 0, len(lines)*2)
|
|
||||||
for i, line := range lines {
|
|
||||||
out = append(out, line)
|
|
||||||
if strings.HasPrefix(line, "> ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "> ") {
|
|
||||||
out = append(out, ">")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixBoldSpacing normalizes emphasis markers exported by Lark while preserving
|
|
||||||
// inline code spans:
|
|
||||||
//
|
|
||||||
// 1. Removes leading whitespace after opening ** and * delimiters:
|
|
||||||
// "** text**" → "**text**", "* text*" → "*text*"
|
|
||||||
//
|
|
||||||
// 2. Removes trailing whitespace before closing ** and * delimiters:
|
|
||||||
// "**text **" → "**text**", "*text *" → "*text*"
|
|
||||||
//
|
|
||||||
// 3. Removes redundant bold around an entire ATX heading:
|
|
||||||
// "# **text**" → "# text"
|
|
||||||
//
|
|
||||||
// The bold and italic spacing fixes only run on non-code segments so literal
|
|
||||||
// code content is left unchanged.
|
|
||||||
var (
|
|
||||||
// headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings
|
|
||||||
// that contain multiple disjoint bold spans such as "# **foo** and **bar**".
|
|
||||||
headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func fixBoldSpacing(md string) string {
|
|
||||||
lines := strings.Split(md, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
lines[i] = fixBoldSpacingLine(line)
|
|
||||||
}
|
|
||||||
md = strings.Join(lines, "\n")
|
|
||||||
md = headingBoldRe.ReplaceAllString(md, "$1 $2")
|
|
||||||
return md
|
|
||||||
}
|
|
||||||
|
|
||||||
// atxHeadingRe matches ATX heading lines (# ... through ###### ...).
|
|
||||||
var atxHeadingRe = regexp.MustCompile(`^#{1,6}\s`)
|
|
||||||
|
|
||||||
// scanInlineCodeSpans returns the byte ranges [start, end) of all inline code
|
|
||||||
// spans in line. It handles multi-backtick delimiters (e.g. “ `foo` “) by
|
|
||||||
// finding the opening run of N backticks and searching for the next identical
|
|
||||||
// run to close the span, per CommonMark spec §6.1.
|
|
||||||
func scanInlineCodeSpans(line string) [][2]int {
|
|
||||||
var spans [][2]int
|
|
||||||
i := 0
|
|
||||||
for i < len(line) {
|
|
||||||
if line[i] != '`' {
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Count the opening backtick run.
|
|
||||||
start := i
|
|
||||||
for i < len(line) && line[i] == '`' {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
delim := line[start:i] // e.g. "`" or "``" or "```"
|
|
||||||
// Search for the closing run of the same length.
|
|
||||||
j := i
|
|
||||||
for j <= len(line)-len(delim) {
|
|
||||||
if line[j] == '`' {
|
|
||||||
k := j
|
|
||||||
for k < len(line) && line[k] == '`' {
|
|
||||||
k++
|
|
||||||
}
|
|
||||||
if k-j == len(delim) {
|
|
||||||
spans = append(spans, [2]int{start, k})
|
|
||||||
i = k
|
|
||||||
break
|
|
||||||
}
|
|
||||||
j = k // skip this backtick run and keep searching
|
|
||||||
} else {
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No closing delimiter found — not a code span, continue.
|
|
||||||
}
|
|
||||||
return spans
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line,
|
|
||||||
// skipping content inside inline code spans to avoid corrupting literal code.
|
|
||||||
// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing
|
|
||||||
// handles them separately, keeping heading-only normalization isolated from the
|
|
||||||
// inline emphasis spacing scanner below.
|
|
||||||
func fixBoldSpacingLine(line string) string {
|
|
||||||
if atxHeadingRe.MatchString(line) {
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
spans := scanInlineCodeSpans(line)
|
|
||||||
if len(spans) == 0 {
|
|
||||||
return fixEmphasisSpacingSegment(line)
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
pos := 0
|
|
||||||
for _, loc := range spans {
|
|
||||||
// Process the non-code segment before this inline code span.
|
|
||||||
seg := line[pos:loc[0]]
|
|
||||||
sb.WriteString(fixEmphasisSpacingSegment(seg))
|
|
||||||
// Preserve inline code span as-is.
|
|
||||||
sb.WriteString(line[loc[0]:loc[1]])
|
|
||||||
pos = loc[1]
|
|
||||||
}
|
|
||||||
// Remaining non-code segment after the last code span.
|
|
||||||
sb.WriteString(fixEmphasisSpacingSegment(line[pos:]))
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixEmphasisSpacingSegment trims only the whitespace immediately inside simple
|
|
||||||
// *...* and **...** spans. It deliberately ignores runs of 3+ asterisks and
|
|
||||||
// any candidate whose payload contains another asterisk so nested emphasis-like
|
|
||||||
// text remains untouched. When both inner sides contain whitespace, single-rune
|
|
||||||
// payloads are preserved as literal text (for example "* x *" and "** x **").
|
|
||||||
func fixEmphasisSpacingSegment(seg string) string {
|
|
||||||
if !strings.Contains(seg, "*") {
|
|
||||||
return seg
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
pos := 0
|
|
||||||
for pos < len(seg) {
|
|
||||||
openStart, openEnd, ok := nextAsteriskRun(seg, pos)
|
|
||||||
if !ok {
|
|
||||||
sb.WriteString(seg[pos:])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(seg[pos:openStart])
|
|
||||||
|
|
||||||
markerLen := openEnd - openStart
|
|
||||||
if markerLen != 1 && markerLen != 2 {
|
|
||||||
sb.WriteString(seg[openStart:openEnd])
|
|
||||||
pos = openEnd
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd)
|
|
||||||
if !ok || closeEnd-closeStart != markerLen {
|
|
||||||
sb.WriteString(seg[openStart:openEnd])
|
|
||||||
pos = openEnd
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := seg[openEnd:closeStart]
|
|
||||||
normalized, shouldNormalize := normalizeEmphasisPayload(payload)
|
|
||||||
if !shouldNormalize {
|
|
||||||
sb.WriteString(seg[openStart:closeEnd])
|
|
||||||
pos = closeEnd
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
marker := seg[openStart:openEnd]
|
|
||||||
sb.WriteString(marker)
|
|
||||||
sb.WriteString(normalized)
|
|
||||||
sb.WriteString(marker)
|
|
||||||
pos = closeEnd
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) {
|
|
||||||
for i := start; i < len(s); i++ {
|
|
||||||
if s[i] != '*' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
j := i
|
|
||||||
for j < len(s) && s[j] == '*' {
|
|
||||||
j++
|
|
||||||
}
|
|
||||||
return i, j, true
|
|
||||||
}
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeEmphasisPayload(payload string) (string, bool) {
|
|
||||||
trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace)
|
|
||||||
trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace)
|
|
||||||
if trimmed == "" {
|
|
||||||
return payload, false
|
|
||||||
}
|
|
||||||
|
|
||||||
hasLeadingSpace := len(trimmedLeft) != len(payload)
|
|
||||||
hasTrailingSpace := len(trimmed) != len(trimmedLeft)
|
|
||||||
if !hasLeadingSpace && !hasTrailingSpace {
|
|
||||||
return payload, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 {
|
|
||||||
return payload, false
|
|
||||||
}
|
|
||||||
return trimmed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`)
|
|
||||||
|
|
||||||
func fixSetextAmbiguity(md string) string {
|
|
||||||
return setextRe.ReplaceAllString(md, "$1\n\n$2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// calloutTypeColors maps the semantic type= shorthand to a recommended
|
|
||||||
// [background-color, border-color] pair for Feishu callout blocks.
|
|
||||||
// Used only for hint messages — the Markdown itself is never rewritten.
|
|
||||||
var calloutTypeColors = map[string][2]string{
|
|
||||||
"warning": {"light-yellow", "yellow"},
|
|
||||||
"caution": {"light-orange", "orange"},
|
|
||||||
"note": {"light-blue", "blue"},
|
|
||||||
"info": {"light-blue", "blue"},
|
|
||||||
"tip": {"light-green", "green"},
|
|
||||||
"success": {"light-green", "green"},
|
|
||||||
"check": {"light-green", "green"},
|
|
||||||
"error": {"light-red", "red"},
|
|
||||||
"danger": {"light-red", "red"},
|
|
||||||
"important": {"light-purple", "purple"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// calloutOpenTagRe matches a <callout …> opening tag.
|
|
||||||
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
|
|
||||||
|
|
||||||
// calloutTypeAttrRe extracts the value of a type= attribute (single or
|
|
||||||
// double quoted) from a callout opening tag's attribute string. The
|
|
||||||
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
|
|
||||||
// word/non-word boundary, and `-` is a non-word character, so
|
|
||||||
// `\btype=` would also match the suffix of `data-type=` and yield a
|
|
||||||
// bogus type lookup. Anchoring on start-of-string-or-whitespace
|
|
||||||
// requires a real attribute separator before the name.
|
|
||||||
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
|
|
||||||
|
|
||||||
// calloutBackgroundColorAttrRe matches a background-color= attribute
|
|
||||||
// name with optional whitespace around the equals sign, so forms like
|
|
||||||
// `background-color="..."` and `background-color = "..."` are both
|
|
||||||
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
|
|
||||||
// reason: `data-background-color="..."` must not look like a present
|
|
||||||
// background-color and silently suppress the hint.
|
|
||||||
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
|
|
||||||
|
|
||||||
// WarnCalloutType scans md for callout tags that carry a type= attribute but
|
|
||||||
// no background-color= attribute, then writes a hint line to w for each one
|
|
||||||
// suggesting the explicit Feishu color attributes to use instead.
|
|
||||||
//
|
|
||||||
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
|
|
||||||
// are documentation samples, not real callouts the user wants Feishu to
|
|
||||||
// render. Fence detection uses the shared codeFenceOpenMarker /
|
|
||||||
// isCodeFenceClose helpers so both backtick and tilde fences are handled
|
|
||||||
// (matching CommonMark §4.5).
|
|
||||||
//
|
|
||||||
// The Markdown is not modified — the caller is responsible for acting on
|
|
||||||
// the hints or ignoring them. This keeps the create/update path
|
|
||||||
// transparent: user input reaches create-doc exactly as written.
|
|
||||||
func WarnCalloutType(md string, w io.Writer) {
|
|
||||||
fenceMarker := ""
|
|
||||||
for _, line := range strings.Split(md, "\n") {
|
|
||||||
if fenceMarker != "" {
|
|
||||||
// Inside a fenced block — skip everything until the matching
|
|
||||||
// closer. Code samples that show literal <callout type=...>
|
|
||||||
// must not produce a phantom hint.
|
|
||||||
if isCodeFenceClose(line, fenceMarker) {
|
|
||||||
fenceMarker = ""
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if marker := codeFenceOpenMarker(line); marker != "" {
|
|
||||||
fenceMarker = marker
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
scanCalloutTagsForWarning(line, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
|
|
||||||
// tag in s that lacks an explicit background-color= attribute. Pulled out
|
|
||||||
// of WarnCalloutType so the line walker only handles fence state and the
|
|
||||||
// per-tag scan is its own readable unit.
|
|
||||||
//
|
|
||||||
// The previous implementation routed the tag iteration through
|
|
||||||
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
|
|
||||||
// returned the original tag and threw the rebuilt string away — using a
|
|
||||||
// rewrite primitive purely for its iteration side-effect, plus a second
|
|
||||||
// regex execution to recover the capture groups inside the callback.
|
|
||||||
// FindAllStringSubmatch hands us both the iteration and the groups in one
|
|
||||||
// pass, no allocation thrown away.
|
|
||||||
func scanCalloutTagsForWarning(s string, w io.Writer) {
|
|
||||||
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
|
|
||||||
attrs := m[1]
|
|
||||||
// Skip tags that already carry an explicit background-color.
|
|
||||||
if calloutBackgroundColorAttrRe.MatchString(attrs) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
|
|
||||||
if len(parts) < 3 {
|
|
||||||
continue // no type= attribute
|
|
||||||
}
|
|
||||||
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
|
|
||||||
typeName := parts[1]
|
|
||||||
if typeName == "" {
|
|
||||||
typeName = parts[2]
|
|
||||||
}
|
|
||||||
colors, ok := calloutTypeColors[typeName]
|
|
||||||
if !ok {
|
|
||||||
continue // unknown type — no hint to give
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w,
|
|
||||||
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
|
|
||||||
typeName, colors[0], colors[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
|
|
||||||
// Unicode emoji characters that create-doc accepts.
|
|
||||||
var calloutEmojiAliases = map[string]string{
|
|
||||||
"warning": "⚠️",
|
|
||||||
"note": "📝",
|
|
||||||
"tip": "💡",
|
|
||||||
"info": "ℹ️",
|
|
||||||
"check": "✅",
|
|
||||||
"success": "✅",
|
|
||||||
"error": "❌",
|
|
||||||
"danger": "🚨",
|
|
||||||
"important": "❗",
|
|
||||||
"caution": "⚠️",
|
|
||||||
"question": "❓",
|
|
||||||
"forbidden": "🚫",
|
|
||||||
"fire": "🔥",
|
|
||||||
"star": "⭐",
|
|
||||||
"pin": "📌",
|
|
||||||
"clock": "🕐",
|
|
||||||
"gift": "🎁",
|
|
||||||
"eyes": "👀",
|
|
||||||
"bulb": "💡",
|
|
||||||
"memo": "📝",
|
|
||||||
"link": "🔗",
|
|
||||||
"key": "🔑",
|
|
||||||
"lock": "🔒",
|
|
||||||
"thumbsup": "👍",
|
|
||||||
"thumbsdown": "👎",
|
|
||||||
"rocket": "🚀",
|
|
||||||
"construction": "🚧",
|
|
||||||
}
|
|
||||||
|
|
||||||
// calloutEmojiRe matches emoji="<name>" in callout opening tags.
|
|
||||||
var calloutEmojiRe = regexp.MustCompile(`(<callout[^>]*\bemoji=")([^"]+)(")`)
|
|
||||||
|
|
||||||
// fixCalloutEmoji replaces named emoji aliases in callout tags with actual
|
|
||||||
// Unicode emoji characters. fetch-doc sometimes emits emoji="warning" instead
|
|
||||||
// of emoji="⚠️"; create-doc only accepts Unicode emoji.
|
|
||||||
func fixCalloutEmoji(md string) string {
|
|
||||||
return calloutEmojiRe.ReplaceAllStringFunc(md, func(match string) string {
|
|
||||||
parts := calloutEmojiRe.FindStringSubmatch(match)
|
|
||||||
if len(parts) != 4 {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
name := parts[2]
|
|
||||||
if emoji, ok := calloutEmojiAliases[name]; ok {
|
|
||||||
return parts[1] + emoji + parts[3]
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTableStructuralTag returns true for lark-table tags that are structural
|
|
||||||
// (table/tr/td open/close) and should not themselves trigger blank-line insertion.
|
|
||||||
func isTableStructuralTag(s string) bool {
|
|
||||||
return strings.HasPrefix(s, "<lark-t") ||
|
|
||||||
strings.HasPrefix(s, "</lark-t")
|
|
||||||
}
|
|
||||||
|
|
||||||
// contentContainers lists block tags whose interior should have blank lines
|
|
||||||
// inserted between adjacent content lines (same treatment as lark-td).
|
|
||||||
var contentContainers = [][2]string{
|
|
||||||
{"<lark-td>", "</lark-td>"},
|
|
||||||
{"<callout", "</callout>"},
|
|
||||||
{"<quote-container>", "</quote-container>"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// listItemRe matches unordered and ordered list item markers, including
|
|
||||||
// indented (nested) items.
|
|
||||||
var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `)
|
|
||||||
|
|
||||||
// nestedListIndentRe matches nested list item markers indented with pairs of
|
|
||||||
// spaces. We rewrite those space pairs to tabs because some downstream
|
|
||||||
// round-trip paths treat multi-space indented ordered items as flat items or
|
|
||||||
// literal text, while tab indentation remains nested and avoids 4-space code
|
|
||||||
// block ambiguity.
|
|
||||||
var nestedListIndentRe = regexp.MustCompile(`^( {2,})([-*+]|\d+[.)]) `)
|
|
||||||
|
|
||||||
func normalizeNestedListIndentation(md string) string {
|
|
||||||
lines := strings.Split(md, "\n")
|
|
||||||
for i, line := range lines {
|
|
||||||
matches := nestedListIndentRe.FindStringSubmatch(line)
|
|
||||||
if len(matches) != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !hasPreviousNonBlankListItem(lines, i) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
indent := matches[1]
|
|
||||||
if len(indent)%2 != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tabs := strings.Repeat("\t", len(indent)/2)
|
|
||||||
lines[i] = tabs + line[len(indent):]
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasPreviousNonBlankListItem(lines []string, index int) bool {
|
|
||||||
for i := index - 1; i >= 0; i-- {
|
|
||||||
trimmed := strings.TrimSpace(lines[i])
|
|
||||||
if trimmed == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return listItemRe.MatchString(lines[i])
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isListItemOrContinuation returns true for lines that are part of a list:
|
|
||||||
// either a list item marker line or an indented continuation of a list item.
|
|
||||||
// This is used to prevent blank lines being inserted between tight list lines,
|
|
||||||
// which would turn a tight list into a loose list and change rendering.
|
|
||||||
func isListItemOrContinuation(line string) bool {
|
|
||||||
if listItemRe.MatchString(line) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Continuation lines are indented by at least 2 spaces or 1 tab.
|
|
||||||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixTopLevelSoftbreaks ensures that adjacent non-empty content lines are
|
|
||||||
// separated by a blank line in the following contexts:
|
|
||||||
// 1. Top level (depth == 0): every Lark block becomes its own Markdown paragraph.
|
|
||||||
// 2. Inside content containers (<lark-td>, <callout>, <quote-container>):
|
|
||||||
// multi-line content is preserved as separate paragraphs.
|
|
||||||
//
|
|
||||||
// Structural table tags (<lark-table>, <lark-tr>, <lark-td> and their closing
|
|
||||||
// counterparts) never trigger blank-line insertion themselves. Fenced code
|
|
||||||
// blocks (``` ... ```) are left completely untouched. Consecutive list items
|
|
||||||
// and list continuations are not separated (to preserve tight lists).
|
|
||||||
func fixTopLevelSoftbreaks(md string) string {
|
|
||||||
lines := strings.Split(md, "\n")
|
|
||||||
out := make([]string, 0, len(lines)*2)
|
|
||||||
|
|
||||||
inCodeBlock := false
|
|
||||||
// containerDepth > 0 means we are inside a content container.
|
|
||||||
containerDepth := 0
|
|
||||||
// tableDepth tracks <lark-table> nesting (outer structure, not content).
|
|
||||||
tableDepth := 0
|
|
||||||
|
|
||||||
for i, line := range lines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
|
|
||||||
// --- Track fenced code blocks — skip all processing inside. ---
|
|
||||||
// Any ``` line opens a block; only plain ``` (no language id) closes it.
|
|
||||||
if strings.HasPrefix(trimmed, "```") {
|
|
||||||
if inCodeBlock {
|
|
||||||
if trimmed == "```" {
|
|
||||||
inCodeBlock = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inCodeBlock = true
|
|
||||||
}
|
|
||||||
out = append(out, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inCodeBlock {
|
|
||||||
// --- Track content containers. ---
|
|
||||||
for _, cc := range contentContainers {
|
|
||||||
if strings.HasPrefix(trimmed, cc[0]) {
|
|
||||||
containerDepth++
|
|
||||||
}
|
|
||||||
if strings.Contains(trimmed, cc[1]) {
|
|
||||||
containerDepth--
|
|
||||||
if containerDepth < 0 {
|
|
||||||
containerDepth = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Track table structure (outer, non-content). ---
|
|
||||||
if strings.HasPrefix(trimmed, "<lark-table") {
|
|
||||||
tableDepth++
|
|
||||||
}
|
|
||||||
if strings.Contains(trimmed, "</lark-table>") {
|
|
||||||
tableDepth--
|
|
||||||
if tableDepth < 0 {
|
|
||||||
tableDepth = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Decide whether to insert a blank line before this line. ---
|
|
||||||
if !inCodeBlock && trimmed != "" && i > 0 {
|
|
||||||
// Skip structural table tags — they are not content lines.
|
|
||||||
isStructural := isTableStructuralTag(trimmed)
|
|
||||||
|
|
||||||
// Don't split consecutive blockquote lines ("> ...") — they form
|
|
||||||
// one continuous blockquote in the original document.
|
|
||||||
isBlockquote := strings.HasPrefix(trimmed, "> ") || trimmed == ">"
|
|
||||||
|
|
||||||
// Only closing container tags suppress blank-line insertion.
|
|
||||||
// Opening container tags may still receive a blank line before them
|
|
||||||
// (e.g. two consecutive <callout> blocks need a blank between them).
|
|
||||||
isContainerTag := false
|
|
||||||
for _, cc := range contentContainers {
|
|
||||||
closingTag := "</" + cc[0][1:]
|
|
||||||
if strings.HasPrefix(trimmed, closingTag) {
|
|
||||||
isContainerTag = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert blank line when:
|
|
||||||
// - at top level (tableDepth == 0, containerDepth == 0), OR
|
|
||||||
// - inside a content container (containerDepth > 0, not in outer table)
|
|
||||||
// AND this line is actual content (not structural/blockquote/container-tag).
|
|
||||||
inContent := tableDepth == 0 || containerDepth > 0
|
|
||||||
if !isStructural && !isBlockquote && !isContainerTag && inContent {
|
|
||||||
// Don't split consecutive list items / continuations — inserting a
|
|
||||||
// blank line between them turns a tight list into a loose list.
|
|
||||||
isListRelated := isListItemOrContinuation(line)
|
|
||||||
prevIsListRelated := len(out) > 0 && isListItemOrContinuation(out[len(out)-1])
|
|
||||||
if !(isListRelated && prevIsListRelated) {
|
|
||||||
prev := ""
|
|
||||||
if len(out) > 0 {
|
|
||||||
prev = strings.TrimSpace(out[len(out)-1])
|
|
||||||
}
|
|
||||||
if prev != "" && !isTableStructuralTag(prev) {
|
|
||||||
out = append(out, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(out, "\n")
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestFixExportedMarkdownIdempotent asserts the core promise of the exported
|
|
||||||
// markdown pipeline: applying the fixes twice produces the same result as
|
|
||||||
// applying them once. Round-trip formatting relies on this invariant, so any
|
|
||||||
// transform that keeps rewriting its own output would break fetch → edit →
|
|
||||||
// update → fetch stability.
|
|
||||||
func TestFixExportedMarkdownIdempotent(t *testing.T) {
|
|
||||||
fixtures := map[string]string{
|
|
||||||
"kitchen sink": strings.Join([]string{
|
|
||||||
"# **Title**",
|
|
||||||
"paragraph one",
|
|
||||||
"paragraph two",
|
|
||||||
"**bold ** and * italic*",
|
|
||||||
"",
|
|
||||||
"> q1",
|
|
||||||
"> q2",
|
|
||||||
"",
|
|
||||||
"1. parent",
|
|
||||||
" 1. child",
|
|
||||||
" 1. grandchild",
|
|
||||||
"",
|
|
||||||
"<callout emoji=\"warning\">",
|
|
||||||
"callout body line 1",
|
|
||||||
"callout body line 2",
|
|
||||||
"</callout>",
|
|
||||||
"",
|
|
||||||
"some text",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
"```go",
|
|
||||||
"// code content with markdown-like shapes must survive as-is",
|
|
||||||
"**foo **",
|
|
||||||
"* hello*",
|
|
||||||
" 1. nested",
|
|
||||||
"> q",
|
|
||||||
"---",
|
|
||||||
"```",
|
|
||||||
"",
|
|
||||||
}, "\n"),
|
|
||||||
|
|
||||||
"cjk content": strings.Join([]string{
|
|
||||||
"# **测试标题**",
|
|
||||||
"段落一",
|
|
||||||
"段落二",
|
|
||||||
"**有用性 ** and * 关键 *",
|
|
||||||
"",
|
|
||||||
"1. 父项",
|
|
||||||
" 1. 子项",
|
|
||||||
"",
|
|
||||||
}, "\n"),
|
|
||||||
|
|
||||||
"nested containers": strings.Join([]string{
|
|
||||||
"<callout emoji=\"info\">",
|
|
||||||
"line a",
|
|
||||||
"line b",
|
|
||||||
"</callout>",
|
|
||||||
"",
|
|
||||||
"<quote-container>",
|
|
||||||
"quoted 1",
|
|
||||||
"quoted 2",
|
|
||||||
"</quote-container>",
|
|
||||||
"",
|
|
||||||
}, "\n"),
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, fixture := range fixtures {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
once := fixExportedMarkdown(fixture)
|
|
||||||
twice := fixExportedMarkdown(once)
|
|
||||||
if once != twice {
|
|
||||||
t.Errorf("fixExportedMarkdown is not idempotent for %q\nfirst pass:\n%s\nsecond pass:\n%s",
|
|
||||||
name, once, twice)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFixExportedMarkdownPreservesFencedCodeByteForByte packs a fenced code
|
|
||||||
// block with content that every individual transform in the pipeline would
|
|
||||||
// normally rewrite, and asserts the fence content comes out byte-for-byte
|
|
||||||
// identical. This is the pipeline's strongest invariant — users' code samples
|
|
||||||
// must never be silently modified by a formatting pass.
|
|
||||||
func TestFixExportedMarkdownPreservesFencedCodeByteForByte(t *testing.T) {
|
|
||||||
// Every line below is something at least one transform would touch if it
|
|
||||||
// appeared outside a fence. None of it must change.
|
|
||||||
dangerous := strings.Join([]string{
|
|
||||||
"**foo **", // fixBoldSpacing — trailing space bold
|
|
||||||
"* hello*", // fixBoldSpacing — leading space italic
|
|
||||||
"# **heading**", // fixBoldSpacing — redundant heading bold
|
|
||||||
"para1", // fixTopLevelSoftbreaks — adjacent paragraphs
|
|
||||||
"para2",
|
|
||||||
"> q1", // fixBlockquoteHardBreaks — blockquote pair
|
|
||||||
"> q2",
|
|
||||||
"some text", // fixSetextAmbiguity — text before ---
|
|
||||||
"---",
|
|
||||||
" 1. nested", // normalizeNestedListIndentation
|
|
||||||
`<callout emoji="warning">`, // fixCalloutEmoji — emoji alias
|
|
||||||
}, "\n")
|
|
||||||
|
|
||||||
// Wrap the dangerous content in a triple-backtick fence and surround with
|
|
||||||
// content so the pipeline has adjacent regions to potentially touch.
|
|
||||||
input := "before\n\n```\n" + dangerous + "\n```\n\nafter\n"
|
|
||||||
|
|
||||||
got := fixExportedMarkdown(input)
|
|
||||||
|
|
||||||
// Extract the fence content from the output and compare to the input fence
|
|
||||||
// content byte-for-byte.
|
|
||||||
gotFence, ok := extractFirstFenceContent(got)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("fixExportedMarkdown output lost its fenced code block:\n%s", got)
|
|
||||||
}
|
|
||||||
if gotFence != dangerous {
|
|
||||||
t.Errorf("fenced code content was modified\nwant (bytes): %q\ngot (bytes): %q",
|
|
||||||
dangerous, gotFence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFirstFenceContent returns the inner text of the first triple-backtick
|
|
||||||
// fenced code block it finds, or ("", false) if none is present.
|
|
||||||
func extractFirstFenceContent(md string) (string, bool) {
|
|
||||||
const fence = "```"
|
|
||||||
open := strings.Index(md, fence)
|
|
||||||
if open < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
// Skip the fence marker and its info-string line.
|
|
||||||
rest := md[open+len(fence):]
|
|
||||||
lineEnd := strings.Index(rest, "\n")
|
|
||||||
if lineEnd < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
rest = rest[lineEnd+1:]
|
|
||||||
close := strings.Index(rest, "\n"+fence)
|
|
||||||
if close < 0 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return rest[:close], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFixExportedMarkdownPreservesCRLF feeds CRLF-terminated markdown (Windows
|
|
||||||
// line endings) through the pipeline and asserts that line endings are
|
|
||||||
// preserved AND the emphasis/heading transforms still apply — neither
|
|
||||||
// silently-LF-normalized nor passed through unchanged.
|
|
||||||
func TestFixExportedMarkdownPreservesCRLF(t *testing.T) {
|
|
||||||
lf := "# **Title**\nparagraph one\nparagraph two\n**bold **\n"
|
|
||||||
crlf := strings.ReplaceAll(lf, "\n", "\r\n")
|
|
||||||
|
|
||||||
got := fixExportedMarkdown(crlf)
|
|
||||||
|
|
||||||
// Transforms must still fire: heading bold stripped, trailing-space bold trimmed.
|
|
||||||
if strings.Contains(got, "**Title**") {
|
|
||||||
t.Errorf("heading bold not stripped on CRLF input:\n%q", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "**bold **") {
|
|
||||||
t.Errorf("trailing-space bold not fixed on CRLF input:\n%q", got)
|
|
||||||
}
|
|
||||||
// CRLF line endings must survive — we don't want to silently normalize a
|
|
||||||
// Windows author's document to LF.
|
|
||||||
if !strings.Contains(got, "\r\n") {
|
|
||||||
t.Errorf("CRLF line endings were normalized away:\n%q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFixExportedMarkdownTransformInteractions covers shapes where more than
|
|
||||||
// one transform fires on the same input. Each transform is individually tested
|
|
||||||
// elsewhere; these cases guard against composition regressions.
|
|
||||||
func TestFixExportedMarkdownTransformInteractions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantContains []string // substrings that must be present after fixes
|
|
||||||
wantAbsent []string // substrings that must be absent after fixes
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nested list item with trailing-space bold",
|
|
||||||
input: "1. parent\n 1. **child **\n",
|
|
||||||
wantContains: []string{
|
|
||||||
"\t1.", // nested indent converted to tab
|
|
||||||
"**child**", // trailing space trimmed
|
|
||||||
},
|
|
||||||
wantAbsent: []string{
|
|
||||||
" 1.", // original two-space indent gone
|
|
||||||
"**child **", // original trailing space gone
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "paragraph followed by list",
|
|
||||||
input: "paragraph\n- item a\n- item b\n",
|
|
||||||
wantContains: []string{
|
|
||||||
"paragraph\n\n- item a", // blank line inserted at text-to-list transition
|
|
||||||
},
|
|
||||||
wantAbsent: []string{
|
|
||||||
"\n\n\n", // no triple newline
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "callout containing list with emphasis",
|
|
||||||
input: "<callout emoji=\"info\">\n- **item **\n- another\n</callout>\n",
|
|
||||||
wantContains: []string{
|
|
||||||
"**item**", // trailing-space bold fixed inside callout
|
|
||||||
},
|
|
||||||
wantAbsent: []string{
|
|
||||||
"**item **",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "heading followed by paragraph with bold",
|
|
||||||
input: "# **Title**\nbody **text **\n",
|
|
||||||
wantContains: []string{
|
|
||||||
"# Title", // heading bold stripped
|
|
||||||
"body **text**", // paragraph bold trimmed, not stripped
|
|
||||||
},
|
|
||||||
wantAbsent: []string{
|
|
||||||
"# **Title**",
|
|
||||||
"body **text **",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := fixExportedMarkdown(tt.input)
|
|
||||||
for _, want := range tt.wantContains {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("want substring %q not found in output:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unwanted := range tt.wantAbsent {
|
|
||||||
if strings.Contains(got, unwanted) {
|
|
||||||
t.Errorf("unwanted substring %q still present in output:\n%s", unwanted, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNormalizeNestedListIndentationDocumentedSkips locks in the deliberate
|
|
||||||
// "do nothing" branches of normalizeNestedListIndentation. Each case below is
|
|
||||||
// a shape the function intentionally does not rewrite; if a future change to
|
|
||||||
// the heuristic flips one of these, we want the regression to be visible in
|
|
||||||
// the test diff rather than silently changing user documents.
|
|
||||||
func TestNormalizeNestedListIndentationDocumentedSkips(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
// want is identical to input — we are asserting "no change".
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "three-space indent (odd) under list item stays unchanged",
|
|
||||||
input: "1. parent\n 1. child",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "five-space indent (odd) under list item stays unchanged",
|
|
||||||
input: "- parent\n - deep",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "two-space indent without a parent list item stays unchanged",
|
|
||||||
input: "plain paragraph\n - not nested",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "blank-line-separated loose-list sibling stays unchanged",
|
|
||||||
input: "1. a\n\n 1. b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "four-space indented code block under list item stays unchanged",
|
|
||||||
input: "- parent\n\n 1. code sample",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := normalizeNestedListIndentation(tt.input)
|
|
||||||
if got != tt.input {
|
|
||||||
t.Errorf("normalizeNestedListIndentation unexpectedly rewrote documented-skip input\ninput: %q\ngot: %q", tt.input, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFixBoldSpacing(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "leading space after opening bold",
|
|
||||||
input: "** hello**",
|
|
||||||
want: "**hello**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "leading space after opening italic",
|
|
||||||
input: "* hello*",
|
|
||||||
want: "*hello*",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "leading and trailing spaces inside bold are collapsed",
|
|
||||||
input: "** hello **",
|
|
||||||
want: "**hello**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "leading and trailing spaces inside italic are collapsed",
|
|
||||||
input: "* hello *",
|
|
||||||
want: "*hello*",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple spaced italic spans on one line are each collapsed",
|
|
||||||
input: "* a* * b*",
|
|
||||||
want: "*a* *b*",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ambiguous italic span stays literal",
|
|
||||||
input: "2 * x * y",
|
|
||||||
want: "2 * x * y",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ambiguous bold span stays literal",
|
|
||||||
input: "2 ** x ** y",
|
|
||||||
want: "2 ** x ** y",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single-rune italic with spaces on both sides stays literal",
|
|
||||||
input: "* x *",
|
|
||||||
want: "* x *",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single-rune bold with spaces on both sides stays literal",
|
|
||||||
input: "** x **",
|
|
||||||
want: "** x **",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "triple-asterisk near miss stays literal",
|
|
||||||
input: "*** hello**",
|
|
||||||
want: "*** hello**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trailing space before closing bold",
|
|
||||||
input: "**hello **",
|
|
||||||
want: "**hello**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "trailing space before closing italic",
|
|
||||||
input: "*hello *",
|
|
||||||
want: "*hello*",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "redundant bold in h1",
|
|
||||||
input: "# **Title**",
|
|
||||||
want: "# Title",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "redundant bold in h2",
|
|
||||||
input: "## **Section**",
|
|
||||||
want: "## Section",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no change needed for clean bold",
|
|
||||||
input: "**bold**",
|
|
||||||
want: "**bold**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple lines processed independently",
|
|
||||||
input: "**foo **\n**bar **",
|
|
||||||
want: "**foo**\n**bar**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inline code span not modified",
|
|
||||||
input: "`**hello **`",
|
|
||||||
want: "`**hello **`",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inline code preserved, bold outside fixed",
|
|
||||||
input: "**foo ** and `**bar **`",
|
|
||||||
want: "**foo** and `**bar **`",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inline code with spaced italic stays literal while outside span is fixed",
|
|
||||||
input: "`* hello *` and * hello *",
|
|
||||||
want: "`* hello *` and *hello*",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "opening space inside text tag fixed",
|
|
||||||
input: `<text color="red">** Helpful - 有用性:**</text>`,
|
|
||||||
want: `<text color="red">**Helpful - 有用性:**</text>`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double-backtick inline code not modified",
|
|
||||||
input: "``**hello **`` and **world **",
|
|
||||||
want: "``**hello **`` and **world**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "double-backtick span containing literal backtick not modified",
|
|
||||||
input: "`` a`b `` and **bold **",
|
|
||||||
want: "`` a`b `` and **bold**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "heading with multiple bold spans left unchanged",
|
|
||||||
input: "# **foo** and **bar**",
|
|
||||||
want: "# **foo** and **bar**",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := fixBoldSpacing(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("fixBoldSpacing(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixSetextAmbiguity(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "paragraph followed by ---",
|
|
||||||
input: "some text\n---",
|
|
||||||
want: "some text\n\n---",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "blank line before --- already",
|
|
||||||
input: "some text\n\n---",
|
|
||||||
want: "some text\n\n---",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "heading not affected",
|
|
||||||
input: "# Heading\n---",
|
|
||||||
want: "# Heading\n\n---",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := fixSetextAmbiguity(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("fixSetextAmbiguity(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixBlockquoteHardBreaks(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "two consecutive blockquote lines",
|
|
||||||
input: "> line1\n> line2",
|
|
||||||
want: "> line1\n>\n> line2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "three consecutive blockquote lines",
|
|
||||||
input: "> a\n> b\n> c",
|
|
||||||
want: "> a\n>\n> b\n>\n> c",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single blockquote line unchanged",
|
|
||||||
input: "> only one",
|
|
||||||
want: "> only one",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-blockquote not affected",
|
|
||||||
input: "line1\nline2",
|
|
||||||
want: "line1\nline2",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := fixBlockquoteHardBreaks(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("fixBlockquoteHardBreaks(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixTopLevelSoftbreaks(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "adjacent top-level lines get blank line",
|
|
||||||
input: "paragraph one\nparagraph two",
|
|
||||||
want: "paragraph one\n\nparagraph two",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lines inside code block not modified",
|
|
||||||
input: "```\nline1\nline2\n```",
|
|
||||||
want: "```\nline1\nline2\n```",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// callout is a content container: blank lines are inserted between inner lines.
|
|
||||||
name: "lines inside callout get blank line between them",
|
|
||||||
input: "<callout>\nline1\nline2\n</callout>",
|
|
||||||
want: "<callout>\n\nline1\n\nline2\n</callout>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lark-td cell content gets blank line",
|
|
||||||
input: "<lark-td>\nline1\nline2\n</lark-td>",
|
|
||||||
want: "<lark-td>\nline1\n\nline2\n</lark-td>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "structural lark-table tags not separated",
|
|
||||||
input: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
|
|
||||||
want: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "blockquote lines not split",
|
|
||||||
input: "> line1\n> line2",
|
|
||||||
want: "> line1\n> line2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "consecutive unordered list items not split",
|
|
||||||
input: "- item a\n- item b\n- item c",
|
|
||||||
want: "- item a\n- item b\n- item c",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "consecutive ordered list items not split",
|
|
||||||
input: "1. first\n2. second\n3. third",
|
|
||||||
want: "1. first\n2. second\n3. third",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "list continuation not split from item",
|
|
||||||
input: "- item a\n continuation",
|
|
||||||
want: "- item a\n continuation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "text to list transition gets blank line",
|
|
||||||
input: "paragraph\n- list item",
|
|
||||||
want: "paragraph\n\n- list item",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "adjacent callout blocks get blank line between them",
|
|
||||||
input: "<callout>\ncontent1\n</callout>\n<callout>\ncontent2\n</callout>",
|
|
||||||
want: "<callout>\n\ncontent1\n</callout>\n\n<callout>\n\ncontent2\n</callout>",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := fixTopLevelSoftbreaks(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("fixTopLevelSoftbreaks(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeNestedListIndentation(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nested ordered list uses tabs instead of space pairs",
|
|
||||||
input: "1. parent\n 1. child\n 1. grandchild",
|
|
||||||
want: "1. parent\n\t1. child\n\t\t1. grandchild",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nested mixed list markers use tabs instead of space pairs",
|
|
||||||
input: "- parent\n - child\n 1. grandchild",
|
|
||||||
want: "- parent\n\t- child\n\t\t1. grandchild",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "top-level list unchanged",
|
|
||||||
input: "1. parent\n2. sibling",
|
|
||||||
want: "1. parent\n2. sibling",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "indented top-level marker without parent list stays unchanged",
|
|
||||||
input: "paragraph\n\n 1. item",
|
|
||||||
want: "paragraph\n\n 1. item",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "blank-line-separated loose-list sibling stays unchanged",
|
|
||||||
input: "1. a\n\n 1. b",
|
|
||||||
want: "1. a\n\n 1. b",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "indented code block inside list item stays unchanged",
|
|
||||||
input: "- parent\n\n 1. code",
|
|
||||||
want: "- parent\n\n 1. code",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := normalizeNestedListIndentation(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("normalizeNestedListIndentation(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixExportedMarkdown(t *testing.T) {
|
|
||||||
// End-to-end: all fixes applied together
|
|
||||||
input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---"
|
|
||||||
result := fixExportedMarkdown(input)
|
|
||||||
|
|
||||||
if strings.Contains(result, "# **Title**") {
|
|
||||||
t.Error("expected heading bold to be stripped")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "paragraph one\n\nparagraph two") {
|
|
||||||
t.Error("expected blank line between top-level paragraphs")
|
|
||||||
}
|
|
||||||
if strings.Contains(result, "**bold **") {
|
|
||||||
t.Error("expected trailing space in bold to be fixed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, ">\n> q2") {
|
|
||||||
t.Error("expected blockquote hard break inserted")
|
|
||||||
}
|
|
||||||
if strings.Contains(result, "some text\n---") {
|
|
||||||
t.Error("expected blank line before --- to prevent setext heading")
|
|
||||||
}
|
|
||||||
// Should end with exactly one newline
|
|
||||||
if !strings.HasSuffix(result, "\n") || strings.HasSuffix(result, "\n\n") {
|
|
||||||
t.Errorf("expected result to end with exactly one newline, got %q", result[len(result)-5:])
|
|
||||||
}
|
|
||||||
// No triple newlines
|
|
||||||
if strings.Contains(result, "\n\n\n") {
|
|
||||||
t.Error("expected no triple newlines in output")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarnCalloutType(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantHint bool // whether a hint line is expected
|
|
||||||
hintContains string // substring the hint must contain
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "warning type without background-color emits hint",
|
|
||||||
input: `<callout type="warning" emoji="📝">`,
|
|
||||||
wantHint: true,
|
|
||||||
hintContains: `background-color="light-yellow"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "info type without background-color emits hint",
|
|
||||||
input: `<callout type="info" emoji="ℹ️">`,
|
|
||||||
wantHint: true,
|
|
||||||
hintContains: `background-color="light-blue"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single-quoted type attribute emits hint",
|
|
||||||
input: `<callout type='warning' emoji="📝">`,
|
|
||||||
wantHint: true,
|
|
||||||
hintContains: `background-color="light-yellow"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "explicit background-color suppresses hint",
|
|
||||||
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "whitespace around equals is tolerated in background-color",
|
|
||||||
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown type emits no hint",
|
|
||||||
input: `<callout type="custom" emoji="🔥">`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no type attribute emits no hint",
|
|
||||||
input: `<callout emoji="💡" background-color="light-green">`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-callout tag emits no hint",
|
|
||||||
input: `<div type="warning">`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "hint includes border-color suggestion",
|
|
||||||
input: `<callout type="error" emoji="❌">`,
|
|
||||||
wantHint: true,
|
|
||||||
hintContains: `border-color="red"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Regression: the old `\btype=` regex matched the suffix of
|
|
||||||
// `data-type=` because `-` is a non-word character, so a tag
|
|
||||||
// carrying only data-attrs would silently get a bogus hint.
|
|
||||||
// The (?:^|\s) anchor requires a real attribute separator.
|
|
||||||
name: "data-type attribute does not trigger hint",
|
|
||||||
input: `<callout data-type="warning" emoji="📝">`,
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Symmetric guard for the background-color regex: a future
|
|
||||||
// `data-background-color=` attribute must not be mistaken
|
|
||||||
// for a present background-color and silently suppress the
|
|
||||||
// hint that the real type= would otherwise produce.
|
|
||||||
name: "data-background-color does not suppress hint",
|
|
||||||
input: `<callout type="warning" data-background-color="anything">`,
|
|
||||||
wantHint: true,
|
|
||||||
hintContains: `background-color="light-yellow"`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Regression for the code-fence skip: a documentation sample
|
|
||||||
// inside a ``` fence is NOT a real callout the user wants
|
|
||||||
// rendered, so it must produce no stderr noise.
|
|
||||||
name: "callout inside backtick fence emits no hint",
|
|
||||||
input: "```markdown\n" +
|
|
||||||
`<callout type="warning" emoji="📝">` + "\n" +
|
|
||||||
"```\n",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Same skip works for tilde fences (CommonMark §4.5 makes
|
|
||||||
// `~~~` an equivalent fence character).
|
|
||||||
name: "callout inside tilde fence emits no hint",
|
|
||||||
input: "~~~markdown\n" +
|
|
||||||
`<callout type="info" emoji="ℹ️">` + "\n" +
|
|
||||||
"~~~\n",
|
|
||||||
wantHint: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Closing the fence must restore normal scanning: a real
|
|
||||||
// callout that follows a documentation block still gets a
|
|
||||||
// hint. Pins that fenceMarker is reset, not stuck.
|
|
||||||
name: "callout after fence close still emits hint",
|
|
||||||
input: "```markdown\n" +
|
|
||||||
`<callout type="warning">sample</callout>` + "\n" +
|
|
||||||
"```\n" +
|
|
||||||
`<callout type="error" emoji="❌">real</callout>` + "\n",
|
|
||||||
wantHint: true,
|
|
||||||
hintContains: `border-color="red"`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
var buf strings.Builder
|
|
||||||
WarnCalloutType(tt.input, &buf)
|
|
||||||
got := buf.String()
|
|
||||||
if tt.wantHint {
|
|
||||||
if got == "" {
|
|
||||||
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
|
|
||||||
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if got != "" {
|
|
||||||
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixCalloutEmoji(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "warning alias replaced",
|
|
||||||
input: `<callout emoji="warning" background-color="light-orange">`,
|
|
||||||
want: `<callout emoji="⚠️" background-color="light-orange">`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tip alias replaced",
|
|
||||||
input: `<callout emoji="tip">`,
|
|
||||||
want: `<callout emoji="💡">`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "actual emoji unchanged",
|
|
||||||
input: `<callout emoji="⚠️">`,
|
|
||||||
want: `<callout emoji="⚠️">`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown alias unchanged",
|
|
||||||
input: `<callout emoji="unicorn">`,
|
|
||||||
want: `<callout emoji="unicorn">`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-callout tag unchanged",
|
|
||||||
input: `<div emoji="warning">`,
|
|
||||||
want: `<div emoji="warning">`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := fixCalloutEmoji(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("fixCalloutEmoji(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyOutsideCodeFences(t *testing.T) {
|
|
||||||
// Transforms should not modify content inside fenced code blocks.
|
|
||||||
input := "```md\n**x **\n> a\n> b\nline\n---\n```"
|
|
||||||
|
|
||||||
if got := applyOutsideCodeFences(input, fixBoldSpacing); got != input {
|
|
||||||
t.Fatalf("fixBoldSpacing (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
|
|
||||||
}
|
|
||||||
if got := applyOutsideCodeFences(input, fixSetextAmbiguity); got != input {
|
|
||||||
t.Fatalf("fixSetextAmbiguity (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
|
|
||||||
}
|
|
||||||
if got := applyOutsideCodeFences(input, fixBlockquoteHardBreaks); got != input {
|
|
||||||
t.Fatalf("fixBlockquoteHardBreaks (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content outside the fence should still be transformed.
|
|
||||||
mixed := "**foo ** before\n```\n**x **\n```\n**bar ** after"
|
|
||||||
got := applyOutsideCodeFences(mixed, fixBoldSpacing)
|
|
||||||
if strings.Contains(got, "**foo **") {
|
|
||||||
t.Errorf("fixBoldSpacing did not fix bold before fence: %q", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "**bar **") {
|
|
||||||
t.Errorf("fixBoldSpacing did not fix bold after fence: %q", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "```\n**x **\n```") {
|
|
||||||
t.Errorf("fixBoldSpacing modified content inside fence: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixTopLevelSoftbreaksQuoteContainer(t *testing.T) {
|
|
||||||
input := "<quote-container>\nline1\nline2\n</quote-container>"
|
|
||||||
got := fixTopLevelSoftbreaks(input)
|
|
||||||
// quote-container is a content container: blank lines inserted between inner lines.
|
|
||||||
want := "<quote-container>\n\nline1\n\nline2\n</quote-container>"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("fixTopLevelSoftbreaks quote-container = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,28 +9,44 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
const docsServiceHelpDefault = `Document and content operations.`
|
const docsServiceHelpDefault = `Document and content operations.`
|
||||||
|
|
||||||
const docsServiceHelpV2 = `Document and content operations (v2).`
|
const docsSkillReadCommand = "lark-cli skills read lark-doc"
|
||||||
|
const docsXMLSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-xml.md"
|
||||||
|
const docsMDSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-md.md"
|
||||||
|
const docsContentSkillHelp = "AI agents MUST read " +
|
||||||
|
docsXMLSkillReadCommand + " before writing any --content payload; " +
|
||||||
|
"when using --doc-format markdown, also read " + docsMDSkillReadCommand + ". " +
|
||||||
|
"Follow the latest rules there, and MUST NOT grep/open local SKILL.md files " +
|
||||||
|
"to discover this guidance"
|
||||||
|
|
||||||
var docsVersionSelectionTips = []string{
|
func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||||
"Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
switch strings.TrimPrefix(shortcut, "+") {
|
||||||
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
|
case "create":
|
||||||
}
|
return docsSkillReadCommand + " references/lark-doc-create.md"
|
||||||
|
case "fetch":
|
||||||
var docsV2VersionSelectionTips = []string{
|
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||||
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
|
case "update":
|
||||||
}
|
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||||
|
default:
|
||||||
func docsTipsForVersion(apiVersion string) []string {
|
return docsSkillReadCommand
|
||||||
if apiVersion == "v2" {
|
}
|
||||||
return docsV2VersionSelectionTips
|
}
|
||||||
|
|
||||||
|
func docsHelpCommandForShortcut(shortcut string) string {
|
||||||
|
switch strings.TrimPrefix(shortcut, "+") {
|
||||||
|
case "create":
|
||||||
|
return "lark-cli docs +create --help"
|
||||||
|
case "fetch":
|
||||||
|
return "lark-cli docs +fetch --help"
|
||||||
|
case "update":
|
||||||
|
return "lark-cli docs +update --help"
|
||||||
|
default:
|
||||||
|
return "lark-cli docs --help"
|
||||||
}
|
}
|
||||||
return docsVersionSelectionTips
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shortcuts returns all docs shortcuts.
|
// Shortcuts returns all docs shortcuts.
|
||||||
@@ -48,45 +64,32 @@ func Shortcuts() []common.Shortcut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
|
||||||
// The shortcut-level help remains compatible with legacy v1 skills; this parent
|
|
||||||
// help switches docs guidance to match the selected API version.
|
|
||||||
func ConfigureServiceHelp(cmd *cobra.Command) {
|
func ConfigureServiceHelp(cmd *cobra.Command) {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serviceCmd := cmd
|
cmd.Long = docsHelpLong(docsServiceHelpDefault, docsSkillReadCommand)
|
||||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
}
|
||||||
if cmd.Flags().Lookup("api-version") == nil {
|
|
||||||
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
|
func installDocsShortcutHelp(command string) func(*cobra.Command) {
|
||||||
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command) {
|
||||||
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
|
cmd.Long = docsHelpLong(cmd.Short, docsSkillReadCommandForShortcut(command))
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultHelp := cmd.HelpFunc()
|
func docsHelpLong(summary, skillReadCommand string) string {
|
||||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
return strings.TrimSpace(fmt.Sprintf(`%s
|
||||||
if cmd != serviceCmd {
|
|
||||||
defaultHelp(cmd, args)
|
Start here (required for AI agents):
|
||||||
return
|
%s
|
||||||
}
|
|
||||||
|
AI agents MUST read the matching embedded skill before choosing flags
|
||||||
apiVersion, _ := cmd.Flags().GetString("api-version")
|
or running docs commands. Do not skip this step, and do not infer
|
||||||
previousLong := cmd.Long
|
workflows from --help alone. MUST NOT grep/open local SKILL.md files
|
||||||
if apiVersion == "v2" {
|
to discover this guidance; use %s so content stays version-matched
|
||||||
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
|
with this CLI. Skills ship with the CLI and include docs workflows,
|
||||||
} else {
|
selector/block-id usage, XML/Markdown formats, and copy-paste examples.
|
||||||
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
|
|
||||||
}
|
skills read lark-doc Docs workflow guide
|
||||||
defer func() {
|
skills read lark-doc <path> Read a referenced docs skill file`, strings.TrimSpace(summary), skillReadCommand, skillReadCommand))
|
||||||
cmd.Long = previousLong
|
|
||||||
}()
|
|
||||||
|
|
||||||
defaultHelp(cmd, args)
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
fmt.Fprintln(out, "Tips:")
|
|
||||||
for _, tip := range docsTipsForVersion(apiVersion) {
|
|
||||||
fmt.Fprintf(out, " • %s\n", tip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
103
shortcuts/doc/v2_only.go
Normal file
103
shortcuts/doc/v2_only.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type docsLegacyFlag struct {
|
||||||
|
Name string
|
||||||
|
Replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsAPIVersionCompatFlag() common.Flag {
|
||||||
|
return common.Flag{
|
||||||
|
Name: "api-version",
|
||||||
|
Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples",
|
||||||
|
Default: "v2",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsCreateLegacyFlags() []docsLegacyFlag {
|
||||||
|
return []docsLegacyFlag{
|
||||||
|
{Name: "title", Replacement: "put the title in --content, for example <title>Title</title>"},
|
||||||
|
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
|
||||||
|
{Name: "folder-token", Replacement: "use --parent-token"},
|
||||||
|
{Name: "wiki-node", Replacement: "use --parent-token"},
|
||||||
|
{Name: "wiki-space", Replacement: "use --parent-position my_library or a concrete parent position"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsFetchLegacyFlags() []docsLegacyFlag {
|
||||||
|
return []docsLegacyFlag{
|
||||||
|
{Name: "offset", Replacement: "use --scope outline/range/keyword/section for partial reads"},
|
||||||
|
{Name: "limit", Replacement: "use --scope outline/range/keyword/section for partial reads"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsUpdateLegacyFlags() []docsLegacyFlag {
|
||||||
|
return []docsLegacyFlag{
|
||||||
|
{Name: "mode", Replacement: "use --command"},
|
||||||
|
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
|
||||||
|
{Name: "selection-with-ellipsis", Replacement: "use --command str_replace with --pattern"},
|
||||||
|
{Name: "selection-by-title", Replacement: "fetch block ids first, then use --command block_replace/block_insert_after with --block-id"},
|
||||||
|
{Name: "new-title", Replacement: "update the title through XML content in --content"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
|
||||||
|
out := make([]common.Flag, 0, len(flags))
|
||||||
|
for _, flag := range flags {
|
||||||
|
out = append(out, common.Flag{
|
||||||
|
Name: flag.Name,
|
||||||
|
Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill",
|
||||||
|
Hidden: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error {
|
||||||
|
switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
|
||||||
|
case "", "v1", "v2":
|
||||||
|
default:
|
||||||
|
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API")
|
||||||
|
}
|
||||||
|
|
||||||
|
var used []string
|
||||||
|
var replacements []string
|
||||||
|
for _, flag := range legacyFlags {
|
||||||
|
if !runtime.Changed(flag.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used = append(used, "--"+flag.Name)
|
||||||
|
if flag.Replacement != "" {
|
||||||
|
replacements = append(replacements, "--"+flag.Name+" -> "+flag.Replacement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(used) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := "the old v1 interface has been shut down; legacy v1 flag(s) " + strings.Join(used, ", ") + " are no longer supported"
|
||||||
|
if len(replacements) > 0 {
|
||||||
|
detail += "; " + strings.Join(replacements, "; ")
|
||||||
|
}
|
||||||
|
return docsV2OnlyError(shortcut, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsV2OnlyError(shortcut, detail string) error {
|
||||||
|
return common.FlagErrorf(
|
||||||
|
"docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags",
|
||||||
|
shortcut,
|
||||||
|
detail,
|
||||||
|
docsSkillReadCommandForShortcut(shortcut),
|
||||||
|
docsXMLSkillReadCommand,
|
||||||
|
docsMDSkillReadCommand,
|
||||||
|
docsHelpCommandForShortcut(shortcut),
|
||||||
|
)
|
||||||
|
}
|
||||||
86
shortcuts/doc/v2_only_test.go
Normal file
86
shortcuts/doc/v2_only_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) {
|
||||||
|
for _, apiVersion := range []string{"", "v1", "v2"} {
|
||||||
|
t.Run(apiVersion, func(t *testing.T) {
|
||||||
|
runtime := docsV2OnlyTestRuntime(t, apiVersion, false)
|
||||||
|
if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil {
|
||||||
|
t.Fatalf("validateDocsV2Only(%q) error = %v, want nil", apiVersion, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) {
|
||||||
|
runtime := docsV2OnlyTestRuntime(t, "v0", false)
|
||||||
|
err := validateDocsV2Only(runtime, "+fetch", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unknown --api-version to be rejected")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"docs +fetch is v2-only",
|
||||||
|
"--api-version is deprecated and only accepts v1 or v2",
|
||||||
|
"both values execute the v2 API",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||||
|
"MUST NOT grep/open local SKILL.md files",
|
||||||
|
"lark-cli docs +fetch --help",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error missing %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) {
|
||||||
|
runtime := docsV2OnlyTestRuntime(t, "", true)
|
||||||
|
err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected changed legacy flag to be rejected")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"the old v1 interface has been shut down",
|
||||||
|
"legacy v1 flag(s) --mode are no longer supported",
|
||||||
|
"--mode -> use --command",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-update.md",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||||
|
"lark-cli skills read lark-doc references/lark-doc-md.md",
|
||||||
|
"MUST NOT grep/open local SKILL.md files",
|
||||||
|
"lark-cli docs +update --help",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("error missing %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsV2OnlyTestRuntime(t *testing.T, apiVersion string, legacyMode bool) *common.RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := &cobra.Command{Use: "+update"}
|
||||||
|
cmd.Flags().String("api-version", "", "")
|
||||||
|
cmd.Flags().String("mode", "", "")
|
||||||
|
if apiVersion != "" {
|
||||||
|
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
|
||||||
|
t.Fatalf("set api-version: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if legacyMode {
|
||||||
|
if err := cmd.Flags().Set("mode", "overwrite"); err != nil {
|
||||||
|
t.Fatalf("set mode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return common.TestNewRuntimeContext(cmd, nil)
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
// installVersionedHelp sets a custom help function on cmd that shows only the
|
|
||||||
// flags relevant to the selected --api-version. flagVersions maps flag name to
|
|
||||||
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
|
|
||||||
// always visible.
|
|
||||||
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
|
|
||||||
origHelp := cmd.HelpFunc()
|
|
||||||
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
|
||||||
ver, _ := cmd.Flags().GetString("api-version")
|
|
||||||
if ver == "" {
|
|
||||||
ver = defaultVersion
|
|
||||||
}
|
|
||||||
// Show/hide flags based on the active version.
|
|
||||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
|
||||||
if fv, ok := flagVersions[f.Name]; ok {
|
|
||||||
f.Hidden = fv != ver
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cmdutil.SetTips(cmd, docsTipsForVersion(ver))
|
|
||||||
origHelp(cmd, args)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
|
|
||||||
// path is used.
|
|
||||||
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
|
|
||||||
fmt.Fprintf(runtime.IO().ErrOut,
|
|
||||||
"[deprecated] docs %s is using the v1 API. %s\n",
|
|
||||||
shortcut, docsV2VersionSelectionTips[0])
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package doc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) {
|
|
||||||
for _, shortcut := range []string{"+create", "+fetch", "+update"} {
|
|
||||||
t.Run(shortcut, func(t *testing.T) {
|
|
||||||
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
|
|
||||||
warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut)
|
|
||||||
|
|
||||||
got := stderr.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
"[deprecated] docs " + shortcut + " is using the v1 API.",
|
|
||||||
"Check the installed lark-doc skill first",
|
|
||||||
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("warning missing %q:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(got, "will be removed in a future release") {
|
|
||||||
t.Fatalf("warning should not include removal-only guidance:\n%s", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
driveInspectRateLimitRetries = 2
|
||||||
|
driveInspectRetryInitialBackoff = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
var driveInspectAfter = time.After
|
||||||
|
|
||||||
var DriveInspect = common.Shortcut{
|
var DriveInspect = common.Shortcut{
|
||||||
Service: "drive",
|
Service: "drive",
|
||||||
Command: "+inspect",
|
Command: "+inspect",
|
||||||
@@ -35,32 +43,15 @@ var DriveInspect = common.Shortcut{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
raw := strings.TrimSpace(runtime.Str("url"))
|
if _, err := driveInspectResolveRef(runtime); err != nil {
|
||||||
if raw == "" {
|
return err
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, ok := common.ParseResourceURL(raw)
|
|
||||||
if !ok {
|
|
||||||
// Not a recognized URL pattern.
|
|
||||||
if strings.Contains(raw, "://") {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
|
||||||
}
|
|
||||||
// Bare token: --type is required.
|
|
||||||
if strings.TrimSpace(runtime.Str("type")) == "" {
|
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
raw := strings.TrimSpace(runtime.Str("url"))
|
ref, err := driveInspectResolveRef(runtime)
|
||||||
ref, ok := common.ParseResourceURL(raw)
|
if err != nil {
|
||||||
if !ok {
|
return common.NewDryRunAPI()
|
||||||
ref = common.ResourceRef{
|
|
||||||
Type: strings.TrimSpace(runtime.Str("type")),
|
|
||||||
Token: raw,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dry := common.NewDryRunAPI()
|
dry := common.NewDryRunAPI()
|
||||||
@@ -91,15 +82,9 @@ var DriveInspect = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
raw := strings.TrimSpace(runtime.Str("url"))
|
raw := strings.TrimSpace(runtime.Str("url"))
|
||||||
|
ref, err := driveInspectResolveRef(runtime)
|
||||||
// Step 1: Parse URL to extract {type, token}.
|
if err != nil {
|
||||||
ref, ok := common.ParseResourceURL(raw)
|
return err
|
||||||
if !ok {
|
|
||||||
// Bare token: use --type.
|
|
||||||
ref = common.ResourceRef{
|
|
||||||
Type: strings.TrimSpace(runtime.Str("type")),
|
|
||||||
Token: raw,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inputURL := raw
|
inputURL := raw
|
||||||
@@ -111,14 +96,19 @@ var DriveInspect = common.Shortcut{
|
|||||||
// Step 2: If type is "wiki", unwrap via get_node API.
|
// Step 2: If type is "wiki", unwrap via get_node API.
|
||||||
if docType == "wiki" {
|
if docType == "wiki" {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
|
||||||
data, err := runtime.CallAPITyped(
|
data, err := driveInspectCallWithRetry(
|
||||||
"GET",
|
ctx,
|
||||||
"/open-apis/wiki/v2/spaces/get_node",
|
func() (map[string]interface{}, error) {
|
||||||
map[string]interface{}{"token": docToken},
|
return runtime.CallAPITyped(
|
||||||
nil,
|
"GET",
|
||||||
|
"/open-apis/wiki/v2/spaces/get_node",
|
||||||
|
map[string]interface{}{"token": docToken},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return driveInspectAnnotateError("resolve_wiki", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
node := common.GetMap(data, "node")
|
node := common.GetMap(data, "node")
|
||||||
@@ -145,9 +135,9 @@ var DriveInspect = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Call batch_query to verify and get title.
|
// Step 3: Call batch_query to verify and get title.
|
||||||
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
|
title, err := driveInspectFetchMetaTitle(ctx, runtime, docToken, docType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return driveInspectAnnotateError("query_meta", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Build the resolved URL.
|
// Step 4: Build the resolved URL.
|
||||||
@@ -181,3 +171,116 @@ var DriveInspect = common.Shortcut{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func driveInspectResolveRef(runtime *common.RuntimeContext) (common.ResourceRef, error) {
|
||||||
|
raw := strings.TrimSpace(runtime.Str("url"))
|
||||||
|
if raw == "" {
|
||||||
|
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||||
|
ref, ok := common.ParseResourceURL(raw)
|
||||||
|
if ok {
|
||||||
|
if inputType != "" && inputType != ref.Type {
|
||||||
|
return common.ResourceRef{}, errs.NewValidationError(
|
||||||
|
errs.SubtypeInvalidArgument,
|
||||||
|
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||||
|
inputType,
|
||||||
|
ref.Type,
|
||||||
|
).WithParam("--type")
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(raw, "://") {
|
||||||
|
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(raw, "/?#") {
|
||||||
|
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments and pass only the raw token with --type", raw).WithParam("--url")
|
||||||
|
}
|
||||||
|
if inputType == "" {
|
||||||
|
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
|
||||||
|
}
|
||||||
|
return common.ResourceRef{Type: inputType, Token: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveInspectFetchMetaTitle(ctx context.Context, runtime *common.RuntimeContext, token, docType string) (string, error) {
|
||||||
|
var title string
|
||||||
|
_, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
|
||||||
|
got, callErr := common.FetchDriveMeta(runtime, token, docType, false)
|
||||||
|
if callErr != nil {
|
||||||
|
return nil, callErr
|
||||||
|
}
|
||||||
|
title = got.Title
|
||||||
|
return map[string]interface{}{"title": got.Title}, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return title, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveInspectCallWithRetry(ctx context.Context, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= driveInspectRateLimitRetries; attempt++ {
|
||||||
|
data, err := call()
|
||||||
|
if err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
if !driveInspectShouldRetry(err) || attempt == driveInspectRateLimitRetries {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
backoff := driveInspectRetryInitialBackoff * time.Duration(1<<attempt)
|
||||||
|
if waitErr := driveInspectWait(ctx, backoff); waitErr != nil {
|
||||||
|
return nil, waitErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveInspectShouldRetry(err error) bool {
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || problem == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400 || problem.Retryable
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveInspectWait(ctx context.Context, d time.Duration) error {
|
||||||
|
if d <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errs.WrapInternal(ctx.Err())
|
||||||
|
case <-driveInspectAfter(d):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveInspectAnnotateError(stage string, err error) error {
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || problem == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
label := map[string]string{
|
||||||
|
"resolve_wiki": "resolve wiki node",
|
||||||
|
"query_meta": "query document metadata",
|
||||||
|
}[stage]
|
||||||
|
if label == "" {
|
||||||
|
label = stage
|
||||||
|
}
|
||||||
|
problem.Message = fmt.Sprintf("%s failed: %s", label, problem.Message)
|
||||||
|
if strings.TrimSpace(problem.Hint) == "" {
|
||||||
|
switch stage {
|
||||||
|
case "resolve_wiki":
|
||||||
|
problem.Hint = "check that the wiki URL/token is valid and that the current identity can read the wiki node"
|
||||||
|
case "query_meta":
|
||||||
|
problem.Hint = "check that the resolved document still exists and that the current identity can read its metadata"
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(problem.Hint, label) {
|
||||||
|
problem.Hint = label + ": " + problem.Hint
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ package drive
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -83,6 +86,34 @@ func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDriveInspectValidate_URLTypeConflict(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||||
|
cmd.Flags().String("url", "", "")
|
||||||
|
cmd.Flags().String("type", "", "")
|
||||||
|
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnBareToken")
|
||||||
|
_ = cmd.Flags().Set("type", "sheet")
|
||||||
|
|
||||||
|
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||||
|
err := DriveInspect.Validate(context.Background(), runtime)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for conflicting --type, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveInspectValidate_BareTokenWithPathFragment(t *testing.T) {
|
||||||
|
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||||
|
cmd.Flags().String("url", "", "")
|
||||||
|
cmd.Flags().String("type", "", "")
|
||||||
|
_ = cmd.Flags().Set("url", "doxcnBareToken/extra")
|
||||||
|
_ = cmd.Flags().Set("type", "docx")
|
||||||
|
|
||||||
|
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
|
||||||
|
err := DriveInspect.Validate(context.Background(), runtime)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for bare token with path fragment, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
|
||||||
cmd := &cobra.Command{Use: "drive +inspect"}
|
cmd := &cobra.Command{Use: "drive +inspect"}
|
||||||
cmd.Flags().String("url", "", "")
|
cmd.Flags().String("url", "", "")
|
||||||
@@ -540,6 +571,76 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for batch_query failure, got nil")
|
t.Fatal("expected error for batch_query failure, got nil")
|
||||||
}
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "query document metadata failed") {
|
||||||
|
t.Fatalf("message = %q, want query document metadata prefix", p.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveInspectExecute_RetriesRateLimitOnWikiResolve(t *testing.T) {
|
||||||
|
cfg := driveTestConfig()
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991400,
|
||||||
|
"msg": "request trigger frequency limit",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"node": map[string]interface{}{
|
||||||
|
"obj_type": "docx",
|
||||||
|
"obj_token": "doxcnUnwrapped",
|
||||||
|
"space_id": "space123",
|
||||||
|
"node_token": "wikcnNodeToken",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/drive/v1/metas/batch_query",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"metas": []map[string]interface{}{
|
||||||
|
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
origAfter := driveInspectAfter
|
||||||
|
driveInspectAfter = func(time.Duration) <-chan time.Time {
|
||||||
|
ch := make(chan time.Time, 1)
|
||||||
|
ch <- time.Now()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
defer func() { driveInspectAfter = origAfter }()
|
||||||
|
|
||||||
|
err := mountAndRunDrive(t, DriveInspect, []string{
|
||||||
|
"+inspect",
|
||||||
|
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error after retry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeDriveEnvelope(t, stdout)
|
||||||
|
if data["token"] != "doxcnUnwrapped" {
|
||||||
|
t.Fatalf("token = %v, want doxcnUnwrapped", data["token"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func batchResolveByBasicContact(runtime *common.RuntimeContext, missingIDs []str
|
|||||||
}
|
}
|
||||||
batch := missingIDs[i:end]
|
batch := missingIDs[i:end]
|
||||||
|
|
||||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
data, err := runtime.DoAPIJSONTyped(http.MethodPost,
|
||||||
"/open-apis/contact/v3/users/basic_batch",
|
"/open-apis/contact/v3/users/basic_batch",
|
||||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||||
map[string]interface{}{"user_ids": batch},
|
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, "&")
|
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 {
|
if err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,20 +200,20 @@ func batchResolveMergeForwardSenders(runtime *common.RuntimeContext, prefetch ma
|
|||||||
// container via a single API call. Returns a flat list of raw message items
|
// container via a single API call. Returns a flat list of raw message items
|
||||||
// with upper_message_id for tree reconstruction.
|
// 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
|
// — 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
|
// 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
|
// "code: 2200 Internal Error" with its log_id would show up as just "empty
|
||||||
// data" in the output).
|
// data" in the output).
|
||||||
func fetchMergeForwardSubMessages(messageID string, runtime *common.RuntimeContext) ([]map[string]interface{}, error) {
|
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"},
|
"user_id_type": []string{"open_id"},
|
||||||
"card_msg_content_type": []string{"raw_card_content"},
|
"card_msg_content_type": []string{"raw_card_content"},
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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
|
// Reading from a nil map in Go is safe (returns the zero value, never
|
||||||
// panics), but guarding explicitly makes the "successful empty
|
// panics), but guarding explicitly makes the "successful empty
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIn
|
|||||||
queries = append(queries, map[string]interface{}{"message_id": id})
|
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",
|
"/open-apis/im/v1/messages/reactions/batch_query",
|
||||||
nil,
|
nil,
|
||||||
map[string]interface{}{"queries": queries},
|
map[string]interface{}{"queries": queries},
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ func ExpandThreadReplies(runtime *common.RuntimeContext, messages []map[string]i
|
|||||||
// Returns the raw message items, whether more replies exist beyond the limit,
|
// Returns the raw message items, whether more replies exist beyond the limit,
|
||||||
// and a non-nil error when the API call fails.
|
// and a non-nil error when the API call fails.
|
||||||
func fetchThreadReplies(runtime *common.RuntimeContext, threadID string, limit int) ([]map[string]interface{}, bool, error) {
|
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_type": []string{"thread"},
|
||||||
"container_id": []string{threadID},
|
"container_id": []string{threadID},
|
||||||
"sort_type": []string{"ByCreateTimeAsc"},
|
"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"},
|
"card_msg_content_type": []string{"raw_card_content"},
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != 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)
|
hasMore, _ := data["has_more"].(bool)
|
||||||
rawItems, _ := data["items"].([]interface{})
|
rawItems, _ := data["items"].([]interface{})
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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) {
|
func flagMessageID(rt *common.RuntimeContext) (string, error) {
|
||||||
id := strings.TrimSpace(rt.Str("message-id"))
|
id := strings.TrimSpace(rt.Str("message-id"))
|
||||||
if 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_") {
|
if strings.HasPrefix(id, "omt_") {
|
||||||
return "", output.ErrValidation(
|
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)
|
"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)
|
return validateMessageID(id)
|
||||||
}
|
}
|
||||||
@@ -65,10 +65,10 @@ func buildMGetURL(ids []string) string {
|
|||||||
func validateMessageID(input string) (string, error) {
|
func validateMessageID(input string) (string, error) {
|
||||||
input = strings.TrimSpace(input)
|
input = strings.TrimSpace(input)
|
||||||
if 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_") {
|
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
|
return input, nil
|
||||||
}
|
}
|
||||||
@@ -173,14 +173,16 @@ func sanitizeURLForDisplay(rawURL string) string {
|
|||||||
// startURLDownload performs URL validation, creates an HTTP client, and sends a
|
// startURLDownload performs URL validation, creates an HTTP client, and sends a
|
||||||
// GET request. It returns the response (with Body still open) and the file
|
// GET request. It returns the response (with Body still open) and the file
|
||||||
// extension inferred from the URL. The caller must close resp.Body.
|
// 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 {
|
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()
|
httpClient, err := runtime.Factory.HttpClient()
|
||||||
if err != nil {
|
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{
|
httpClient = validate.NewDownloadHTTPClient(httpClient, validate.DownloadHTTPClientOptions{
|
||||||
AllowHTTP: true,
|
AllowHTTP: true,
|
||||||
@@ -188,17 +190,19 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
if err != 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)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("download failed: %w", err)
|
return nil, "", wrapIMNetworkErr(err, "download failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
resp.Body.Close()
|
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))
|
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
|
// downloadURLToReader returns a size-limited io.ReadCloser for the URL content
|
||||||
// and the file extension inferred from the URL. The caller must close the
|
// 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.
|
// 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) {
|
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) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
|
resp, ext, err := startURLDownload(ctx, runtime, rawURL, param) //nolint:bodyclose // resp.Body is closed by the returned limitedReadCloser
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
@@ -233,7 +237,7 @@ func (l *limitedReadCloser) Read(p []byte) (int, error) {
|
|||||||
n, err := l.r.Read(p)
|
n, err := l.r.Read(p)
|
||||||
l.n += int64(n)
|
l.n += int64(n)
|
||||||
if l.n > l.max {
|
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
|
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))
|
fmt.Fprintf(runtime.IO().ErrOut, "downloading %s: %s\n", s.flagName, sanitizeURLForDisplay(s.value))
|
||||||
|
|
||||||
if s.kind == mediaKindImage {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
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.
|
// 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 {
|
if err != nil {
|
||||||
return "", err
|
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))
|
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value))
|
||||||
|
|
||||||
if s.kind == mediaKindImage {
|
if s.kind == mediaKindImage {
|
||||||
return uploadImageToIM(ctx, runtime, s.value, "message")
|
return uploadImageToIM(ctx, runtime, s.value, "message", s.flagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
ft := detectIMFileType(s.value)
|
ft := detectIMFileType(s.value)
|
||||||
@@ -349,7 +353,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me
|
|||||||
if s.withDuration {
|
if s.withDuration {
|
||||||
dur = parseMediaDuration(runtime, s.value, ft)
|
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
|
// 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)
|
coverKey, err := resolveOneMedia(ctx, runtime, coverSpec)
|
||||||
if err != nil {
|
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})
|
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})
|
jsonBytes, _ := json.Marshal(map[string]string{"text": fallbackText})
|
||||||
return "text", string(jsonBytes), nil
|
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.
|
// resolveP2PChatID resolves user open_id to P2P chat_id.
|
||||||
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
|
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
|
||||||
if runtime.IsBot() {
|
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{
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||||
HttpMethod: http.MethodPost,
|
HttpMethod: http.MethodPost,
|
||||||
@@ -405,11 +409,10 @@ func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var result map[string]interface{}
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse chat_p2p response: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
data, _ := result["data"].(map[string]interface{})
|
|
||||||
|
|
||||||
chats, _ := data["p2p_chats"].([]interface{})
|
chats, _ := data["p2p_chats"].([]interface{})
|
||||||
for _, item := range chats {
|
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.
|
// 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
|
return id, nil
|
||||||
}
|
}
|
||||||
if !messageIDRe.MatchString(id) {
|
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{
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||||
@@ -439,11 +442,10 @@ func resolveThreadID(runtime *common.RuntimeContext, id string) (string, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
var result map[string]interface{}
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse message response: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
data, _ := result["data"].(map[string]interface{})
|
|
||||||
|
|
||||||
items, _ := data["items"].([]interface{})
|
items, _ := data["items"].([]interface{})
|
||||||
for _, item := range items {
|
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
|
// 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.
|
// newMediaBuffer downloads URL content into memory via downloadURLToReader.
|
||||||
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64) (*mediaBuffer, error) {
|
func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL string, maxSize int64, param string) (*mediaBuffer, error) {
|
||||||
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize)
|
rc, ext, err := downloadURLToReader(ctx, runtime, rawURL, maxSize, param)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -621,7 +623,7 @@ func newMediaBuffer(ctx context.Context, runtime *common.RuntimeContext, rawURL
|
|||||||
|
|
||||||
data, err := io.ReadAll(rc)
|
data, err := io.ReadAll(rc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download failed: %w", err)
|
return nil, wrapIMNetworkErr(err, "download failed")
|
||||||
}
|
}
|
||||||
return newMediaBufferFromBytes(data, ext, rawURL), nil
|
return newMediaBufferFromBytes(data, ext, rawURL), nil
|
||||||
}
|
}
|
||||||
@@ -927,7 +929,7 @@ func resolveMarkdownImageURLs(ctx context.Context, runtime *common.RuntimeContex
|
|||||||
}
|
}
|
||||||
imgURL := sub[1]
|
imgURL := sub[1]
|
||||||
|
|
||||||
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize)
|
rc, _, err := downloadURLToReader(ctx, runtime, imgURL, maxImageUploadSize, "--markdown")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
|
fmt.Fprintf(runtime.IO().ErrOut, "warning: failed to download image %s: %v\n", sanitizeURLForDisplay(imgURL), err)
|
||||||
return ""
|
return ""
|
||||||
@@ -1049,14 +1051,14 @@ func detectIMFileType(filePath string) string {
|
|||||||
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
||||||
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
|
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 {
|
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)
|
f, err := runtime.FileIO().Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
@@ -1073,27 +1075,25 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse error: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := result["data"].(map[string]interface{})
|
|
||||||
imageKey, _ := data["image_key"].(string)
|
imageKey, _ := data["image_key"].(string)
|
||||||
if imageKey == "" {
|
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
|
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 {
|
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)
|
f, err := runtime.FileIO().Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", withIMValidationParam(common.WrapInputStatErrorTyped(err), param)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
@@ -1114,15 +1114,13 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse error: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := result["data"].(map[string]interface{})
|
|
||||||
fileKey, _ := data["file_key"].(string)
|
fileKey, _ := data["file_key"].(string)
|
||||||
if fileKey == "" {
|
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
|
return fileKey, nil
|
||||||
}
|
}
|
||||||
@@ -1142,15 +1140,13 @@ func uploadImageFromReader(ctx context.Context, runtime *common.RuntimeContext,
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse error: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := result["data"].(map[string]interface{})
|
|
||||||
imageKey, _ := data["image_key"].(string)
|
imageKey, _ := data["image_key"].(string)
|
||||||
if imageKey == "" {
|
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
|
return imageKey, nil
|
||||||
}
|
}
|
||||||
@@ -1174,15 +1170,13 @@ func uploadFileFromReader(ctx context.Context, runtime *common.RuntimeContext, r
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("parse error: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := result["data"].(map[string]interface{})
|
|
||||||
fileKey, _ := data["file_key"].(string)
|
fileKey, _ := data["file_key"].(string)
|
||||||
if fileKey == "" {
|
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
|
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))
|
result, err := rt.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(rt.As(), rt.Config.AppID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrWithHint(output.ExitAuth, "auth",
|
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "cannot verify required scope(s): %v", err).
|
||||||
fmt.Sprintf("cannot verify required scope(s): %v", err),
|
WithHint("%s", flagScopeLoginHint(required)).
|
||||||
flagScopeLoginHint(required))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
if result == nil || result.Scopes == "" {
|
if result == nil || result.Scopes == "" {
|
||||||
fmt.Fprintf(rt.IO().ErrOut,
|
fmt.Fprintf(rt.IO().ErrOut,
|
||||||
@@ -1248,9 +1242,9 @@ func checkFlagRequiredScopes(ctx context.Context, rt *common.RuntimeContext, req
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
return errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scope(s): %s", strings.Join(missing, ", ")).
|
||||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
WithMissingScopes(missing...).
|
||||||
flagScopeLoginHint(missing))
|
WithHint("%s", flagScopeLoginHint(missing))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1276,11 +1270,11 @@ func parseItemID(id string) (ItemType, FlagType, error) {
|
|||||||
case strings.HasPrefix(id, "om_"):
|
case strings.HasPrefix(id, "om_"):
|
||||||
return ItemTypeDefault, FlagTypeMessage, nil
|
return ItemTypeDefault, FlagTypeMessage, nil
|
||||||
case id == "":
|
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:
|
default:
|
||||||
return 0, 0, output.ErrValidation(
|
return 0, 0, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"cannot infer item type from id %q: expected om_ (message) prefix; "+
|
"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":
|
case "msg_thread":
|
||||||
return ItemTypeMsgThread, nil
|
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.
|
// parseFlagType converts a user-facing string to the server enum.
|
||||||
@@ -1305,7 +1299,7 @@ func parseFlagType(s string) (FlagType, error) {
|
|||||||
case "feed":
|
case "feed":
|
||||||
return FlagTypeFeed, nil
|
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.
|
// 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.
|
// getMessageChatID queries the message API to get the chat_id.
|
||||||
// Used by flag-create to determine the chat type for feed-layer flags.
|
// Used by flag-create to determine the chat type for feed-layer flags.
|
||||||
func getMessageChatID(rt *common.RuntimeContext, messageID string) (string, error) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
items, ok := data["items"].([]any)
|
items, ok := data["items"].([]any)
|
||||||
if !ok || len(items) == 0 {
|
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)
|
msg, ok := items[0].(map[string]any)
|
||||||
if !ok {
|
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)
|
chatID, ok := msg["chat_id"].(string)
|
||||||
if !ok {
|
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
|
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
|
// Returns an error if the chat query fails, since guessing the wrong item_type
|
||||||
// can cause silent failures in flag operations.
|
// can cause silent failures in flag operations.
|
||||||
func resolveThreadFeedItemType(rt *common.RuntimeContext, chatID string) (ItemType, error) {
|
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 {
|
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)
|
chatMode, _ := data["chat_mode"].(string)
|
||||||
if chatMode == "topic" {
|
if chatMode == "topic" {
|
||||||
return ItemTypeThread, nil
|
return ItemTypeThread, nil
|
||||||
}
|
}
|
||||||
return ItemTypeMsgThread, nil
|
return ItemTypeMsgThread, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShortcutType enumerates the OpenAPI feed-shortcut types.
|
||||||
|
// Currently the server only opens CHAT (1) externally; other internal values
|
||||||
|
// (DOC, OPENAPP, etc.) are not yet whitelisted on the OAPI gateway.
|
||||||
|
type ShortcutType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ShortcutTypeUnknown ShortcutType = 0
|
||||||
|
ShortcutTypeChat ShortcutType = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
feedShortcutBatchLimit = 10
|
||||||
|
feedShortcutWriteScope = "im:feed.shortcut:write"
|
||||||
|
feedShortcutReadScope = "im:feed.shortcut:read"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shortcutItem is one entry in the feed_shortcuts API body.
|
||||||
|
type shortcutItem struct {
|
||||||
|
FeedCardID string `json:"feed_card_id"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectChatIDs reads --chat-id values (repeatable + comma-split) and
|
||||||
|
// returns deduped, validated oc_ IDs. The server batch limit is 10.
|
||||||
|
func collectChatIDs(rt *common.RuntimeContext) ([]string, error) {
|
||||||
|
raw := rt.StrSlice("chat-id")
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx); repeat the flag or pass comma-separated values").WithParam("--chat-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(raw))
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, v := range raw {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(v, "oc_") {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"invalid --chat-id %q: must be an open_chat_id starting with oc_", v).WithParam("--chat-id")
|
||||||
|
}
|
||||||
|
if _, ok := seen[v]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v] = struct{}{}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||||
|
}
|
||||||
|
if len(out) > feedShortcutBatchLimit {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
|
"too many --chat-id values (%d); the server accepts up to %d per request",
|
||||||
|
len(out), feedShortcutBatchLimit).WithParam("--chat-id")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildShortcutItems converts chat IDs to API payload entries (type=CHAT).
|
||||||
|
func buildShortcutItems(ids []string) []shortcutItem {
|
||||||
|
items := make([]shortcutItem, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
items = append(items, shortcutItem{FeedCardID: id, Type: int(ShortcutTypeChat)})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcutFailedReasonString converts the numeric failed-reason enum returned
|
||||||
|
// by the server into a human-readable label. Used to enrich the response
|
||||||
|
// when the API reports per-item failures.
|
||||||
|
func shortcutFailedReasonString(reason int) string {
|
||||||
|
switch reason {
|
||||||
|
case 0:
|
||||||
|
return "unknown"
|
||||||
|
case 1:
|
||||||
|
return "no_permission"
|
||||||
|
case 2:
|
||||||
|
return "invalid_item"
|
||||||
|
case 3:
|
||||||
|
return "has_pending_delete"
|
||||||
|
case 4:
|
||||||
|
return "type_not_support"
|
||||||
|
case 5:
|
||||||
|
return "internal_error"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatBatchQueryScope is the scope required by im.chats.batch_query, which
|
||||||
|
// the CHAT detail resolver depends on. Surfaced as a conditional scope on
|
||||||
|
// +feed-shortcut-list so the framework's scope diagnostics know about it.
|
||||||
|
const chatBatchQueryScope = "im:chat:read"
|
||||||
|
|
||||||
|
// chatBatchQuerySize matches the server-side limit on /im/v1/chats/batch_query.
|
||||||
|
const chatBatchQuerySize = 50
|
||||||
|
|
||||||
|
// shortcutTypeFromValue parses the type field as returned by the v2
|
||||||
|
// feed_shortcuts API. JSON numbers come back as float64 after generic
|
||||||
|
// unmarshal; we also tolerate the int form for forward-compat.
|
||||||
|
func shortcutTypeFromValue(v any) ShortcutType {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return ShortcutType(int(n))
|
||||||
|
case int:
|
||||||
|
return ShortcutType(n)
|
||||||
|
case json.Number:
|
||||||
|
i, err := n.Int64()
|
||||||
|
if err == nil {
|
||||||
|
return ShortcutType(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ShortcutTypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryChatBatch fetches one im.chats.batch_query page (at most
|
||||||
|
// chatBatchQuerySize ids) and merges the full chat objects into dst keyed by
|
||||||
|
// chat_id. Shared by feed-shortcut detail enrichment and message-search chat
|
||||||
|
// context lookup, which apply their own per-chunk error policies.
|
||||||
|
func queryChatBatch(rt *common.RuntimeContext, batch []string, dst map[string]map[string]any) error {
|
||||||
|
res, err := rt.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
||||||
|
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||||
|
map[string]any{"chat_ids": batch})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items, _ := res["items"].([]any)
|
||||||
|
for _, ci := range items {
|
||||||
|
cm, _ := ci.(map[string]any)
|
||||||
|
if cm == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := asString(cm["chat_id"]); id != "" {
|
||||||
|
dst[id] = cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveChatDetail batch-fetches the full chat object via
|
||||||
|
// im.chats.batch_query (50 ids per request — server limit) and returns the
|
||||||
|
// objects keyed by chat_id, verbatim, so the caller can decide which fields
|
||||||
|
// to surface. The server's `name` field is empty for p2p chats (client UI
|
||||||
|
// shows the partner's display name there), but the full object still carries
|
||||||
|
// `chat_mode`, `p2p_target_id`, `description`, etc., so callers can render
|
||||||
|
// p2p entries however they want.
|
||||||
|
func resolveChatDetail(rt *common.RuntimeContext, ids []string) (map[string]map[string]any, error) {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := checkFlagRequiredScopes(rt.Ctx(), rt, []string{chatBatchQueryScope}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, batch := range chunkStrings(ids, chatBatchQuerySize) {
|
||||||
|
if err := queryChatBatch(rt, batch, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichFeedShortcutDetail walks the list response and attaches the full chat
|
||||||
|
// object under `detail` for CHAT-type entries — the only type the OpenAPI
|
||||||
|
// gateway exposes today. Mutates data in place.
|
||||||
|
//
|
||||||
|
// Failures are returned to the caller so it can decide whether to hard-fail
|
||||||
|
// the command or downgrade to a warning. Listing the shortcuts succeeds even
|
||||||
|
// if enrichment is unavailable (missing scope, network error, etc.).
|
||||||
|
func enrichFeedShortcutDetail(rt *common.RuntimeContext, data map[string]any) error {
|
||||||
|
items, _ := data["shortcuts"].([]any)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
ids := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
m, _ := it.(map[string]any)
|
||||||
|
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := asString(m["feed_card_id"])
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
details, err := resolveChatDetail(rt, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing items (server didn't return one for an id we asked about) are
|
||||||
|
// left untouched, so the presence of `detail` signals a successful lookup.
|
||||||
|
for _, it := range items {
|
||||||
|
m, _ := it.(map[string]any)
|
||||||
|
if m == nil || shortcutTypeFromValue(m["type"]) != ShortcutTypeChat {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, ok := details[asString(m["feed_card_id"])]; ok {
|
||||||
|
m["detail"] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// annotateFailedShortcuts walks the API response and attaches a
|
||||||
|
// reason_label string next to each numeric reason. Mutates data in place.
|
||||||
|
func annotateFailedShortcuts(data map[string]any) {
|
||||||
|
items, ok := data["failed_shortcuts"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
m, _ := it.(map[string]any)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// reason is serialized as a JSON number → float64 after generic unmarshal.
|
||||||
|
switch r := m["reason"].(type) {
|
||||||
|
case float64:
|
||||||
|
m["reason_label"] = shortcutFailedReasonString(int(r))
|
||||||
|
case int:
|
||||||
|
m["reason_label"] = shortcutFailedReasonString(r)
|
||||||
|
case json.Number:
|
||||||
|
i, err := r.Int64()
|
||||||
|
if err == nil {
|
||||||
|
m["reason_label"] = shortcutFailedReasonString(int(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitFeedShortcutWriteResult preserves the server payload while adding a
|
||||||
|
// batch ledger. A feed-shortcut write can return HTTP/API success with
|
||||||
|
// failed_shortcuts populated; callers still need a complete account of which
|
||||||
|
// requested entries succeeded and which failed.
|
||||||
|
func emitFeedShortcutWriteResult(rt *common.RuntimeContext, requested []shortcutItem, data map[string]any) error {
|
||||||
|
// A fully-successful write can come back as code:0 with data:null, in
|
||||||
|
// which case DoAPIJSON hands us a nil map; the caller is still owed a
|
||||||
|
// ledger, so start from an empty object instead of panicking on write.
|
||||||
|
if data == nil {
|
||||||
|
data = map[string]any{}
|
||||||
|
}
|
||||||
|
annotateFailedShortcuts(data)
|
||||||
|
addFeedShortcutWriteLedger(data, requested)
|
||||||
|
if hasFailedShortcuts(data) {
|
||||||
|
return rt.OutPartialFailure(data, nil)
|
||||||
|
}
|
||||||
|
rt.Out(data, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFeedShortcutWriteLedger(data map[string]any, requested []shortcutItem) {
|
||||||
|
failed := failedShortcutItems(data)
|
||||||
|
// Failed entries are matched back to requested items by feed_card_id
|
||||||
|
// alone: every requested item is CHAT-type, so the id is the identity,
|
||||||
|
// and a failed echo with a missing or zero type still excludes its item
|
||||||
|
// from the success list.
|
||||||
|
failedIDs := map[string]struct{}{}
|
||||||
|
for _, it := range failed {
|
||||||
|
m, _ := it.(map[string]any)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shortcut, _ := m["shortcut"].(map[string]any)
|
||||||
|
if shortcut == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id := asString(shortcut["feed_card_id"]); id != "" {
|
||||||
|
failedIDs[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
succeeded := make([]shortcutItem, 0, len(requested))
|
||||||
|
for _, it := range requested {
|
||||||
|
if _, isFailed := failedIDs[it.FeedCardID]; isFailed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
succeeded = append(succeeded, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts are derived from the requested-item accounting alone so the
|
||||||
|
// success+failure==total invariant holds even if the server echoes a
|
||||||
|
// failed entry twice or reports one we never asked about;
|
||||||
|
// failed_shortcuts still carries the raw server report.
|
||||||
|
data["total"] = len(requested)
|
||||||
|
data["success_count"] = len(succeeded)
|
||||||
|
data["failure_count"] = len(requested) - len(succeeded)
|
||||||
|
data["succeeded_shortcuts"] = succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFailedShortcuts(data map[string]any) bool {
|
||||||
|
return len(failedShortcutItems(data)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedShortcutItems(data map[string]any) []any {
|
||||||
|
items, _ := data["failed_shortcuts"].([]any)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -445,8 +447,15 @@ func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
|
|||||||
cmdutil.TestChdir(t, t.TempDir())
|
cmdutil.TestChdir(t, t.TempDir())
|
||||||
target := "out.bin"
|
target := "out.bin"
|
||||||
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true)
|
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target, true)
|
||||||
if err != context.Canceled {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
|
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
|
// First attempt is made, then retry checks ctx.Err() and returns
|
||||||
if attempts != 1 {
|
if attempts != 1 {
|
||||||
@@ -600,6 +609,14 @@ func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
|
|||||||
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
|
||||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
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) {
|
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||||
t.Fatalf("output file exists after failed download, stat error = %v", 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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -754,7 +771,7 @@ func TestUploadFileToIMSuccess(t *testing.T) {
|
|||||||
t.Fatalf("WriteFile() error = %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
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) {
|
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return nil, fmt.Errorf("unexpected")
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
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) {
|
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) {
|
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
return nil, fmt.Errorf("unexpected")
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
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{
|
runtime := &common.RuntimeContext{
|
||||||
Factory: &cmdutil.Factory{
|
Factory: &cmdutil.Factory{
|
||||||
FileIOProvider: fileio.GetProvider(),
|
FileIOProvider: fileio.GetProvider(),
|
||||||
@@ -826,8 +855,49 @@ func TestResolveMediaContentWrapsUploadError(t *testing.T) {
|
|||||||
|
|
||||||
missing := "missing.png"
|
missing := "missing.png"
|
||||||
_, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "")
|
_, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "")
|
||||||
if err == nil || !strings.Contains(err.Error(), "image upload failed") {
|
var ve *errs.ValidationError
|
||||||
t.Fatalf("resolveMediaContent() error = %v", err)
|
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)
|
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)
|
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
|
if !strings.Contains(gotBody, `name="file_name"`) || !strings.Contains(gotBody, localName) {
|
||||||
|
|||||||
@@ -606,6 +606,12 @@ func TestShortcuts(t *testing.T) {
|
|||||||
"+flag-create",
|
"+flag-create",
|
||||||
"+flag-cancel",
|
"+flag-cancel",
|
||||||
"+flag-list",
|
"+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) {
|
if !reflect.DeepEqual(commands, want) {
|
||||||
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -52,7 +53,7 @@ var ImChatCreate = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if runtime.Bool("set-bot-manager") && !runtime.IsBot() {
|
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")
|
name := runtime.Str("name")
|
||||||
@@ -60,25 +61,25 @@ var ImChatCreate = common.Shortcut{
|
|||||||
|
|
||||||
// Public groups must have a name with at least 2 characters.
|
// Public groups must have a name with at least 2 characters.
|
||||||
if chatType == "public" && len([]rune(name)) < 2 {
|
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.
|
// Group name length must not exceed 60 characters.
|
||||||
if len([]rune(name)) > 60 {
|
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.
|
// Description length must not exceed 100 characters.
|
||||||
if desc := runtime.Str("description"); len([]rune(desc)) > 100 {
|
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.
|
// Validate users.
|
||||||
if users := runtime.Str("users"); users != "" {
|
if users := runtime.Str("users"); users != "" {
|
||||||
ids := common.SplitCSV(users)
|
ids := common.SplitCSV(users)
|
||||||
if len(ids) > 50 {
|
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 {
|
for _, id := range ids {
|
||||||
if _, err := common.ValidateUserID(id); err != nil {
|
if _, err := common.ValidateUserIDTyped("--users", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,18 +89,18 @@ var ImChatCreate = common.Shortcut{
|
|||||||
if bots := runtime.Str("bots"); bots != "" {
|
if bots := runtime.Str("bots"); bots != "" {
|
||||||
ids := common.SplitCSV(bots)
|
ids := common.SplitCSV(bots)
|
||||||
if len(ids) > 5 {
|
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 {
|
for _, id := range ids {
|
||||||
if !strings.HasPrefix(id, "cli_") {
|
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.
|
// Validate owner.
|
||||||
if owner := runtime.Str("owner"); owner != "" {
|
if owner := runtime.Str("owner"); owner != "" {
|
||||||
if _, err := common.ValidateUserID(owner); err != nil {
|
if _, err := common.ValidateUserIDTyped("--owner", owner); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ var ImChatCreate = common.Shortcut{
|
|||||||
if runtime.Bool("set-bot-manager") {
|
if runtime.Bool("set-bot-manager") {
|
||||||
qp["set_bot_manager"] = []string{"true"}
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ var ImChatCreate = common.Shortcut{
|
|||||||
|
|
||||||
// Try to fetch the group share link without blocking on failure.
|
// Try to fetch the group share link without blocking on failure.
|
||||||
if chatID, ok := resData["chat_id"].(string); ok && chatID != "" {
|
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)),
|
fmt.Sprintf("/open-apis/im/v1/chats/%s/link", validate.EncodePathSegment(chatID)),
|
||||||
nil, nil)
|
nil, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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).
|
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
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"))
|
parts, err := normalizeTypes(runtime.StrSlice("types"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
|
if len(parts) == 1 && parts[0] == "p2p" && runtime.IsBot() {
|
||||||
return output.ErrValidation(
|
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.`)
|
`--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
|
return nil
|
||||||
},
|
},
|
||||||
@@ -95,7 +96,7 @@ var ImChatList = common.Shortcut{
|
|||||||
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
writeBotStripP2pWarning(runtime.IO().ErrOut)
|
||||||
}
|
}
|
||||||
params := buildChatListParams(runtime, effective)
|
params := buildChatListParams(runtime, effective)
|
||||||
resData, err := runtime.CallAPI("GET", imChatListPath, params, nil)
|
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -211,10 +212,10 @@ func normalizeTypes(raw []string) ([]string, error) {
|
|||||||
for _, p := range raw {
|
for _, p := range raw {
|
||||||
p = strings.TrimSpace(strings.ToLower(p))
|
p = strings.TrimSpace(strings.ToLower(p))
|
||||||
if 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" {
|
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 {
|
if _, dup := seen[p]; dup {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
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.
|
// Under bot identity, --user-id is not supported; require --chat-id only.
|
||||||
if runtime.IsBot() {
|
if runtime.IsBot() {
|
||||||
if runtime.Str("user-id") != "" {
|
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") == "" {
|
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 {
|
} 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") == "" {
|
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
|
return err
|
||||||
}
|
}
|
||||||
@@ -82,12 +83,12 @@ var ImChatMessageList = common.Shortcut{
|
|||||||
|
|
||||||
// Validate ID formats
|
// Validate ID formats
|
||||||
if chatFlag := runtime.Str("chat-id"); chatFlag != "" {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if userFlag := runtime.Str("user-id"); userFlag != "" {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +110,7 @@ var ImChatMessageList = common.Shortcut{
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -205,14 +206,14 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string)
|
|||||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||||
startTime, err := common.ParseTime(startFlag)
|
startTime, err := common.ParseTime(startFlag)
|
||||||
if err != nil {
|
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}
|
params["start_time"] = []string{startTime}
|
||||||
}
|
}
|
||||||
if endFlag := runtime.Str("end"); endFlag != "" {
|
if endFlag := runtime.Str("end"); endFlag != "" {
|
||||||
endTime, err := common.ParseTime(endFlag, "end")
|
endTime, err := common.ParseTime(endFlag, "end")
|
||||||
if err != nil {
|
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}
|
params["end_time"] = []string{endTime}
|
||||||
}
|
}
|
||||||
@@ -236,7 +237,7 @@ func resolveChatIDForMessagesList(runtime *common.RuntimeContext, dryRun bool) (
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if chatId == "" {
|
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
|
return chatId, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -53,10 +54,10 @@ var ImChatSearch = common.Shortcut{
|
|||||||
query := runtime.Str("query")
|
query := runtime.Str("query")
|
||||||
memberIDs := runtime.Str("member-ids")
|
memberIDs := runtime.Str("member-ids")
|
||||||
if query == "" && memberIDs == "" {
|
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 {
|
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 != "" {
|
if st := runtime.Str("search-types"); st != "" {
|
||||||
allowed := map[string]struct{}{
|
allowed := map[string]struct{}{
|
||||||
@@ -67,23 +68,23 @@ var ImChatSearch = common.Shortcut{
|
|||||||
}
|
}
|
||||||
for _, item := range common.SplitCSV(st) {
|
for _, item := range common.SplitCSV(st) {
|
||||||
if _, ok := allowed[item]; !ok {
|
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 != "" {
|
if mi := runtime.Str("member-ids"); mi != "" {
|
||||||
ids := common.SplitCSV(mi)
|
ids := common.SplitCSV(mi)
|
||||||
if len(ids) > 50 {
|
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 {
|
for _, id := range ids {
|
||||||
if _, err := common.ValidateUserID(id); err != nil {
|
if _, err := common.ValidateUserIDTyped("--member-ids", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n := runtime.Int("page-size"); n < 1 || n > 100 {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
@@ -94,7 +95,7 @@ var ImChatSearch = common.Shortcut{
|
|||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
body := buildSearchChatBody(runtime)
|
body := buildSearchChatBody(runtime)
|
||||||
params := buildSearchChatParams(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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
chat := runtime.Str("chat-id")
|
chat := runtime.Str("chat-id")
|
||||||
if _, err := common.ValidateChatID(chat); err != nil {
|
if _, err := common.ValidateChatIDTyped("--chat-id", chat); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate --name length.
|
// Validate --name length.
|
||||||
name := runtime.Str("name")
|
name := runtime.Str("name")
|
||||||
if name != "" && len([]rune(name)) > 60 {
|
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.
|
// Validate --description length.
|
||||||
if desc := runtime.Str("description"); desc != "" && len([]rune(desc)) > 100 {
|
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.
|
// At least one field must be provided for update.
|
||||||
body := buildUpdateChatBody(runtime)
|
body := buildUpdateChatBody(runtime)
|
||||||
if len(body) == 0 {
|
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
|
return nil
|
||||||
@@ -65,7 +65,7 @@ var ImChatUpdate = common.Shortcut{
|
|||||||
chatID := runtime.Str("chat-id")
|
chatID := runtime.Str("chat-id")
|
||||||
body := buildUpdateChatBody(runtime)
|
body := buildUpdateChatBody(runtime)
|
||||||
|
|
||||||
_, err := runtime.DoAPIJSON(http.MethodPut,
|
_, err := runtime.DoAPIJSONTyped(http.MethodPut,
|
||||||
fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)),
|
fmt.Sprintf("/open-apis/im/v1/chats/%s", validate.EncodePathSegment(chatID)),
|
||||||
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
larkcore.QueryParams{"user_id_type": []string{"open_id"}},
|
||||||
body,
|
body,
|
||||||
|
|||||||
63
shortcuts/im/im_errors.go
Normal file
63
shortcuts/im/im_errors.go
Normal 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)
|
||||||
|
}
|
||||||
82
shortcuts/im/im_errors_test.go
Normal file
82
shortcuts/im/im_errors_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
713
shortcuts/im/im_feed_group_item_test.go
Normal file
713
shortcuts/im/im_feed_group_item_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
shortcuts/im/im_feed_group_items.go
Normal file
140
shortcuts/im/im_feed_group_items.go
Normal 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)
|
||||||
|
}
|
||||||
234
shortcuts/im/im_feed_group_list.go
Normal file
234
shortcuts/im/im_feed_group_list.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
207
shortcuts/im/im_feed_group_list_item.go
Normal file
207
shortcuts/im/im_feed_group_list_item.go
Normal 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
|
||||||
|
}
|
||||||
257
shortcuts/im/im_feed_group_list_test.go
Normal file
257
shortcuts/im/im_feed_group_list_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
90
shortcuts/im/im_feed_group_query_item.go
Normal file
90
shortcuts/im/im_feed_group_query_item.go
Normal 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
|
||||||
|
}
|
||||||
97
shortcuts/im/im_feed_shortcut_create.go
Normal file
97
shortcuts/im/im_feed_shortcut_create.go
Normal 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
|
||||||
|
}
|
||||||
79
shortcuts/im/im_feed_shortcut_list.go
Normal file
79
shortcuts/im/im_feed_shortcut_list.go
Normal 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}}
|
||||||
|
}
|
||||||
57
shortcuts/im/im_feed_shortcut_remove.go
Normal file
57
shortcuts/im/im_feed_shortcut_remove.go
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
1092
shortcuts/im/im_feed_shortcut_test.go
Normal file
1092
shortcuts/im/im_feed_shortcut_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,21 +8,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
|
// 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.
|
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
|
||||||
var ImFlagCancel = common.Shortcut{
|
var ImFlagCancel = common.Shortcut{
|
||||||
Service: "im",
|
Service: "im",
|
||||||
Command: "+flag-cancel",
|
Command: "+flag-cancel",
|
||||||
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
|
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",
|
||||||
"performs double-cancel: removes both message and feed layers",
|
Risk: "write",
|
||||||
Risk: "write",
|
UserScopes: flagWriteLookupScopes,
|
||||||
UserScopes: flagWriteLookupScopes,
|
AuthTypes: []string{"user"},
|
||||||
AuthTypes: []string{"user"},
|
HasFormat: true,
|
||||||
HasFormat: true,
|
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "message-id", Desc: "message ID (om_xxx)"},
|
{Name: "message-id", Desc: "message ID (om_xxx)"},
|
||||||
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
|
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},
|
||||||
@@ -63,11 +62,9 @@ var ImFlagCancel = common.Shortcut{
|
|||||||
"item_type": itemType,
|
"item_type": itemType,
|
||||||
"flag_type": flagType,
|
"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}})
|
map[string]any{"flag_items": []flagItem{item}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: cancel failed for %s/%s: %v\n",
|
|
||||||
itemType, flagType, err)
|
|
||||||
result["status"] = "failed"
|
result["status"] = "failed"
|
||||||
result["error"] = err.Error()
|
result["error"] = err.Error()
|
||||||
lastErr = err
|
lastErr = err
|
||||||
@@ -78,8 +75,12 @@ var ImFlagCancel = common.Shortcut{
|
|||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.Out(map[string]any{"results": results}, nil)
|
payload := map[string]any{"results": results}
|
||||||
return lastErr
|
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
|
// Provide more specific hints for common mistakes
|
||||||
if itOverride != "" && ftOverride == "" {
|
if itOverride != "" && ftOverride == "" {
|
||||||
if itemType == ItemTypeThread || itemType == ItemTypeMsgThread {
|
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)",
|
"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",
|
"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 != "" {
|
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",
|
"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")
|
"invalid --item-type/--flag-type combination: supported pairs are default+message, thread+feed, and msg_thread+feed")
|
||||||
}
|
}
|
||||||
return newFlagItem(id, itemType, flagType), nil
|
return newFlagItem(id, itemType, flagType), nil
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
var ImFlagCreate = common.Shortcut{
|
var ImFlagCreate = common.Shortcut{
|
||||||
Service: "im",
|
Service: "im",
|
||||||
Command: "+flag-create",
|
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",
|
Risk: "write",
|
||||||
UserScopes: flagWriteLookupScopes,
|
UserScopes: flagWriteLookupScopes,
|
||||||
AuthTypes: []string{"user"},
|
AuthTypes: []string{"user"},
|
||||||
@@ -50,12 +50,14 @@ var ImFlagCreate = common.Shortcut{
|
|||||||
}
|
}
|
||||||
// Combo validation already done in Validate, but double-check as a safety net.
|
// Combo validation already done in Validate, but double-check as a safety net.
|
||||||
if !isValidCombo(parseItemTypeFromRaw(item.ItemType), parseFlagTypeFromRaw(item.FlagType)) {
|
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 "+
|
"invalid (item_type=%s, flag_type=%s) combination; the server only accepts "+
|
||||||
"(default, message), (thread, feed), or (msg_thread, feed)",
|
"(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}})
|
map[string]any{"flag_items": []flagItem{item}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -138,18 +140,16 @@ func buildCreateItem(rt *common.RuntimeContext) (flagItem, error) {
|
|||||||
|
|
||||||
chatID, err := getMessageChatID(rt, id)
|
chatID, err := getMessageChatID(rt, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return flagItem{}, output.ErrValidation(
|
return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly")
|
||||||
"failed to query message for feed-layer flag: %v; if you know the chat type, specify --item-type explicitly", err)
|
|
||||||
}
|
}
|
||||||
if chatID == "" {
|
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")
|
"message does not belong to a chat; feed-layer flags are only for messages in chats")
|
||||||
}
|
}
|
||||||
|
|
||||||
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
feedIT, err := resolveThreadFeedItemType(rt, chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return flagItem{}, output.ErrValidation(
|
return flagItem{}, appendIMRecoveryHint(err, "specify --item-type explicitly")
|
||||||
"failed to determine chat type: %v; if you know the chat type, specify --item-type explicitly", err)
|
|
||||||
}
|
}
|
||||||
return newFlagItem(id, feedIT, FlagTypeFeed), nil
|
return newFlagItem(id, feedIT, FlagTypeFeed), nil
|
||||||
}
|
}
|
||||||
@@ -186,18 +186,24 @@ func parseExplicitFlagCombo(itOverride, ftOverride string) (explicitFlagCombo, e
|
|||||||
if combo.ItemTypeSet && !combo.FlagTypeSet {
|
if combo.ItemTypeSet && !combo.FlagTypeSet {
|
||||||
switch combo.ItemType {
|
switch combo.ItemType {
|
||||||
case ItemTypeThread, ItemTypeMsgThread:
|
case ItemTypeThread, ItemTypeMsgThread:
|
||||||
return explicitFlagCombo{}, output.ErrValidation(
|
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"--item-type=%s requires --flag-type=feed; message-layer flags always use item-type=default", itOverride)
|
"--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:
|
case ItemTypeDefault:
|
||||||
return explicitFlagCombo{}, output.ErrValidation(
|
return explicitFlagCombo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"--item-type=default requires --flag-type=message; or omit both to use default behavior")
|
"--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) {
|
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",
|
"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
|
return combo, nil
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
)
|
)
|
||||||
@@ -56,7 +56,7 @@ var ImFlagList = common.Shortcut{
|
|||||||
return executeListAllPages(runtime)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -72,10 +72,10 @@ var ImFlagList = common.Shortcut{
|
|||||||
|
|
||||||
func validateListOptions(rt *common.RuntimeContext) error {
|
func validateListOptions(rt *common.RuntimeContext) error {
|
||||||
if n := rt.Int("page-size"); n < 1 || n > 50 {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ func enrichFeedThreadItems(rt *common.RuntimeContext, data map[string]any) error
|
|||||||
end = len(ids)
|
end = len(ids)
|
||||||
}
|
}
|
||||||
batch := ids[i:end]
|
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)
|
larkcore.QueryParams{"message_ids": batch}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -244,7 +244,7 @@ func executeListAllPages(rt *common.RuntimeContext) error {
|
|||||||
if page > 0 {
|
if page > 0 {
|
||||||
token = lastPageToken
|
token = lastPageToken
|
||||||
}
|
}
|
||||||
data, err := rt.DoAPIJSON("GET", "/open-apis/im/v1/flags",
|
data, err := rt.DoAPIJSONTyped("GET", "/open-apis/im/v1/flags",
|
||||||
larkcore.QueryParams{
|
larkcore.QueryParams{
|
||||||
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
|
||||||
"page_token": []string{token},
|
"page_token": []string{token},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -593,18 +594,18 @@ func TestCheckFlagRequiredScopesReportsTokenResolutionError(t *testing.T) {
|
|||||||
setRuntimeTokenError(t, rt, errors.New("token cache unavailable"))
|
setRuntimeTokenError(t, rt, errors.New("token cache unavailable"))
|
||||||
|
|
||||||
err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes)
|
err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes)
|
||||||
var exitErr *output.ExitError
|
var authErr *errs.AuthenticationError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &authErr) {
|
||||||
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want ExitError", err, err)
|
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want *errs.AuthenticationError", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth || exitErr.Detail.Type != "auth" {
|
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||||
t.Fatalf("checkFlagRequiredScopes() detail = %+v code=%d, want auth exit", exitErr.Detail, exitErr.Code)
|
t.Fatalf("checkFlagRequiredScopes() subtype = %q, want %q", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, "cannot verify required scope") {
|
if !strings.Contains(authErr.Message, "cannot verify required scope") {
|
||||||
t.Fatalf("message = %q, want scope verification context", exitErr.Detail.Message)
|
t.Fatalf("message = %q, want scope verification context", authErr.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Hint, strings.Join(flagMessageReadScopes, " ")) {
|
if !strings.Contains(authErr.Hint, strings.Join(flagMessageReadScopes, " ")) {
|
||||||
t.Fatalf("hint = %q, want required scopes", exitErr.Detail.Hint)
|
t.Fatalf("hint = %q, want required scopes", authErr.Hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,6 +1338,10 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("Execute() expected partial failure error, got 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()
|
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"} {
|
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 {
|
var envelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
Data struct {
|
Data struct {
|
||||||
Results []map[string]any `json:"results"`
|
Results []map[string]any `json:"results"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
@@ -1356,6 +1362,12 @@ func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
|
|||||||
if len(envelope.Data.Results) != 2 {
|
if len(envelope.Data.Results) != 2 {
|
||||||
t.Fatalf("results len = %d, want 2", len(envelope.Data.Results))
|
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) {
|
func TestBuildCancelItems_OnlyItemTypeOverride(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||||
if len(ids) == 0 {
|
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 {
|
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 {
|
for _, id := range ids {
|
||||||
if _, err := validateMessageID(id); err != nil {
|
if _, err := validateMessageID(id); err != nil {
|
||||||
@@ -58,7 +59,7 @@ var ImMessagesMGet = common.Shortcut{
|
|||||||
ids := common.SplitCSV(runtime.Str("message-ids"))
|
ids := common.SplitCSV(runtime.Str("message-ids"))
|
||||||
mgetURL := buildMGetURL(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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -102,20 +102,20 @@ var ImMessagesReply = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if messageId == "" {
|
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 {
|
if _, err := validateMessageID(messageId); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
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)) {
|
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 != "" {
|
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
|
return nil
|
||||||
@@ -167,7 +167,7 @@ var ImMessagesReply = common.Shortcut{
|
|||||||
data["uuid"] = idempotencyKey
|
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)),
|
fmt.Sprintf("/open-apis/im/v1/messages/%s/reply", validate.EncodePathSegment(messageId)),
|
||||||
nil, data)
|
nil, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if messageId := runtime.Str("message-id"); messageId == "" {
|
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 {
|
} else if _, err := validateMessageID(messageId); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
|
relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return err
|
||||||
}
|
}
|
||||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
@@ -67,10 +67,10 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
|||||||
fileType := runtime.Str("type")
|
fileType := runtime.Str("type")
|
||||||
relPath, err := normalizeDownloadOutputPath(fileKey, runtime.Str("output"))
|
relPath, err := normalizeDownloadOutputPath(fileKey, runtime.Str("output"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("invalid output path: %s", err)
|
return err
|
||||||
}
|
}
|
||||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
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") != ""
|
userSpecifiedOutput := runtime.Str("output") != ""
|
||||||
@@ -87,23 +87,23 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
|||||||
func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
||||||
fileKey = strings.TrimSpace(fileKey)
|
fileKey = strings.TrimSpace(fileKey)
|
||||||
if 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, "/\\") {
|
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 == "" {
|
if outputPath == "" {
|
||||||
return fileKey, nil
|
return fileKey, nil
|
||||||
}
|
}
|
||||||
outputPath = filepath.Clean(strings.TrimSpace(outputPath))
|
outputPath = filepath.Clean(strings.TrimSpace(outputPath))
|
||||||
if 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) {
|
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)) {
|
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
|
return outputPath, nil
|
||||||
}
|
}
|
||||||
@@ -192,7 +192,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
|||||||
return 0, closeErr
|
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 {
|
switch err {
|
||||||
@@ -222,7 +222,7 @@ func (r *rangeChunkReader) Read(p []byte) (int, error) {
|
|||||||
if r.delivered == r.totalSize {
|
if r.delivered == r.totalSize {
|
||||||
return 0, io.EOF
|
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)
|
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 {
|
if resp.StatusCode != http.StatusPartialContent {
|
||||||
resp.Body.Close()
|
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
|
r.current = resp.Body
|
||||||
@@ -270,7 +270,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
|||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
if downloadResp == nil {
|
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 {
|
if downloadResp.StatusCode >= 400 {
|
||||||
@@ -289,7 +289,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
|||||||
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
|
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
downloadResp.Body.Close()
|
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)
|
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
|
||||||
sizeBytes = totalSize
|
sizeBytes = totalSize
|
||||||
@@ -300,7 +300,7 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
downloadResp.Body.Close()
|
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()
|
defer body.Close()
|
||||||
|
|
||||||
@@ -309,10 +309,10 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
|||||||
ContentLength: sizeBytes,
|
ContentLength: sizeBytes,
|
||||||
}, body)
|
}, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
|
return "", 0, common.WrapSaveErrorTyped(err)
|
||||||
}
|
}
|
||||||
if sizeBytes >= 0 && result.Size() != sizeBytes {
|
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)
|
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||||
if resolveErr != nil || savedPath == "" {
|
if resolveErr != nil || savedPath == "" {
|
||||||
@@ -404,7 +404,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, ctx.Err()
|
return nil, imContextError(ctx.Err())
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
if attempt == imDownloadRequestRetries {
|
if attempt == imDownloadRequestRetries {
|
||||||
@@ -415,7 +415,7 @@ func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeCon
|
|||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return nil, lastErr
|
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) {
|
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
|
||||||
@@ -431,37 +431,37 @@ func sleepIMDownloadRetry(ctx context.Context, attempt int) {
|
|||||||
func downloadResponseError(resp *http.Response) error {
|
func downloadResponseError(resp *http.Response) error {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
if len(body) > 0 {
|
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) {
|
func parseTotalSize(contentRange string) (int64, error) {
|
||||||
contentRange = strings.TrimSpace(contentRange)
|
contentRange = strings.TrimSpace(contentRange)
|
||||||
if 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 ") {
|
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)
|
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
|
||||||
if len(parts) != 2 || parts[1] == "" {
|
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] == "*" {
|
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] == "*" {
|
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)
|
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
if err != nil {
|
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 {
|
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
|
return totalSize, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||||
@@ -22,7 +23,6 @@ const (
|
|||||||
messagesSearchDefaultPageLimit = 20
|
messagesSearchDefaultPageLimit = 20
|
||||||
messagesSearchMaxPageLimit = 40
|
messagesSearchMaxPageLimit = 40
|
||||||
messagesSearchMGetBatchSize = 50
|
messagesSearchMGetBatchSize = 50
|
||||||
messagesSearchChatBatchSize = 50
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ImMessagesSearch = common.Shortcut{
|
var ImMessagesSearch = common.Shortcut{
|
||||||
@@ -269,7 +269,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||||
pageLimit := runtime.Int("page-limit")
|
pageLimit := runtime.Int("page-limit")
|
||||||
if pageLimit < 1 || pageLimit > messagesSearchMaxPageLimit {
|
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 != "" {
|
if startFlag != "" {
|
||||||
ts, err := common.ParseTime(startFlag)
|
ts, err := common.ParseTime(startFlag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("--start: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
startTs = ts
|
startTs = ts
|
||||||
start := startFlag
|
start := startFlag
|
||||||
@@ -288,7 +288,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
if endFlag != "" {
|
if endFlag != "" {
|
||||||
ts, err := common.ParseTime(endFlag, "end")
|
ts, err := common.ParseTime(endFlag, "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrValidation("--end: %v", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
endTs = ts
|
endTs = ts
|
||||||
end := endFlag
|
end := endFlag
|
||||||
@@ -298,7 +298,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
sv, _ := strconv.ParseInt(startTs, 10, 64)
|
sv, _ := strconv.ParseInt(startTs, 10, 64)
|
||||||
ev, _ := strconv.ParseInt(endTs, 10, 64)
|
ev, _ := strconv.ParseInt(endTs, 10, 64)
|
||||||
if sv > ev {
|
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 {
|
if len(timeRange) > 0 {
|
||||||
@@ -307,12 +307,12 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
|
|
||||||
if senderTypeFlag != "" && excludeSenderTypeFlag != "" {
|
if senderTypeFlag != "" && excludeSenderTypeFlag != "" {
|
||||||
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 != "" {
|
if chatFlag != "" {
|
||||||
for _, chatID := range common.SplitCSV(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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
}
|
}
|
||||||
if senderFlag != "" {
|
if senderFlag != "" {
|
||||||
for _, userID := range common.SplitCSV(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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
if atChatterIdsFlag != "" {
|
if atChatterIdsFlag != "" {
|
||||||
ids := common.SplitCSV(atChatterIdsFlag)
|
ids := common.SplitCSV(atChatterIdsFlag)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if _, err := common.ValidateUserID(id); err != nil {
|
if _, err := common.ValidateUserIDTyped("--at-chatter-ids", id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +358,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
|||||||
|
|
||||||
pageSize := runtime.Int("page-size")
|
pageSize := runtime.Int("page-size")
|
||||||
if pageSize < 1 {
|
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 {
|
if pageSize > messagesSearchMaxPageSize {
|
||||||
pageSize = messagesSearchMaxPageSize
|
pageSize = messagesSearchMaxPageSize
|
||||||
@@ -421,7 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
|||||||
params["page_token"] = []string{pageToken}
|
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 {
|
if err != nil {
|
||||||
return nil, false, "", false, pageLimit, err
|
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) {
|
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||||
var items []interface{}
|
var items []interface{}
|
||||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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{} {
|
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||||
chatContexts := map[string]map[string]interface{}{}
|
chatContexts := map[string]map[string]interface{}{}
|
||||||
for _, batch := range chunkStrings(chatIds, messagesSearchChatBatchSize) {
|
// Best-effort: a failed chunk only loses its own entries.
|
||||||
chatRes, chatErr := runtime.DoAPIJSON(
|
for _, batch := range chunkStrings(chatIds, chatBatchQuerySize) {
|
||||||
http.MethodPost, "/open-apis/im/v1/chats/batch_query",
|
_ = queryChatBatch(runtime, batch, chatContexts)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return chatContexts
|
return chatContexts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ID formats
|
// Validate ID formats
|
||||||
if chatFlag != "" {
|
if chatFlag != "" {
|
||||||
if _, err := common.ValidateChatID(chatFlag); err != nil {
|
if _, err := common.ValidateChatIDTyped("--chat-id", chatFlag); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if userFlag != "" {
|
if userFlag != "" {
|
||||||
if _, err := common.ValidateUserID(userFlag); err != nil {
|
if _, err := common.ValidateUserIDTyped("--user-id", userFlag); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg := validateContentFlags(text, markdown, content, imageKey, fileKey, videoKey, videoCoverKey, audioKey); msg != "" {
|
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)) {
|
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 != "" {
|
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
|
return nil
|
||||||
@@ -193,7 +193,7 @@ var ImMessagesSend = common.Shortcut{
|
|||||||
data["uuid"] = idempotencyKey
|
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)
|
larkcore.QueryParams{"receive_id_type": []string{receiveIdType}}, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -220,7 +220,7 @@ func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
|
||||||
@@ -46,7 +47,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
|||||||
sortType = "ByCreateTimeDesc"
|
sortType = "ByCreateTimeDesc"
|
||||||
}
|
}
|
||||||
|
|
||||||
pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||||
|
|
||||||
d := common.NewDryRunAPI()
|
d := common.NewDryRunAPI()
|
||||||
containerID := threadFlag
|
containerID := threadFlag
|
||||||
@@ -79,12 +80,12 @@ var ImThreadsMessagesList = common.Shortcut{
|
|||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
threadId := runtime.Str("thread")
|
threadId := runtime.Str("thread")
|
||||||
if threadId == "" {
|
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_") {
|
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
|
return err
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
@@ -100,7 +101,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
|||||||
sortType = "ByCreateTimeDesc"
|
sortType = "ByCreateTimeDesc"
|
||||||
}
|
}
|
||||||
|
|
||||||
pageSize, _ := common.ValidatePageSize(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||||
|
|
||||||
params := map[string][]string{
|
params := map[string][]string{
|
||||||
"container_id_type": []string{"thread"},
|
"container_id_type": []string{"thread"},
|
||||||
@@ -113,7 +114,7 @@ var ImThreadsMessagesList = common.Shortcut{
|
|||||||
params["page_token"] = []string{pageToken}
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ package im
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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
|
return map[string]bool{}, nil, nil
|
||||||
}
|
}
|
||||||
if len(chatIDs) > MaxMuteStatusBatchSize {
|
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)",
|
"batch_get_mute_status accepts at most %d chat_ids per call (got %d)",
|
||||||
MaxMuteStatusBatchSize, len(chatIDs))
|
MaxMuteStatusBatchSize, len(chatIDs))
|
||||||
}
|
}
|
||||||
body := BuildBatchGetMuteStatusBody(chatIDs)
|
body := BuildBatchGetMuteStatusBody(chatIDs)
|
||||||
resp, err := runtime.CallAPI("POST", BatchGetMuteStatusPath, nil, body)
|
resp, err := runtime.CallAPITyped("POST", BatchGetMuteStatusPath, nil, body)
|
||||||
if err != nil {
|
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)
|
muted, unknown := ParseBatchGetMuteStatusResponse(chatIDs, resp)
|
||||||
return muted, unknown, nil
|
return muted, unknown, nil
|
||||||
|
|||||||
@@ -22,5 +22,11 @@ func Shortcuts() []common.Shortcut {
|
|||||||
ImFlagCreate,
|
ImFlagCreate,
|
||||||
ImFlagCancel,
|
ImFlagCancel,
|
||||||
ImFlagList,
|
ImFlagList,
|
||||||
|
ImFeedShortcutCreate,
|
||||||
|
ImFeedShortcutRemove,
|
||||||
|
ImFeedShortcutList,
|
||||||
|
ImFeedGroupList,
|
||||||
|
ImFeedGroupListItem,
|
||||||
|
ImFeedGroupQueryItem,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -54,29 +55,29 @@ var MinutesDownload = common.Shortcut{
|
|||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
return output.ErrValidation("--minute-tokens is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens")
|
||||||
}
|
}
|
||||||
if len(tokens) > maxBatchSize {
|
if len(tokens) > maxBatchSize {
|
||||||
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
|
||||||
}
|
}
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
if !validMinuteToken.MatchString(token) {
|
if !validMinuteToken.MatchString(token) {
|
||||||
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cheap checks first, then path-safety resolution.
|
// Cheap checks first, then path-safety resolution.
|
||||||
out := runtime.Str("output")
|
out := runtime.Str("output")
|
||||||
outDir := runtime.Str("output-dir")
|
outDir := runtime.Str("output-dir")
|
||||||
if out != "" && outDir != "" {
|
if out != "" && outDir != "" {
|
||||||
return output.ErrValidation("--output and --output-dir cannot both be set")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output")
|
||||||
}
|
}
|
||||||
if out != "" {
|
if out != "" {
|
||||||
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
|
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if outDir != "" {
|
if outDir != "" {
|
||||||
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
|
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ var MinutesDownload = common.Shortcut{
|
|||||||
explicitOutputPath = ""
|
explicitOutputPath = ""
|
||||||
case statErr == nil && !fi.IsDir():
|
case statErr == nil && !fi.IsDir():
|
||||||
if !single {
|
if !single {
|
||||||
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output")
|
||||||
}
|
}
|
||||||
case errors.Is(statErr, fs.ErrNotExist):
|
case errors.Is(statErr, fs.ErrNotExist):
|
||||||
if !single {
|
if !single {
|
||||||
@@ -120,7 +121,7 @@ var MinutesDownload = common.Shortcut{
|
|||||||
explicitOutputPath = ""
|
explicitOutputPath = ""
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
|
return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ var MinutesDownload = common.Shortcut{
|
|||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
err error // raw typed error for single-mode passthrough
|
||||||
}
|
}
|
||||||
|
|
||||||
results := make([]result, len(tokens))
|
results := make([]result, len(tokens))
|
||||||
@@ -151,18 +153,18 @@ var MinutesDownload = common.Shortcut{
|
|||||||
// download URLs originate from the trusted Lark API, not user input.
|
// download URLs originate from the trusted Lark API, not user input.
|
||||||
baseClient, err := runtime.Factory.HttpClient()
|
baseClient, err := runtime.Factory.HttpClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrNetwork("failed to get HTTP client: %s", err)
|
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
clonedClient := *baseClient
|
clonedClient := *baseClient
|
||||||
clonedClient.Timeout = disableClientTimeout
|
clonedClient.Timeout = disableClientTimeout
|
||||||
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= maxDownloadRedirects {
|
if len(via) >= maxDownloadRedirects {
|
||||||
return fmt.Errorf("too many redirects")
|
return fmt.Errorf("too many redirects") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
|
||||||
}
|
}
|
||||||
if len(via) > 0 {
|
if len(via) > 0 {
|
||||||
prev := via[len(via)-1]
|
prev := via[len(via)-1]
|
||||||
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
||||||
return fmt.Errorf("redirect from https to http is not allowed")
|
return fmt.Errorf("redirect from https to http is not allowed") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
|
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
|
||||||
@@ -193,7 +195,7 @@ var MinutesDownload = common.Shortcut{
|
|||||||
|
|
||||||
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
|
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +222,7 @@ var MinutesDownload = common.Shortcut{
|
|||||||
|
|
||||||
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
|
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results[i] = result{MinuteToken: token, Error: err.Error()}
|
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results[i] = result{
|
results[i] = result{
|
||||||
@@ -235,7 +237,10 @@ var MinutesDownload = common.Shortcut{
|
|||||||
if single {
|
if single {
|
||||||
r := results[0]
|
r := results[0]
|
||||||
if r.Error != "" {
|
if r.Error != "" {
|
||||||
return output.ErrAPI(0, r.Error, nil)
|
if r.err != nil {
|
||||||
|
return r.err // typed error from fetchDownloadURL/downloadMediaFile, exit code preserved
|
||||||
|
}
|
||||||
|
return runtime.OutPartialFailure(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)})
|
||||||
}
|
}
|
||||||
if urlOnly {
|
if urlOnly {
|
||||||
runtime.Out(map[string]interface{}{
|
runtime.Out(map[string]interface{}{
|
||||||
@@ -262,17 +267,19 @@ var MinutesDownload = common.Shortcut{
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||||
|
|
||||||
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
|
outData := map[string]interface{}{"downloads": results}
|
||||||
|
meta := &output.Meta{Count: len(results)}
|
||||||
if successCount == 0 && len(results) > 0 {
|
if successCount == 0 && len(results) > 0 {
|
||||||
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
|
return runtime.OutPartialFailure(outData, meta)
|
||||||
}
|
}
|
||||||
|
runtime.OutFormat(outData, meta, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
|
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
|
||||||
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
|
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
|
||||||
data, err := runtime.DoAPIJSON(http.MethodGet,
|
data, err := runtime.CallAPITyped(http.MethodGet,
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
|
||||||
nil, nil)
|
nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,7 +287,7 @@ func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minut
|
|||||||
}
|
}
|
||||||
downloadURL := common.GetString(data, "download_url")
|
downloadURL := common.GetString(data, "download_url")
|
||||||
if downloadURL == "" {
|
if downloadURL == "" {
|
||||||
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned empty download_url for %s", minuteToken)
|
||||||
}
|
}
|
||||||
return downloadURL, nil
|
return downloadURL, nil
|
||||||
}
|
}
|
||||||
@@ -302,26 +309,26 @@ type downloadOpts struct {
|
|||||||
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
|
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
|
||||||
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
|
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
|
||||||
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
|
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
|
||||||
return nil, output.ErrValidation("blocked download URL: %s", err)
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked download URL: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrNetwork("invalid download URL: %s", err)
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrNetwork("download failed: %s", err)
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
if len(body) > 0 {
|
if len(body) > 0 {
|
||||||
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve output path
|
// resolve output path
|
||||||
@@ -340,7 +347,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
|
|||||||
|
|
||||||
if !opts.overwrite {
|
if !opts.overwrite {
|
||||||
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
|
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
|
||||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +356,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
|
|||||||
ContentLength: resp.ContentLength,
|
ContentLength: resp.ContentLength,
|
||||||
}, resp.Body)
|
}, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
return nil, common.WrapSaveErrorTyped(err)
|
||||||
}
|
}
|
||||||
resolvedPath, err := opts.fio.ResolvePath(outputPath)
|
resolvedPath, err := opts.fio.ResolvePath(outputPath)
|
||||||
if err != nil || resolvedPath == "" {
|
if err != nil || resolvedPath == "" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,9 +16,11 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -694,3 +697,284 @@ func TestDownload_Batch_DryRun(t *testing.T) {
|
|||||||
t.Errorf("dry-run should show tokens, got: %s", out)
|
t.Errorf("dry-run should show tokens, got: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typed-error lock tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestDownload_TypedErr_ValidationInvalidArgument verifies that an invalid
|
||||||
|
// minute token format (passing cobra's required check but failing our regex)
|
||||||
|
// returns a *errs.ValidationError with SubtypeInvalidArgument and the expected
|
||||||
|
// Param. This locks site :64 (invalid minute token %q).
|
||||||
|
func TestDownload_TypedErr_ValidationInvalidArgument(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
err := mountAndRun(t, MinutesDownload, []string{
|
||||||
|
"+download", "--minute-tokens", "INVALID***TOKEN", "--as", "user",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
if ve.Param != "--minute-tokens" {
|
||||||
|
t.Errorf("Param = %q, want %q", ve.Param, "--minute-tokens")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownload_TypedErr_NetworkTransport_HttpError verifies that a non-2xx
|
||||||
|
// download response from downloadMediaFile returns a *errs.NetworkError with
|
||||||
|
// SubtypeNetworkTransport.
|
||||||
|
//
|
||||||
|
// In the end-to-end single-token Execute path the typed error is now passed
|
||||||
|
// through directly via r.err (single-mode passthrough). We call downloadMediaFile
|
||||||
|
// directly via a probe shortcut to assert the typed shape at the source.
|
||||||
|
func TestDownload_TypedErr_NetworkTransport_HttpError(t *testing.T) {
|
||||||
|
chdir(t, t.TempDir())
|
||||||
|
|
||||||
|
var capturedErr error
|
||||||
|
probe := common.Shortcut{
|
||||||
|
Service: "minutes",
|
||||||
|
Command: "+probe-dl",
|
||||||
|
AuthTypes: []string{"bot"},
|
||||||
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
client, err := rctx.Factory.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, capturedErr = downloadMediaFile(ctx, client,
|
||||||
|
"https://example.com/presigned/download", "tok001",
|
||||||
|
downloadOpts{fio: rctx.FileIO(), outputPath: "out.mp4"})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
URL: "example.com/presigned/download",
|
||||||
|
Status: 503,
|
||||||
|
RawBody: []byte("Service Unavailable"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := mountAndRun(t, probe, []string{"+probe-dl", "--as", "bot"}, f, nil); err != nil {
|
||||||
|
t.Fatalf("probe shortcut should not error: %v", err)
|
||||||
|
}
|
||||||
|
if capturedErr == nil {
|
||||||
|
t.Fatal("expected downloadMediaFile to return an error for HTTP 503, got nil")
|
||||||
|
}
|
||||||
|
var ne *errs.NetworkError
|
||||||
|
if !errors.As(capturedErr, &ne) {
|
||||||
|
t.Fatalf("expected *errs.NetworkError, got %T: %v", capturedErr, capturedErr)
|
||||||
|
}
|
||||||
|
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||||
|
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ne.Error(), "503") {
|
||||||
|
t.Errorf("error message should contain status code 503, got: %v", ne)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownload_TypedErr_InternalInvalidResponse verifies that fetchDownloadURL
|
||||||
|
// returns *errs.InternalError with SubtypeInvalidResponse when the API
|
||||||
|
// response contains an empty download_url field.
|
||||||
|
//
|
||||||
|
// In the end-to-end single-token Execute path the typed error is now passed
|
||||||
|
// through directly via r.err (single-mode passthrough). The typed assertion
|
||||||
|
// is also made at the fetchDownloadURL call site directly via a probe shortcut.
|
||||||
|
func TestDownload_TypedErr_InternalInvalidResponse(t *testing.T) {
|
||||||
|
var capturedErr error
|
||||||
|
probe := common.Shortcut{
|
||||||
|
Service: "minutes",
|
||||||
|
Command: "+probe-download-url",
|
||||||
|
AuthTypes: []string{"bot"},
|
||||||
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
_, capturedErr = fetchDownloadURL(ctx, rctx, "tok001")
|
||||||
|
// Always return nil so mountAndRun doesn't swallow the error type.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/tok001/media",
|
||||||
|
Status: 200,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{"download_url": ""},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := mountAndRun(t, probe, []string{"+probe-download-url", "--as", "bot"}, f, nil); err != nil {
|
||||||
|
t.Fatalf("probe shortcut should not error: %v", err)
|
||||||
|
}
|
||||||
|
if capturedErr == nil {
|
||||||
|
t.Fatal("expected fetchDownloadURL to return an error for empty download_url, got nil")
|
||||||
|
}
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(capturedErr, &ie) {
|
||||||
|
t.Fatalf("expected *errs.InternalError, got %T: %v", capturedErr, capturedErr)
|
||||||
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ie.Error(), "download_url") {
|
||||||
|
t.Errorf("error message should mention download_url, got: %v", ie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownload_TypedErr_OverwriteProtection verifies that the overwrite guard
|
||||||
|
// in downloadMediaFile returns *errs.ValidationError with SubtypeFailedPrecondition.
|
||||||
|
//
|
||||||
|
// In the end-to-end single-token Execute path this typed error is now passed
|
||||||
|
// through directly via r.err (single-mode passthrough), so the typed shape is
|
||||||
|
// also asserted end-to-end via the probe shortcut.
|
||||||
|
func TestDownload_TypedErr_OverwriteProtection(t *testing.T) {
|
||||||
|
chdir(t, t.TempDir())
|
||||||
|
if err := os.WriteFile("existing.mp4", []byte("old"), 0644); err != nil {
|
||||||
|
t.Fatalf("setup failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var capturedErr error
|
||||||
|
probe := common.Shortcut{
|
||||||
|
Service: "minutes",
|
||||||
|
Command: "+probe-overwrite",
|
||||||
|
AuthTypes: []string{"bot"},
|
||||||
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
client, err := rctx.Factory.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, capturedErr = downloadMediaFile(ctx, client,
|
||||||
|
"https://example.com/presigned/download", "tok001",
|
||||||
|
downloadOpts{fio: rctx.FileIO(), outputPath: "existing.mp4", overwrite: false})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(downloadStub("example.com/presigned/download", []byte("new-content"), "video/mp4"))
|
||||||
|
|
||||||
|
if err := mountAndRun(t, probe, []string{"+probe-overwrite", "--as", "bot"}, f, nil); err != nil {
|
||||||
|
t.Fatalf("probe shortcut should not error: %v", err)
|
||||||
|
}
|
||||||
|
if capturedErr == nil {
|
||||||
|
t.Fatal("expected downloadMediaFile to return an error for existing file without overwrite, got nil")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(capturedErr, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", capturedErr, capturedErr)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ve.Error(), "exists") {
|
||||||
|
t.Errorf("error message should mention exists, got: %v", ve)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownload_TypedErr_SingleMode_PassthroughTyped verifies that in single-token
|
||||||
|
// mode a typed error from fetchDownloadURL or downloadMediaFile is returned
|
||||||
|
// directly to the caller with its Problem shape intact (exit code preserved).
|
||||||
|
func TestDownload_TypedErr_SingleMode_PassthroughTyped(t *testing.T) {
|
||||||
|
chdir(t, t.TempDir())
|
||||||
|
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
// API returns non-zero code → CallAPITyped yields a typed APIError.
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/tok001/media",
|
||||||
|
Status: 200,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991, "msg": "permission denied",
|
||||||
|
"data": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, MinutesDownload, []string{
|
||||||
|
"+download", "--minute-tokens", "tok001", "--output", "out.mp4", "--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for API failure, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The error must carry a Problem (typed envelope).
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error (ProblemOf ok), got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("ProblemOf returned nil Problem")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit code must be non-zero and come from the typed error, not a generic 1.
|
||||||
|
code := output.ExitCodeOf(err)
|
||||||
|
if code == 0 {
|
||||||
|
t.Errorf("ExitCodeOf typed error = 0, want non-zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownload_TypedErr_Batch_AllFail_OutPartialFailure verifies that when every
|
||||||
|
// token in a batch fails, Execute emits an ok:false stdout envelope (carrying the
|
||||||
|
// full downloads array) and returns *output.PartialFailureError with Code==ExitAPI.
|
||||||
|
// This locks the double-emit fix: the old code called OutFormat then returned ErrAPI;
|
||||||
|
// the new code calls OutPartialFailure once.
|
||||||
|
func TestDownload_TypedErr_Batch_AllFail_OutPartialFailure(t *testing.T) {
|
||||||
|
chdir(t, t.TempDir())
|
||||||
|
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
// Both tokens fail at the API level.
|
||||||
|
for _, tok := range []string{"tok001", "tok002"} {
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/" + tok + "/media",
|
||||||
|
Status: 200,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 99991, "msg": "permission denied",
|
||||||
|
"data": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mountAndRun(t, MinutesDownload, []string{
|
||||||
|
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
|
||||||
|
// Must return *output.PartialFailureError with ExitAPI.
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if pfErr.Code != output.ExitAPI {
|
||||||
|
t.Errorf("PartialFailureError.Code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdout must carry ok:false with the downloads array (both failed entries).
|
||||||
|
var env struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Data struct {
|
||||||
|
Downloads []struct {
|
||||||
|
MinuteToken string `json:"minute_token"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
} `json:"downloads"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if jsonErr := json.Unmarshal(stdout.Bytes(), &env); jsonErr != nil {
|
||||||
|
t.Fatalf("failed to parse stdout: %v\nraw: %s", jsonErr, stdout.String())
|
||||||
|
}
|
||||||
|
if env.OK {
|
||||||
|
t.Errorf("ok must be false on all-fail batch, got ok:true\nstdout: %s", stdout.String())
|
||||||
|
}
|
||||||
|
if len(env.Data.Downloads) != 2 {
|
||||||
|
t.Fatalf("expected 2 download entries, got %d\nstdout: %s", len(env.Data.Downloads), stdout.String())
|
||||||
|
}
|
||||||
|
for _, d := range env.Data.Downloads {
|
||||||
|
if d.Error == "" {
|
||||||
|
t.Errorf("token %s: expected non-empty error field in all-fail batch", d.MinuteToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -35,28 +36,28 @@ func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
|
|||||||
if start != "" {
|
if start != "" {
|
||||||
parsed, err := toRFC3339(start)
|
parsed, err := toRFC3339(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", output.ErrValidation("--start: %v", err)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
|
||||||
}
|
}
|
||||||
startTime = parsed
|
startTime = parsed
|
||||||
}
|
}
|
||||||
if end != "" {
|
if end != "" {
|
||||||
parsed, err := toRFC3339(end, "end")
|
parsed, err := toRFC3339(end, "end")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", output.ErrValidation("--end: %v", err)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
endTime = parsed
|
endTime = parsed
|
||||||
}
|
}
|
||||||
if startTime != "" && endTime != "" {
|
if startTime != "" && endTime != "" {
|
||||||
st, err := time.Parse(time.RFC3339, startTime)
|
st, err := time.Parse(time.RFC3339, startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("parse normalized --start: %w", err)
|
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "parse normalized --start: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
et, err := time.Parse(time.RFC3339, endTime)
|
et, err := time.Parse(time.RFC3339, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("parse normalized --end: %w", err)
|
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "parse normalized --end: %v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
if st.After(et) {
|
if st.After(et) {
|
||||||
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
|
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start (%s) is after --end (%s)", start, end).WithParam("--start")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return startTime, endTime, nil
|
return startTime, endTime, nil
|
||||||
@@ -70,7 +71,7 @@ func toRFC3339(input string, hint ...string) (string, error) {
|
|||||||
}
|
}
|
||||||
sec, err := strconv.ParseInt(ts, 10, 64)
|
sec, err := strconv.ParseInt(ts, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
|
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err) //nolint:forbidigo // intermediate parse error; callers wrap it into a typed ValidationError
|
||||||
}
|
}
|
||||||
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
return time.Unix(sec, 0).Format(time.RFC3339), nil
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ func buildTimeFilter(startTime, endTime string) map[string]interface{} {
|
|||||||
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
|
||||||
filter := map[string]interface{}{}
|
filter := map[string]interface{}{}
|
||||||
|
|
||||||
ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -102,7 +103,7 @@ func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime
|
|||||||
filter["owner_ids"] = ownerIDs
|
filter["owner_ids"] = ownerIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -231,26 +232,26 @@ var MinutesSearch = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
|
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
|
||||||
return output.ErrValidation("--query: length must be between 1 and 50 characters")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query: length must be between 1 and 50 characters").WithParam("--query")
|
||||||
}
|
}
|
||||||
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
|
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, id := range ownerIDs {
|
for _, id := range ownerIDs {
|
||||||
if _, err := common.ValidateUserID(id); err != nil {
|
if _, err := common.ValidateUserIDTyped("--owner-ids", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, id := range participantIDs {
|
for _, id := range participantIDs {
|
||||||
if _, err := common.ValidateUserID(id); err != nil {
|
if _, err := common.ValidateUserIDTyped("--participant-ids", id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +260,7 @@ var MinutesSearch = common.Shortcut{
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
startTime, endTime, err := parseTimeRange(runtime)
|
startTime, endTime, err := parseTimeRange(runtime)
|
||||||
@@ -288,7 +289,7 @@ var MinutesSearch = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
|
data, err := runtime.CallAPITyped(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ package minutes
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -63,10 +65,11 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
|
|||||||
start string
|
start string
|
||||||
end string
|
end string
|
||||||
wantMessage string
|
wantMessage string
|
||||||
|
wantParam string
|
||||||
}{
|
}{
|
||||||
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
|
{name: "invalid start", start: "bad-start", wantMessage: "--start:", wantParam: "--start"},
|
||||||
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
|
{name: "invalid end", end: "bad-end", wantMessage: "--end:", wantParam: "--end"},
|
||||||
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
|
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end", wantParam: "--start"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -88,6 +91,16 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != tt.wantParam {
|
||||||
|
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +222,16 @@ func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "resolvable open_id") {
|
if !strings.Contains(err.Error(), "resolvable open_id") {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--"+tt.flag {
|
||||||
|
t.Fatalf("Param = %q, want --%s", ve.Param, tt.flag)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +290,13 @@ func TestMinutesSearchValidationNoFilter(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "specify at least one") {
|
if !strings.Contains(err.Error(), "specify at least one") {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
|
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
|
||||||
@@ -279,6 +309,16 @@ func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected invalid user ID error")
|
t.Fatal("expected invalid user ID error")
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--participant-ids" {
|
||||||
|
t.Fatalf("Param = %q, want --participant-ids", ve.Param)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
|
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
|
||||||
@@ -291,6 +331,16 @@ func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected invalid owner ID error")
|
t.Fatal("expected invalid owner ID error")
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--owner-ids" {
|
||||||
|
t.Fatalf("Param = %q, want --owner-ids", ve.Param)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
|
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
|
||||||
@@ -306,6 +356,16 @@ func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
|
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--query" {
|
||||||
|
t.Fatalf("Param = %q, want --query", ve.Param)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
|
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
|
||||||
@@ -335,6 +395,16 @@ func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), "--page-size") {
|
if !strings.Contains(err.Error(), "--page-size") {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--page-size" {
|
||||||
|
t.Fatalf("Param = %q, want --page-size", ve.Param)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
|
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
|
||||||
@@ -372,6 +442,13 @@ func TestMinutesSearchValidationTimeErrors(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||||
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
|
||||||
}
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -37,27 +36,27 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
if minuteToken == "" {
|
if minuteToken == "" {
|
||||||
return output.ErrValidation("--minute-token is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
|
||||||
}
|
}
|
||||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
|
||||||
}
|
}
|
||||||
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||||
if fromUserID == "" {
|
if fromUserID == "" {
|
||||||
return output.ErrValidation("--from-user-id is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id")
|
||||||
}
|
}
|
||||||
if _, err := common.ValidateUserID(fromUserID); err != nil {
|
if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil {
|
||||||
return output.ErrValidation("--from-user-id: %s", err)
|
return err
|
||||||
}
|
}
|
||||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||||
if toUserID == "" {
|
if toUserID == "" {
|
||||||
return output.ErrValidation("--to-user-id is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--to-user-id is required").WithParam("--to-user-id")
|
||||||
}
|
}
|
||||||
if _, err := common.ValidateUserID(toUserID); err != nil {
|
if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil {
|
||||||
return output.ErrValidation("--to-user-id: %s", err)
|
return err
|
||||||
}
|
}
|
||||||
if fromUserID == toUserID {
|
if fromUserID == toUserID {
|
||||||
return output.ErrValidation("--from-user-id and --to-user-id must be different")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -84,7 +83,7 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := runtime.CallAPI(http.MethodPut,
|
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||||
nil, body)
|
nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,37 +102,18 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
|
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
switch p.Code {
|
||||||
switch exitErr.Detail.Code {
|
|
||||||
case minutesSpeakerReplaceNoEditPermission:
|
case minutesSpeakerReplaceNoEditPermission:
|
||||||
return &output.ExitError{
|
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken)
|
||||||
Code: output.ExitAPI,
|
p.Hint = "Ask the minute owner for minute edit permission"
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: "no_edit_permission",
|
|
||||||
Code: minutesSpeakerReplaceNoEditPermission,
|
|
||||||
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken),
|
|
||||||
Hint: "Ask the minute owner for minute edit permission",
|
|
||||||
Detail: exitErr.Detail.Detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
case minutesSpeakerReplaceSpeakerNotFoundCode:
|
case minutesSpeakerReplaceSpeakerNotFoundCode:
|
||||||
return &output.ExitError{
|
p.Subtype = errs.SubtypeNotFound
|
||||||
Code: output.ExitAPI,
|
p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID)
|
||||||
Detail: &output.ErrDetail{
|
p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry."
|
||||||
Type: "speaker_not_found",
|
|
||||||
Code: minutesSpeakerReplaceSpeakerNotFoundCode,
|
|
||||||
Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID),
|
|
||||||
Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.",
|
|
||||||
Detail: exitErr.Detail.Detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,12 +44,12 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "invalid from prefix",
|
name: "invalid from prefix",
|
||||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
|
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
|
||||||
wantErr: "--from-user-id",
|
wantErr: "invalid user ID format",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid to prefix",
|
name: "invalid to prefix",
|
||||||
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
|
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
|
||||||
wantErr: "--to-user-id",
|
wantErr: "invalid user ID format",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "from equals to",
|
name: "from equals to",
|
||||||
@@ -76,6 +76,52 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinutesSpeakerReplace_ValidateTyped(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantParam string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid from prefix",
|
||||||
|
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
|
||||||
|
wantParam: "--from-user-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "from equals to",
|
||||||
|
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"},
|
||||||
|
wantParam: "--to-user-id",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesSpeakerReplace.Mount(parent, f)
|
||||||
|
parent.SetArgs(tt.args)
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
err := parent.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype=%q", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != tt.wantParam {
|
||||||
|
t.Errorf("param=%q, want %q", ve.Param, tt.wantParam)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
@@ -179,24 +225,21 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
|
|||||||
t.Fatal("expected speaker-not-found error, got nil")
|
t.Fatal("expected speaker-not-found error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) {
|
if !ok {
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
t.Fatalf("want typed errs.*, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil {
|
if p.Subtype != errs.SubtypeNotFound {
|
||||||
t.Fatalf("expected structured error detail, got nil")
|
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeNotFound)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "speaker_not_found" {
|
if !strings.Contains(p.Message, "Speaker not found") {
|
||||||
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
|
t.Errorf("message should be friendly, got: %s", p.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, "Speaker not found") {
|
if !strings.Contains(p.Message, "ou_missing_speaker") {
|
||||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
t.Errorf("message should include missing speaker id, got: %s", p.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") {
|
if !strings.Contains(p.Hint, "--from-user-id") {
|
||||||
t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message)
|
t.Errorf("hint should mention --from-user-id, got: %s", p.Hint)
|
||||||
}
|
|
||||||
if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") {
|
|
||||||
t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,23 +268,20 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
|
|||||||
t.Fatal("expected no-edit-permission error, got nil")
|
t.Fatal("expected no-edit-permission error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) {
|
if !ok {
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
t.Fatalf("want typed errs.*, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil {
|
if p.Subtype != errs.SubtypePermissionDenied {
|
||||||
t.Fatalf("expected structured error detail, got nil")
|
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "no_edit_permission" {
|
if !strings.Contains(p.Message, "No edit permission") {
|
||||||
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
|
t.Errorf("message should be friendly, got: %s", p.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
|
if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) {
|
||||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) {
|
if !strings.Contains(p.Hint, "edit permission") {
|
||||||
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
|
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||||
}
|
|
||||||
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
|
|
||||||
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -33,13 +32,13 @@ var MinutesUpdate = common.Shortcut{
|
|||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
if minuteToken == "" {
|
if minuteToken == "" {
|
||||||
return output.ErrValidation("--minute-token is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
|
||||||
}
|
}
|
||||||
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(runtime.Str("topic")) == "" {
|
if strings.TrimSpace(runtime.Str("topic")) == "" {
|
||||||
return output.ErrValidation("--topic is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--topic is required").WithParam("--topic")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -57,7 +56,7 @@ var MinutesUpdate = common.Shortcut{
|
|||||||
"topic": topic,
|
"topic": topic,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := runtime.CallAPI(http.MethodPatch,
|
_, err := runtime.CallAPITyped(http.MethodPatch,
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
|
||||||
nil, body)
|
nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,20 +74,11 @@ var MinutesUpdate = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func minutesUpdateError(err error, minuteToken string) error {
|
func minutesUpdateError(err error, minuteToken string) error {
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode {
|
if !ok || p.Code != minutesUpdateNoEditPermissionCode {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken)
|
||||||
return &output.ExitError{
|
p.Hint = "Ask the minute owner for minute edit permission"
|
||||||
Code: output.ExitAPI,
|
return err
|
||||||
Detail: &output.ErrDetail{
|
|
||||||
Type: "no_edit_permission",
|
|
||||||
Code: minutesUpdateNoEditPermissionCode,
|
|
||||||
Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken),
|
|
||||||
Hint: "Ask the minute owner for minute edit permission",
|
|
||||||
Detail: exitErr.Detail.Detail,
|
|
||||||
},
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,6 +55,32 @@ func TestMinutesUpdate_Validate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinutesUpdate_ValidateTyped(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
// ".." triggers ResourceName rejection — hits our Validate, not cobra's required-flag check.
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesUpdate.Mount(parent, f)
|
||||||
|
parent.SetArgs([]string{"+update", "--minute-token", "..", "--topic", "title", "--as", "user"})
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
err := parent.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype=%q", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--minute-token" {
|
||||||
|
t.Errorf("param=%q", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMinutesUpdate_DryRun(t *testing.T) {
|
func TestMinutesUpdate_DryRun(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
@@ -132,23 +158,20 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) {
|
|||||||
t.Fatal("expected no-edit-permission error, got nil")
|
t.Fatal("expected no-edit-permission error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
p, ok := errs.ProblemOf(err)
|
||||||
if !errors.As(err, &exitErr) {
|
if !ok {
|
||||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
t.Fatalf("want typed errs.*, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil {
|
if p.Subtype != errs.SubtypePermissionDenied {
|
||||||
t.Fatalf("expected structured error detail, got nil")
|
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Type != "no_edit_permission" {
|
if !strings.Contains(p.Message, "No edit permission") {
|
||||||
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
|
t.Errorf("message should be friendly, got: %s", p.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
|
if !strings.Contains(p.Message, minutesUpdateTestToken) {
|
||||||
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
|
t.Errorf("message should include minute token, got: %s", p.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) {
|
if !strings.Contains(p.Hint, "edit permission") {
|
||||||
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
|
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
|
||||||
}
|
|
||||||
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
|
|
||||||
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ package minutes
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
@@ -36,10 +36,10 @@ var MinutesUpload = common.Shortcut{
|
|||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
fileToken := runtime.Str("file-token")
|
fileToken := runtime.Str("file-token")
|
||||||
if fileToken == "" {
|
if fileToken == "" {
|
||||||
return output.ErrValidation("--file-token is required")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required").WithParam("--file-token")
|
||||||
}
|
}
|
||||||
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ var MinutesUpload = common.Shortcut{
|
|||||||
"file_token": fileToken,
|
"file_token": fileToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
data, err := runtime.CallAPITyped("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -46,6 +48,31 @@ func TestMinutesUpload_Validate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMinutesUpload_ValidateTyped(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
// ".." triggers ResourceName rejection — hits our Validate, not cobra's required-flag check.
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesUpload.Mount(parent, f)
|
||||||
|
parent.SetArgs([]string{"+upload", "--file-token", "..", "--as", "user"})
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
err := parent.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("subtype=%q", ve.Subtype)
|
||||||
|
}
|
||||||
|
if ve.Param != "--file-token" {
|
||||||
|
t.Errorf("param=%q", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMinutesUpload_HelpMetadata(t *testing.T) {
|
func TestMinutesUpload_HelpMetadata(t *testing.T) {
|
||||||
if len(MinutesUpload.Flags) == 0 {
|
if len(MinutesUpload.Flags) == 0 {
|
||||||
t.Fatal("expected file-token flag metadata")
|
t.Fatal("expected file-token flag metadata")
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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.
|
// 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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
cycleID := runtime.Str("cycle-id")
|
cycleID := runtime.Str("cycle-id")
|
||||||
if cycleID == "" {
|
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 {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
@@ -52,8 +52,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
cycleID := runtime.Str("cycle-id")
|
cycleID := runtime.Str("cycle-id")
|
||||||
|
|
||||||
// Paginate objectives under the cycle.
|
// Paginate objectives under the cycle.
|
||||||
queryParams := make(larkcore.QueryParams)
|
queryParams := map[string]interface{}{"page_size": "100"}
|
||||||
queryParams.Set("page_size", "100")
|
|
||||||
|
|
||||||
var objectives []Objective
|
var objectives []Objective
|
||||||
page := 0
|
page := 0
|
||||||
@@ -71,7 +70,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
page++
|
page++
|
||||||
|
|
||||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,7 +92,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
if !hasMore || pageToken == "" {
|
if !hasMore || pageToken == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
queryParams.Set("page_token", pageToken)
|
queryParams["page_token"] = pageToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// For each objective, paginate key results and convert to response format.
|
// For each objective, paginate key results and convert to response format.
|
||||||
@@ -104,8 +103,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
}
|
}
|
||||||
obj := &objectives[i]
|
obj := &objectives[i]
|
||||||
|
|
||||||
krQuery := make(larkcore.QueryParams)
|
krQuery := map[string]interface{}{"page_size": "100"}
|
||||||
krQuery.Set("page_size", "100")
|
|
||||||
|
|
||||||
var keyResults []KeyResult
|
var keyResults []KeyResult
|
||||||
krPage := 0
|
krPage := 0
|
||||||
@@ -123,7 +121,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
krPage++
|
krPage++
|
||||||
|
|
||||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -145,7 +143,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
if !hasMore || pageToken == "" {
|
if !hasMore || pageToken == "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
krQuery.Set("page_token", pageToken)
|
krQuery["page_token"] = pageToken
|
||||||
}
|
}
|
||||||
|
|
||||||
respObj := obj.ToResp()
|
respObj := obj.ToResp()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user