Compare commits

..

11 Commits

Author SHA1 Message Date
shanglei
427ead1aa8 fix(im): attribute alias errors to the typed flag and add live pagination e2e
Three review findings on the alias and pagination work.

Alias-supplied values reported failures under the canonical flag name:
--start-time with an unparseable timestamp came back as error.param
"--start", --thread-id as "--thread", --message-id as "--message-ids".
Agents parse error.param to decide their next action (ERROR_CONTRACT.md),
so the error must name the flag the caller actually typed. Track the
source flag through alias resolution and use it in both the message and
the param; --limit already behaved this way.

Declared enums on hidden alias flags were framework-validated before the
canonical-wins resolution ran, so --order asc --sort-order unexpected
failed on a value the command was going to ignore. Hidden aliases no
longer declare enums; validateAliasEnum enforces the value set from
Validate only when the alias is actually in effect, attributing the
rejection to the alias name. Contract tests now pin that aliases must
not declare enums, with regressions at both unit and runner level.

Live pagination coverage was missing: the four commands gained real
multi-page fetching but only mock unit tests and dry-run e2e existed,
while AGENTS.md requires self-contained live E2E for behavior changes.
TestIM_PageAllLiveWorkflow creates its own chats, messages and thread
replies, walks them with --page-size 1 --page-all, and asserts the
merged result plus the truncation contract (has_more, resume page_token,
stderr incomplete notice) for +chat-messages-list,
+threads-messages-list and +chat-list. +chat-search is covered by unit
and dry-run tests only: freshly created chats are not immediately
searchable, which would make a live assertion flaky.
2026-08-01 16:15:57 +08:00
shanglei
15895b74e1 Merge remote-tracking branch 'origin/main' into feat/agent-affordance-fixes 2026-08-01 15:12:28 +08:00
shanglei
02ed6f02a3 fix(im): validate every member-types value and name flags precisely in docs
Review follow-ups on the member-types and chat-search changes.

normalizeMemberTypes accepted any occurrence of "all" before validating
the remaining values, so an invalid value alongside it (--member-types
admin,all) was silently swallowed into "no filter". Validate every value
first; "all" only widens the filter after the whole list is known to be
well-formed, and the rejection message now names all three accepted
spellings.

The skill index and command descriptions for +chat-messages-list and
+threads-messages-list advertised "sort" while the actual flag is
--order; name the flag exactly so callers do not learn a spelling the
command rejects.

Test tightening from the same review: the canonical-precedence e2e now
passes an alias value that would fail validation (--types p2p) alongside
--chat-modes, proving an explicit canonical flag bypasses alias
validation entirely; rejection-path e2e tests assert the structured
validation metadata (error.type, error.subtype, param names) instead of
message text alone.
2026-08-01 15:10:56 +08:00
shanglei
7a2f6443cc fix(im): accept member type variants and improve resource hints 2026-08-01 14:17:51 +08:00
shanglei
3561753a7d feat(im): handle chat-search types by value 2026-08-01 13:26:23 +08:00
shanglei
09b38a7292 feat(im): accept the flag names callers actually type
Six flags in the im domain are routinely typed under a different name —
--start-time for --start, --thread-id for --thread, --message-id for
--message-ids, --keyword for --query, --sort-order for --order and
--limit for --page-size. The value written alongside them is already
valid in every case; only the name is wrong, so the call fails once and
has to be retried under the canonical name.

Register the eight names as hidden aliases, following the existing
pattern in this package: the canonical flag wins when both are given,
--help and schema keep listing only the canonical name, and a note
naming the canonical flag is written to stderr so callers learn it
instead of settling on the alias. The four aliases that already existed
now emit that note too. Out-of-range values report the flag the caller
actually typed, so --limit 500 is rejected as --limit rather than as
--page-size.

--thread and --message-ids drop their Required declaration and validate
in Validate instead, otherwise cobra rejects the call before an alias
can be resolved.

ParseTime gains "2006-01-02 15:04:05 Z07:00". A space-separated
timestamp with an offset is the most common thing written after
--start-time, and without this format the alias would only turn an
unknown-flag error into a parse error. The format is additive: inputs
that parsed before are unaffected.
2026-08-01 12:24:11 +08:00
liangshuo-1
a8ad44ba13 docs: remove broken Star History chart (#2141) 2026-08-01 11:42:24 +08:00
shanglei
eb0bd8a9ab feat(im): add --page-all to list commands and align page-size limits
Four of the most-used im list commands lacked --page-all while sibling
commands in the same domain had it, so callers that learned the flag on
+messages-search kept passing it to +threads-messages-list and friends
and got "unknown flag". Separately, +threads-messages-list declared a
page-size ceiling of 500 while the server accepts 50, so oversized
values were forwarded and came back as an opaque "field validation
failed" with no indication of which field was wrong.

Add --page-all/--page-limit to +threads-messages-list,
+chat-messages-list, +chat-list and +chat-search, following the existing
+flag-list implementation: pages are capped, has_more and page_token
come from the last fetched page so callers can resume, reaching the cap
with has_more=true reports an incomplete result on stderr, and a
non-advancing page_token stops the loop. Progress goes to stderr; stdout
carries data only. Raw items from every page are merged first, then
message conversion, sender-name resolution, thread expansion, reaction
enrichment and resource download run once over the merged set.

Page-size ceilings for the nine paginated im commands now come from a
single table with a table-driven test, out-of-range values are rejected
locally with a structured validation error that names the limit, and no
HTTP request is issued when validation fails. +chat-members-list moves
off its hand-written bounds check onto the shared validator.

+feed-group-list-item keeps its current ceiling of 50: the public
specification for its endpoint is unavailable, so the value is left
pending confirmation rather than guessed.
2026-07-31 19:12:20 +08:00
liangshuo-1
003d0f42f8 chore: release v1.0.81 (#2136) 2026-07-31 18:47:19 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
83 changed files with 3979 additions and 303 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.81] - 2026-07-31
### Features
- support visible_rule for form questions (#1891)
- **contact**: add bot search shortcut (#2083)
- add SXSD schema validation to Slides lint (#2103)
- **drive**: add comment-operation shortcuts (#1898)
- **drive**: extend permission shortcuts for Miaoda (#2070)
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
- support source file preview artifacts (#2085)
### Bug Fixes
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
- **base**: resolve Base URL block types accurately (#2099)
- **drive**: use title for default download filename (#2089)
- drop stale target version from root upgrade prompt (#2100)
### Documentation
- **calendar**: warn against container-default timezone in time conversion (#2104)
- **calendar**: confirm scope before editing recurring events (#2119)
- **base**: clarify form and file operation routing (#2110)
### Misc
- add protected public domain allowlists (#2111)
## [v1.0.80] - 2026-07-29
### Features
@@ -1722,6 +1751,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78

View File

@@ -310,10 +310,6 @@ lark-cli config risk-control default
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).

View File

@@ -311,10 +311,6 @@ lark-cli config risk-control default
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## 贡献
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.80",
"version": "1.0.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.80",
"version": "1.0.81",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.80",
"version": "1.0.81",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
Tips: []string{
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
"Each new question creates a field in the form's table; question IDs are field IDs.",
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
api := common.NewDryRunAPI().
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
Set("form_id", runtime.Str("form-id")).
Body(map[string]interface{}{"questions": questions})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
formId := runtime.Str("form-id")
questionsJSON := runtime.Str("questions")
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
questions, err := parseFormQuestionsCreate(questionsJSON)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST",
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
return nil
},
}
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
var questions []interface{}
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
if questions == nil {
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
}
if len(questions) > 10 {
return nil, baseValidationErrorf("--questions must contain at most 10 items")
}
for i, question := range questions {
item, ok := question.(map[string]interface{})
if !ok {
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
}
title, ok := item["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
}
questionType, ok := item["type"].(string)
if !ok || strings.TrimSpace(questionType) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
}
}
return questions, nil
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
)
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
for _, want := range []string{
"+form-questions-list",
"verified empty form can create directly",
"question IDs are field IDs",
"explicitly requests a separate same-title question",
"+form-questions-update",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}

View File

@@ -61,6 +61,7 @@ func ParseTime(input string, hint ...string) (string, error) {
time.RFC3339,
"2006-01-02T15:04Z07:00",
"2006-01-02T15:04:05Z07:00",
"2006-01-02 15:04:05 Z07:00",
}
for _, f := range tzFormats {
if t, err := time.Parse(f, input); err == nil {

View File

@@ -33,6 +33,16 @@ func TestParseTimeUnix(t *testing.T) {
}
}
func TestParseTimeWithSpaceSeparatedTimezone(t *testing.T) {
got, err := ParseTime("2026-07-27 00:00:00 +08:00")
if err != nil {
t.Fatalf("ParseTime(space-separated timezone) error: %v", err)
}
if got != "1785081600" {
t.Fatalf("ParseTime(space-separated timezone) = %q, want 1785081600", got)
}
}
func TestParseTimeRejectsRelative(t *testing.T) {
for _, input := range []string{"today", "tomorrow", "yesterday", "now", "this_week", "+3d", "-1w", "+2h", "-30m", "last_7_days"} {
t.Run(input, func(t *testing.T) {

View File

@@ -202,7 +202,7 @@ var DriveDownload = common.Shortcut{
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return wrapDriveNetworkErr(err, "download failed: %s", err)
return withDriveDownloadForbiddenPreviewHint(wrapDriveNetworkErr(err, "download failed: %s", err), fileToken)
}
defer resp.Body.Close()

View File

@@ -5,6 +5,8 @@ package drive
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
@@ -21,6 +23,30 @@ func wrapDriveNetworkErr(err error, format string, args ...any) error {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// withDriveDownloadForbiddenPreviewHint keeps the HTTP 403 network error from
// +download intact while giving callers a preview-based path to view content.
func withDriveDownloadForbiddenPreviewHint(err error, _ string) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Code != http.StatusForbidden {
return err
}
if strings.Contains(problem.Hint, "drive +preview") {
return err
}
hint := driveDownloadForbiddenPreviewHint()
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = hint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + hint
return err
}
func driveDownloadForbiddenPreviewHint() string {
const tokenArg = "<FILE_TOKEN>"
return fmt.Sprintf("Direct Drive download returned HTTP 403. To view file content through preview artifacts, try `lark-cli drive +preview --file-token %s --type source_file --output <path>`; for PDF/text/image preview choices, run `lark-cli drive +preview --file-token %s --list-only`.", tokenArg, tokenArg)
}
// driveInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."

View File

@@ -1580,6 +1580,84 @@ func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) {
}
}
func TestDriveDownloadHTTP403SuggestsPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_403/download",
Status: http.StatusForbidden,
RawBody: []byte("permission denied"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_403",
"--output", "blocked.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 403 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryNetwork {
t.Fatalf("category=%q, want network", problem.Category)
}
if problem.Code != http.StatusForbidden {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusForbidden)
}
if !strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want preview guidance", problem.Hint)
}
if strings.Contains(problem.Hint, "file_403") {
t.Fatalf("hint=%q, want placeholder file token", problem.Hint)
}
if !strings.Contains(problem.Hint, "--file-token <FILE_TOKEN>") {
t.Fatalf("hint=%q, want file token placeholder", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output <path>") {
t.Fatalf("hint=%q, want source_file output command", problem.Hint)
}
}
func TestDriveDownloadHTTP404DoesNotSuggestPreview(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/file_missing/download",
Status: http.StatusNotFound,
RawBody: []byte("not found"),
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveDownload, []string{
"+download",
"--file-token", "file_missing",
"--output", "missing.md",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected HTTP 404 error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", problem.Code, http.StatusNotFound)
}
if strings.Contains(problem.Hint, "drive +preview") {
t.Fatalf("hint=%q, want no preview guidance for non-403", problem.Hint)
}
}
func TestDriveDownloadDefaultOutputPathSanitizesSlashOnlyNames(t *testing.T) {
header := http.Header{
"Content-Disposition": []string{`attachment; filename="////"`},

View File

@@ -16,13 +16,13 @@ import (
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "List or download available preview artifacts for a Drive file",
Description: "View or download Drive file content, or list and fetch available preview artifacts",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source_file"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
@@ -40,6 +40,25 @@ var DrivePreview = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
if requestedType == "source_file" {
downloadParams := map[string]interface{}{
"preview_type": drivePreviewTypeSourceFile,
}
if version != "" {
downloadParams["version"] = version
}
return common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download the source file artifact").
Params(downloadParams).
Set("file_token", fileToken).
Set("mode", "download").
Set("requested_type", requestedType).
Set("selected_type", "source_file").
Set("selected_type_code", drivePreviewTypeSourceFile).
Set("output", runtime.Str("output"))
}
body := map[string]interface{}{}
if version != "" {
body["version"] = version
@@ -67,7 +86,7 @@ var DrivePreview = common.Shortcut{
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", runtime.Str("type")).
Set("requested_type", requestedType).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -82,9 +101,25 @@ var DrivePreview = common.Shortcut{
body["version"] = version
}
if requestedType == "source_file" {
fmt.Fprintf(runtime.IO().ErrOut, "Downloading source file artifact: %s\n", common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, drivePreviewTypeSourceFile, version, outputPath, ifExists, drivePreviewFallbackExt("source_file"))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = "source_file"
runtime.Out(result, nil)
return nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
if runtime.Bool("list-only") {
return withDrivePreviewSourceFileHint(err)
}
return err
}
if runtime.Bool("list-only") {

View File

@@ -27,6 +27,8 @@ const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
drivePreviewTypeSourceFile = "16"
drivePreviewSourceFileHint = "Preview candidates are unavailable for this file. To fetch the source file artifact, rerun with --type source_file --output <path>."
)
type drivePreviewCandidate struct {
@@ -88,7 +90,9 @@ var drivePreviewMimeToExt = map[string]string{
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/markdown": ".md",
"text/plain": ".txt",
"text/x-markdown": ".md",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
@@ -464,7 +468,7 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists, fileToken)
if err != nil {
return nil, err
}
@@ -492,8 +496,8 @@ func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists, fallbackName string) (string, *driveExtensionResolution, error) {
finalPath, resolution := resolveDrivePreviewOutputPathName(runtime, outputPath, header, fallbackExt, fallbackName)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
@@ -522,6 +526,32 @@ func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath st
}
}
func resolveDrivePreviewOutputPathName(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
if drivePreviewOutputIsDirectory(runtime, outputPath) {
fileName, resolution := drivePreviewDefaultFileName(header, fallbackExt, fallbackName)
return filepath.Join(outputPath, fileName), resolution
}
return autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
}
func drivePreviewOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") {
return true
}
info, err := runtime.FileIO().Stat(outputPath)
return err == nil && info.IsDir()
}
func drivePreviewDefaultFileName(header http.Header, fallbackExt, fallbackName string) (string, *driveExtensionResolution) {
name := driveDownloadNormalizeFileName(larkcore.FileNameByHeader(header))
if name == "" {
name = driveDownloadNormalizeFileName(fallbackName)
}
name = sanitizeExportFileName(name, "preview")
name, resolution := autoAppendDrivePreviewExtension(name, header, fallbackExt)
return name, resolution
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
@@ -556,6 +586,15 @@ func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fall
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if fallbackExt == "" {
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
return normalizedPath, nil
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
@@ -804,6 +843,36 @@ func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePrevie
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// withDrivePreviewSourceFileHint adds source_file guidance to preview candidate
// API failures without changing their classification or server diagnostics.
func withDrivePreviewSourceFileHint(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI {
return err
}
if problem.Retryable || problem.Subtype == errs.SubtypeRateLimit {
return err
}
if strings.Contains(problem.Hint, "--type source_file") {
return err
}
if !isDrivePreviewCandidatesUnavailableProblem(problem) {
return err
}
if strings.TrimSpace(problem.Hint) == "" {
problem.Hint = drivePreviewSourceFileHint
return err
}
problem.Hint = strings.TrimSpace(problem.Hint) + " " + drivePreviewSourceFileHint
return err
}
func isDrivePreviewCandidatesUnavailableProblem(problem *errs.Problem) bool {
return problem != nil &&
problem.Code == 1 &&
strings.Contains(problem.Message, "mGetFilePreviewCore failed")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {

View File

@@ -147,6 +147,63 @@ func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
}
}
// TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult verifies
// source_file downloads the source file artifact without first fetching preview
// candidates.
func TestDrivePreviewSourceFileDirectDownloadSkipsPreviewResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_source/preview_download?preview_type=16",
Status: 200,
Body: []byte("# markdown\n"),
Headers: http.Header{
"Content-Disposition": []string{`attachment; filename="README.md"`},
"Content-Type": []string{"text/plain; charset=utf-8"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_source",
"--type", "source_file",
"--output", "artifacts/",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if _, ok := data["requested_type"]; ok {
t.Fatalf("requested_type should be omitted from execute output: %#v", data)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted from execute output: %#v", data)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "artifacts", "README.md")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
gotBody, err := os.ReadFile(wantPath)
if err != nil {
t.Fatalf("ReadFile(%q) error: %v", wantPath, err)
}
if string(gotBody) != "# markdown\n" {
t.Fatalf("saved body=%q, want markdown source", string(gotBody))
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
@@ -434,6 +491,72 @@ func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
}
}
// TestDrivePreviewDryRunSourceFileDocumentsDirectDownload verifies source_file
// dry-run documents the direct source artifact download path.
func TestDrivePreviewDryRunSourceFileDocumentsDirectDownload(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source_file",
"version": "7",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "source_file" {
t.Fatalf("requested_type=%v, want source_file", got)
}
if got := data["selected_type"]; got != "source_file" {
t.Fatalf("selected_type=%v, want source_file", got)
}
if got := data["selected_type_code"]; got != drivePreviewTypeSourceFile {
t.Fatalf("selected_type_code=%v, want %s", got, drivePreviewTypeSourceFile)
}
api, _ := data["api"].([]interface{})
if len(api) != 1 {
t.Fatalf("len(api)=%d, want 1", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "GET" {
t.Fatalf("method=%v, want GET", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_download" {
t.Fatalf("url=%v, want preview_download", got)
}
params, _ := call["params"].(map[string]interface{})
if got := params["preview_type"]; got != drivePreviewTypeSourceFile {
t.Fatalf("params.preview_type=%v, want %s", got, drivePreviewTypeSourceFile)
}
if got := params["version"]; got != "7" {
t.Fatalf("params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates verifies only the
// explicit source_file request bypasses preview_result.
func TestDrivePreviewDryRunSourceAliasUsesPreviewCandidates(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_source",
"type": "source",
"output": "source",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_source/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
if _, ok := data["selected_type_code"]; ok {
t.Fatalf("selected_type_code should be omitted for non-source_file dry-run: %#v", data)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
@@ -612,6 +735,135 @@ func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
}
}
// TestDrivePreviewListOnlyErrorAddsSourceFileHint verifies preview_result API
// failures keep server diagnostics while guiding callers to source_file.
func TestDrivePreviewListOnlyErrorAddsSourceFileHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_markdown/preview_result",
Body: map[string]interface{}{
"code": 1,
"msg": "fail:mGetFilePreviewCore failed",
"log_id": "log-preview-result",
"error": map[string]interface{}{
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/preview-result",
"details": []interface{}{
map[string]interface{}{"value": "server preview_result detail"},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_markdown",
"--list-only",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected preview_result error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category=%q, want api", problem.Category)
}
if problem.Code != 1 {
t.Fatalf("code=%d, want 1", problem.Code)
}
if problem.LogID != "log-preview-result" {
t.Fatalf("log_id=%q, want log-preview-result", problem.LogID)
}
if problem.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/preview-result" {
t.Fatalf("troubleshooter=%q, want passthrough", problem.Troubleshooter)
}
if !strings.Contains(problem.Hint, "server preview_result detail") {
t.Fatalf("hint=%q, want server detail preserved", problem.Hint)
}
if !strings.Contains(problem.Hint, "--type source_file") || !strings.Contains(problem.Hint, "--output") {
t.Fatalf("hint=%q, want source_file output guidance", problem.Hint)
}
}
// TestDrivePreviewListOnlyRateLimitKeepsOriginalHint verifies retryable API
// errors are not reframed as source_file recovery.
func TestDrivePreviewListOnlyRateLimitKeepsOriginalHint(t *testing.T) {
err := withDrivePreviewSourceFileHint(errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithCode(99991400).WithRetryable())
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Hint != "" {
t.Fatalf("hint=%q, want empty hint for rate limit", problem.Hint)
}
if !problem.Retryable {
t.Fatal("retryable=false, want true")
}
}
// TestDrivePreviewSourceFileHintGuards verifies source_file recovery guidance
// only rewrites eligible API errors and preserves existing source_file hints.
func TestDrivePreviewSourceFileHintGuards(t *testing.T) {
plainErr := errors.New("plain failure")
if got := withDrivePreviewSourceFileHint(plainErr); got != plainErr {
t.Fatalf("non-API error changed: got %T %v, want original", got, got)
}
for _, tt := range []struct {
name string
err *errs.APIError
want string
}{
{
name: "already has source file hint",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed").WithHint("rerun with --type source_file --output <path>"),
want: "rerun with --type source_file --output <path>",
},
{
name: "candidate core failure empty hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1),
want: drivePreviewSourceFileHint,
},
{
name: "candidate core failure whitespace hint",
err: errs.NewAPIError(errs.SubtypeServerError, "fail:mGetFilePreviewCore failed").WithCode(1).WithHint(" \n\t "),
want: drivePreviewSourceFileHint,
},
{
name: "generic server error",
err: errs.NewAPIError(errs.SubtypeServerError, "preview_result failed"),
want: "",
},
{
name: "not found",
err: errs.NewAPIError(errs.SubtypeNotFound, "file not found").WithCode(1061044),
want: "",
},
{
name: "invalid parameters",
err: errs.NewAPIError(errs.SubtypeInvalidParameters, "invalid file token").WithCode(1063007),
want: "",
},
} {
t.Run(tt.name, func(t *testing.T) {
gotErr := withDrivePreviewSourceFileHint(tt.err)
if gotErr != tt.err {
t.Fatalf("API error pointer changed: got %T, want original", gotErr)
}
problem, ok := errs.ProblemOf(gotErr)
if !ok {
t.Fatalf("expected typed error, got %T: %v", gotErr, gotErr)
}
if problem.Hint != tt.want {
t.Fatalf("hint=%q, want %q", problem.Hint, tt.want)
}
})
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
@@ -721,6 +973,21 @@ func TestDrivePreviewCommonHelpers(t *testing.T) {
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
header.Set("Content-Disposition", `attachment; filename="README.md"`)
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.md" || fallback == nil || fallback.Source != "Content-Disposition" {
t.Fatalf("source_file append = (%q, %+v), want source.md from Content-Disposition", path, fallback)
}
header = http.Header{}
header.Set("Content-Type", "text/plain")
path, fallback = autoAppendDrivePreviewExtension("source", header, "")
if path != "source.txt" || fallback == nil || fallback.Source != "Content-Type" {
t.Fatalf("source_file content-type append = (%q, %+v), want source.txt from Content-Type", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
@@ -751,7 +1018,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename, "file_preview")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
@@ -759,7 +1026,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep", "file_preview")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
@@ -771,6 +1038,20 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
if err := os.Mkdir("artifacts", 0755); err != nil {
t.Fatalf("Mkdir() error: %v", err)
}
sourceHeader := http.Header{}
sourceHeader.Set("Content-Type", "text/plain")
sourceHeader.Set("Content-Disposition", `attachment; filename="README.md"`)
dirOutput, _, err := resolveDrivePreviewOutputPath(runtime, "artifacts", sourceHeader, "", drivePreviewIfExistsError, "file_source")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(directory) error: %v", err)
}
if !strings.HasSuffix(dirOutput, filepath.Join("artifacts", "README.md")) {
t.Fatalf("dirOutput=%q, want artifacts/README.md suffix", dirOutput)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
@@ -779,7 +1060,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite, "file_preview")
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
@@ -791,7 +1072,7 @@ func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError, "file_preview")
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
@@ -876,7 +1157,6 @@ func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)

View File

@@ -63,15 +63,26 @@ func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().Int("page-limit", 10, "")
for _, name := range []string{"query", "search-types", "chat-modes", "types", "member-ids", "sort", "sort-by", "page-token"} {
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
for name := range stringFlags {
if name == "page-size" || name == "page-limit" {
continue
}
if cmd.Flags().Lookup(name) == nil {
cmd.Flags().String(name, "", "")
}
}
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted", "page-all", "dry-run"} {
cmd.Flags().Bool(name, false, "")
}
for name := range boolFlags {
if cmd.Flags().Lookup(name) == nil {
cmd.Flags().Bool(name, false, "")
}
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
@@ -94,9 +105,10 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("page-limit", 20, "")
for name := range stringFlags {
if name == "page-size" || name == "page-limit" {
if name == "page-size" || name == "limit" || name == "page-limit" {
continue
}
cmd.Flags().String(name, "", "")
@@ -330,7 +342,7 @@ func TestShortcutValidateBranches(t *testing.T) {
"page-size": "0",
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") {
if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 100") {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
@@ -700,7 +712,7 @@ func TestShortcutValidateBranches(t *testing.T) {
"page-size": "0",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") {
if err == nil || !strings.Contains(err.Error(), "invalid --page-size 0: must be between 1 and 50") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
@@ -881,7 +893,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-size": "51",
"page-size": "50",
"page-token": "next_page",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime))

View File

@@ -195,7 +195,7 @@ func TestBuildChatMessageListRequest(t *testing.T) {
t.Run("valid request", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"sort": "asc",
"page-size": "80",
"page-size": "50",
"page-token": "next",
"start": "2026-03-01T00:00:00+08:00",
"end": "2026-03-02T23:59:59+08:00",
@@ -245,7 +245,7 @@ func TestBuildChatMessageListRequest(t *testing.T) {
}
func TestChatMessageListOnlyThreadRootMessagesParams(t *testing.T) {
got := buildChatMessageListParams("desc", "20", "oc_123")
got := buildChatMessageListParams("desc", 20, "oc_123")
if vals := got["only_thread_root_messages"]; !reflect.DeepEqual(vals, []string{"true"}) {
t.Fatalf("only_thread_root_messages = %#v, want true", vals)
}
@@ -341,7 +341,7 @@ func TestBuildMessagesSearchRequest(t *testing.T) {
"exclude-sender-type": "bot",
"start": "2026-03-01T00:00:00+08:00",
"end": "2026-03-02T23:59:59+08:00",
"page-size": "80",
"page-size": "50",
"page-token": "next-token",
}, map[string]bool{
"at-all": true,

View File

@@ -82,12 +82,20 @@ func senderDisplay(sender map[string]interface{}) string {
}
func validateMessageID(input string) (string, error) {
return validateMessageIDForParam(input, "--message-id")
}
// validateMessageIDForParam validates a message ID and attributes failures to
// the given flag name — callers that accept the value under a different flag
// (e.g. +messages-mget's --message-ids and its --message-id alias) pass the
// flag the caller actually typed.
func validateMessageIDForParam(input, param string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam("--message-id")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "message ID cannot be empty").WithParam(param)
}
if !strings.HasPrefix(input, "om_") {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam("--message-id")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid message ID %q: must start with om_", input).WithParam(param)
}
return input, nil
}

View File

@@ -676,6 +676,82 @@ func TestShortcuts(t *testing.T) {
}
}
func TestValidateIMResourceDownloadRequiredFlags(t *testing.T) {
t.Run("both missing", func(t *testing.T) {
err := validateIMResourceDownloadRequiredFlags("", "")
if err == nil {
t.Fatal("validateIMResourceDownloadRequiredFlags() error = nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() did not recognize %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v", problem)
}
if problem.Message != "--file-key and --type are required" {
t.Fatalf("message = %q", problem.Message)
}
if !strings.Contains(problem.Hint, "+messages-mget") || !strings.Contains(problem.Hint, "--download-resources") {
t.Fatalf("hint = %q", problem.Hint)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T", err)
}
if len(validationErr.Params) != 2 || validationErr.Params[0].Name != "--file-key" || validationErr.Params[1].Name != "--type" {
t.Fatalf("params = %#v", validationErr.Params)
}
})
t.Run("one missing", func(t *testing.T) {
for _, tc := range []struct {
name string
fileKey string
fileType string
param string
}{
{name: "file key", fileType: "image", param: "--file-key"},
{name: "type", fileKey: "img_xxx", param: "--type"},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateIMResourceDownloadRequiredFlags(tc.fileKey, tc.fileType)
assertValidationError(t, tc.name, err, tc.param)
problem, _ := errs.ProblemOf(err)
if !strings.Contains(problem.Hint, "+messages-mget") || !strings.Contains(problem.Hint, "--download-resources") {
t.Fatalf("hint = %q", problem.Hint)
}
})
}
})
if err := validateIMResourceDownloadRequiredFlags("img_xxx", "image"); err != nil {
t.Fatalf("complete flags error = %v", err)
}
}
func TestMessagesResourcesDownloadRequiredFlagDescriptions(t *testing.T) {
want := map[string]string{
"file-key": "required",
"type": "required",
}
for _, flag := range ImMessagesResourcesDownload.Flags {
if needle, ok := want[flag.Name]; ok {
if flag.Required {
t.Errorf("--%s must be validated manually so the error can carry a hint", flag.Name)
}
if !strings.Contains(flag.Desc, needle) {
t.Errorf("--%s description = %q, want %q", flag.Name, flag.Desc, needle)
}
delete(want, flag.Name)
}
}
if len(want) != 0 {
t.Fatalf("missing flag declarations: %v", want)
}
}
// TestSenderDisplay covers the human-readable sender column: a resolved name wins,
// otherwise the sender id is shown (AC3 fallback), and a system/senderless message
// with neither yields an empty string (no name is normal, not an error).

View File

@@ -14,8 +14,12 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
const imChatListPath = "/open-apis/im/v1/chats"
const (
// imChatListPath is the upstream HTTP path for the +chat-list shortcut.
imChatListPath = "/open-apis/im/v1/chats"
chatListDefaultPageLimit = 10
chatListMaximumPageLimit = 1000
)
// bot_strip_p2p is the request-level adjustment notice emitted when bot
// identity receives a mixed --types containing "p2p": the p2p value is
@@ -41,7 +45,7 @@ func writeBotStripP2pWarning(errOut io.Writer) {
var ImChatList = common.Shortcut{
Service: "im",
Command: "+chat-list",
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only)",
Description: "List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -49,10 +53,12 @@ var ImChatList = common.Shortcut{
Flags: []common.Flag{
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)"},
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+chat-list")},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the GET /open-apis/im/v1/chats request without executing.
@@ -65,15 +71,25 @@ var ImChatList = common.Shortcut{
if stripped {
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
return common.NewDryRunAPI().
dry := common.NewDryRunAPI()
if chatListShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.
GET(imChatListPath).
Params(buildChatListParams(runtime, effective))
},
// Validate enforces flag preconditions: page-size bounds, --types element
// enum, and the bot + single-p2p rejection (mixed types degrade in Execute).
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
if _, err := validateIMPageSize(runtime, "+chat-list", 20); err != nil {
return err
}
if n := runtime.Int("page-limit"); n < 1 || n > chatListMaximumPageLimit {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
if err := validateAliasEnum(runtime, "sort-type", "sort", "ByCreateTimeAsc", "ByActiveTimeDesc"); err != nil {
return err
}
parts, err := normalizeTypes(runtime.StrSlice("types"))
if err != nil {
@@ -85,7 +101,7 @@ var ImChatList = common.Shortcut{
}
return nil
},
// Execute fetches one page of chats, optionally applies --exclude-muted
// Execute fetches one or more pages of chats, optionally applies --exclude-muted
// via MaybeApplyMuteFilter, and renders the result. outData["filter"] is
// populated only when --exclude-muted is set (backward compatible).
// outData["notices"] is populated only when bot identity strips p2p from
@@ -97,7 +113,13 @@ var ImChatList = common.Shortcut{
writeBotStripP2pWarning(runtime.IO().ErrOut)
}
params := buildChatListParams(runtime, effective)
resData, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
var resData map[string]interface{}
var err error
if chatListShouldAutoPaginate(runtime) {
resData, err = fetchChatListAllPages(runtime, params)
} else {
resData, err = runtime.CallAPITyped("GET", imChatListPath, params, nil)
}
if err != nil {
return err
}
@@ -197,6 +219,64 @@ var ImChatList = common.Shortcut{
},
}
func chatListShouldAutoPaginate(runtime *common.RuntimeContext) bool {
return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token")
}
func fetchChatListAllPages(runtime *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) {
maxPages := runtime.Int("page-limit")
if maxPages < 1 {
maxPages = chatListDefaultPageLimit
}
if maxPages > chatListMaximumPageLimit {
maxPages = chatListMaximumPageLimit
}
allItems := make([]interface{}, 0)
var lastData map[string]interface{}
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__"
delete(params, "page_token")
for page := 0; page < maxPages; page++ {
if page > 0 {
params["page_token"] = lastPageToken
}
data, err := runtime.CallAPITyped("GET", imChatListPath, params, nil)
if err != nil {
return nil, err
}
lastData = data
if items, ok := data["items"].([]interface{}); ok {
allItems = append(allItems, items...)
}
lastHasMore, lastPageToken = common.PaginationMeta(data)
fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d chats\n", page+1, len(allItems))
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == prevPageToken {
fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop")
break
}
if page+1 >= maxPages {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages)
break
}
prevPageToken = lastPageToken
}
if lastData == nil {
lastData = map[string]interface{}{}
}
lastData["items"] = allItems
lastData["has_more"] = lastHasMore
lastData["page_token"] = lastPageToken
return lastData, nil
}
// normalizeTypes validates and normalizes the --types slice already parsed by cobra.
// cobra's StringSlice handles the CSV split automatically — both --types=p2p,group
// and repeated --types p2p --types group arrive here as a 2-element []string,

View File

@@ -30,8 +30,10 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Bool("page-all", false, "")
for name := range stringFlags {
if name == "page-size" {
if name == "page-size" || name == "page-limit" {
continue
}
if name == "types" {
@@ -41,6 +43,9 @@ func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[str
}
}
for name := range boolFlags {
if name == "page-all" {
continue
}
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
@@ -296,10 +301,12 @@ func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().String("user-id-type", "open_id", "")
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
cmd.Flags().StringSlice("types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Bool("exclude-muted", false, "")
cmd.Flags().Bool("dry-run", false, "")
if err := cmd.ParseFlags(nil); err != nil {
@@ -686,8 +693,12 @@ func TestChatList_SortFlagSurface(t *testing.T) {
if !aliasFlag.Hidden {
t.Errorf("--sort-type must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
if len(aliasFlag.Enum) != 0 {
// A declared enum is framework-validated before canonical-wins
// resolution, so an inert alias value would fail the command even
// when --sort is present. The value set is enforced by
// validateAliasEnum in Validate instead.
t.Errorf("--sort-type (hidden alias) must not declare an Enum, got %q", aliasFlag.Enum)
}
if aliasFlag.Default != "" {
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)

View File

@@ -20,7 +20,6 @@ import (
const (
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
chatMembersListDefaultPageSize = 20
chatMembersListMaxPageSize = 100
// chatMembersListDefaultPageDelay throttles --page-all the same way the
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
// server-side member cap, where a large group drains many pages back to
@@ -28,6 +27,8 @@ const (
chatMembersListDefaultPageDelay = 200
)
var chatMembersListMaxPageSize = imPageSizeLimit("+chat-members-list")
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
// returning users and bots in separate buckets (users[]/bots[]). It owns its
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
@@ -48,7 +49,7 @@ var ImChatMembersList = common.Shortcut{
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: imPageSizeDescription("+chat-members-list")},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
@@ -67,8 +68,8 @@ var ImChatMembersList = common.Shortcut{
if !strings.HasPrefix(chatID, "oc_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
}
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
if _, err := validateIMPageSize(runtime, "+chat-members-list", chatMembersListDefaultPageSize); err != nil {
return err
}
if n := runtime.Int("page-limit"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
@@ -76,8 +77,12 @@ var ImChatMembersList = common.Shortcut{
if n := runtime.Int("page-delay"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
}
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
return err
memberTypes := runtime.StrSlice("member-types")
if _, err := normalizeMemberTypes(memberTypes); err != nil {
return err
}
writeMemberTypesCompatibilityNotes(runtime.IO().ErrOut, memberTypes)
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
@@ -303,17 +308,30 @@ func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
// the API's default of all types). Any element outside {user, bot} is rejected.
// the API's default of all types). Plural spellings are normalized before
// validation. Every value is validated first; only then does an occurrence of
// all turn the whole filter into a no-op, so an invalid value alongside all
// (e.g. "admin,all") is still rejected instead of silently ignored.
func normalizeMemberTypes(raw []string) (string, error) {
if len(raw) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
hasAll := false
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
switch p {
case "users":
p = "user"
case "bots":
p = "bot"
case "all":
hasAll = true
continue
}
if p != "user" && p != "bot" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot, all", p).WithParam("--member-types")
}
if _, dup := seen[p]; dup {
continue
@@ -321,9 +339,42 @@ func normalizeMemberTypes(raw []string) (string, error) {
seen[p] = struct{}{}
out = append(out, p)
}
if hasAll {
return "", nil
}
return strings.Join(out, ","), nil
}
func writeMemberTypesCompatibilityNotes(w io.Writer, raw []string) {
for _, value := range raw {
value = strings.TrimSpace(value)
if strings.EqualFold(value, "all") {
fmt.Fprintf(w, "note: --member-types %q means no filter (same as omitting the flag)\n", value)
return
}
}
seen := make(map[string]struct{}, 2)
for _, value := range raw {
value = strings.TrimSpace(value)
canonical := ""
switch strings.ToLower(value) {
case "users":
canonical = "user"
case "bots":
canonical = "bot"
}
if canonical == "" {
continue
}
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
fmt.Fprintf(w, "note: --member-types %q is accepted as %q\n", value, canonical)
}
}
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {

View File

@@ -164,6 +164,13 @@ func TestNormalizeMemberTypes(t *testing.T) {
{nil, "", false},
{[]string{"user", "bot"}, "user,bot", false},
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
{[]string{"all"}, "", false},
{[]string{"ALL"}, "", false},
{[]string{"users", "bots"}, "user,bot", false},
{[]string{"Users", "user", "Bots", "bot"}, "user,bot", false},
{[]string{"user", "all"}, "", false},
{[]string{"bots", "ALL"}, "", false},
{[]string{"admin", "ALL"}, "", true}, // invalid value is rejected even when all is present
{[]string{"admin"}, "", true},
{[]string{""}, "", true},
}
@@ -182,6 +189,54 @@ func TestNormalizeMemberTypes(t *testing.T) {
}
}
func TestChatMembersListMemberTypesCompatibilityNotes(t *testing.T) {
cases := []struct {
name string
memberType string
want []string
}{
{
name: "all",
memberType: "all,user",
want: []string{`note: --member-types "all" means no filter (same as omitting the flag)`},
},
{
name: "uppercase all",
memberType: "ALL",
want: []string{`note: --member-types "ALL" means no filter (same as omitting the flag)`},
},
{
name: "plural values",
memberType: "Users,bots",
want: []string{
`note: --member-types "Users" is accepted as "user"`,
`note: --member-types "bots" is accepted as "bot"`,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runtime := newChatMembersTestRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0}), nil
}), map[string]string{"chat-id": "oc_test", "member-types": tc.memberType}, nil, nil)
if err := ImChatMembersList.Validate(context.Background(), runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
stderr := runtime.IO().ErrOut.(*bytes.Buffer).String()
for _, note := range tc.want {
if got := strings.Count(stderr, note); got != 1 {
t.Fatalf("note count = %d, want 1 for %q; stderr=%q", got, note, stderr)
}
}
if stdout := runtime.IO().Out.(*bytes.Buffer).String(); stdout != "" {
t.Fatalf("compatibility note leaked to stdout: %q", stdout)
}
})
}
}
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
// drain with no explicit size → max; explicit size → honored; single page → default.
func TestEffectiveChatMembersPageSize(t *testing.T) {

View File

@@ -17,10 +17,16 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
chatMessagesListDefaultPageSize = 50
chatMessagesListDefaultPageLimit = 10
chatMessagesListMaxPageLimit = 1000
)
var ImChatMessageList = common.Shortcut{
Service: "im",
Command: "+chat-messages-list",
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range, --order asc|desc sorting, auto-pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
@@ -31,11 +37,17 @@ var ImChatMessageList = common.Shortcut{
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
{Name: "start", Desc: "start time (ISO 8601)"},
{Name: "start-time", Hidden: true, Desc: "alias of --start (hidden)"},
{Name: "end", Desc: "end time (ISO 8601)"},
{Name: "end-time", Hidden: true, Desc: "alias of --end (hidden)"},
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)"},
{Name: "sort-order", Hidden: true, Desc: "alias of --order (hidden)"},
{Name: "page-size", Default: "50", Desc: imPageSizeDescription("+chat-messages-list")},
{Name: "limit", Hidden: true, Desc: "alias of --page-size (hidden)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
@@ -48,6 +60,9 @@ var ImChatMessageList = common.Shortcut{
if runtime.Str("user-id") != "" {
d.Desc("(--user-id provided) Will resolve P2P chat_id via POST /open-apis/im/v1/chat_p2p/batch_query at execution time")
}
if chatMessagesListShouldAutoPaginate(runtime) {
d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
params, err := buildChatMessageListRequest(runtime, chatId)
if err != nil {
return d.Desc(err.Error())
@@ -97,6 +112,15 @@ var ImChatMessageList = common.Shortcut{
return err
}
}
if n := runtime.Int("page-limit"); n < 1 || n > chatMessagesListMaxPageLimit {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
if err := validateAliasEnum(runtime, "sort", "order", "asc", "desc"); err != nil {
return err
}
if err := validateAliasEnum(runtime, "sort-order", "order", "asc", "desc"); err != nil {
return err
}
chatId := runtime.Str("chat-id")
if chatId == "" {
@@ -106,6 +130,9 @@ var ImChatMessageList = common.Shortcut{
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateIMPageSize(runtime, "+chat-messages-list", chatMessagesListDefaultPageSize); err != nil {
return err
}
chatId, err := resolveChatIDForMessagesList(runtime, false)
if err != nil {
return err
@@ -115,7 +142,12 @@ var ImChatMessageList = common.Shortcut{
return err
}
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
var data map[string]interface{}
if chatMessagesListShouldAutoPaginate(runtime) {
data, err = fetchChatMessagesListAllPages(runtime, params)
} else {
data, err = runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
}
if err != nil {
return err
}
@@ -188,17 +220,71 @@ var ImChatMessageList = common.Shortcut{
},
}
func chatMessagesListShouldAutoPaginate(runtime *common.RuntimeContext) bool {
return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token")
}
func fetchChatMessagesListAllPages(runtime *common.RuntimeContext, params larkcore.QueryParams) (map[string]interface{}, error) {
maxPages := runtime.Int("page-limit")
if maxPages < 1 {
maxPages = chatMessagesListDefaultPageLimit
}
if maxPages > chatMessagesListMaxPageLimit {
maxPages = chatMessagesListMaxPageLimit
}
allItems := make([]interface{}, 0)
var lastData map[string]interface{}
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__"
delete(params, "page_token")
for page := 0; page < maxPages; page++ {
if page > 0 {
params["page_token"] = []string{lastPageToken}
}
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
if err != nil {
return nil, err
}
lastData = data
if items, ok := data["items"].([]interface{}); ok {
allItems = append(allItems, items...)
}
lastHasMore, lastPageToken = common.PaginationMeta(data)
fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d messages\n", page+1, len(allItems))
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == prevPageToken {
fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop")
break
}
if page+1 >= maxPages {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages)
break
}
prevPageToken = lastPageToken
}
if lastData == nil {
lastData = map[string]interface{}{}
}
lastData["items"] = allItems
lastData["has_more"] = lastHasMore
lastData["page_token"] = lastPageToken
return lastData, nil
}
// buildChatMessageListParams builds the shared API params for DryRun and Execute.
// and params map construction that existed verbatim in both DryRun and Execute.
func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.QueryParams {
func buildChatMessageListParams(sortFlag string, pageSize int, chatId string) larkcore.QueryParams {
sortType := "ByCreateTimeDesc"
if sortFlag == "asc" {
sortType = "ByCreateTimeAsc"
}
pageSize := 50
if n, err := strconv.Atoi(pageSizeStr); err == nil {
pageSize = min(max(n, 1), 50)
}
return larkcore.QueryParams{
"container_id_type": []string{"chat"},
"container_id": []string{chatId},
@@ -217,19 +303,42 @@ func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string)
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
dir = old // old value is asc/desc -> must go through the same map, never pass through
}
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
if old, ok := aliasFlagValue(runtime, "sort-order", "order"); ok {
dir = old
}
pageSizeFlag := "page-size"
if _, ok := aliasFlagValue(runtime, "limit", "page-size"); ok {
pageSizeFlag = "limit"
}
pageSize, err := validateIMPageSizeFlag(runtime, "+chat-messages-list", pageSizeFlag, chatMessagesListDefaultPageSize)
if err != nil {
return nil, err
}
params := buildChatMessageListParams(dir, pageSize, chatId)
if startFlag := runtime.Str("start"); startFlag != "" {
startFlag := runtime.Str("start")
startParam := "--start"
if old, ok := aliasFlagValue(runtime, "start-time", "start"); ok {
startFlag = old
startParam = "--start-time" // attribute errors to the flag the caller actually typed
}
if startFlag != "" {
startTime, err := common.ParseTime(startFlag)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", startParam, err).WithParam(startParam)
}
params["start_time"] = []string{startTime}
}
if endFlag := runtime.Str("end"); endFlag != "" {
endFlag := runtime.Str("end")
endParam := "--end"
if old, ok := aliasFlagValue(runtime, "end-time", "end"); ok {
endFlag = old
endParam = "--end-time"
}
if endFlag != "" {
endTime, err := common.ParseTime(endFlag, "end")
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %v", endParam, err).WithParam(endParam)
}
params["end_time"] = []string{endTime}
}

View File

@@ -92,7 +92,9 @@ func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
if !aliasFlag.Hidden {
t.Errorf("--sort must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
if len(aliasFlag.Enum) != 0 {
// Enforced by validateAliasEnum in Validate; a declared enum would be
// framework-validated before canonical-wins resolution runs.
t.Errorf("--sort (hidden alias) must not declare an Enum, got %q", aliasFlag.Enum)
}
}

View File

@@ -16,6 +16,11 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const (
chatSearchDefaultPageLimit = 10
chatSearchMaximumPageLimit = 1000
)
// ImChatSearch is the +chat-search shortcut: wraps POST /open-apis/im/v2/chats/search
// to find visible group chats by keyword and/or member open_ids. Supports
// member/type filters, sort order, pagination, and (user identity only) the
@@ -23,7 +28,7 @@ import (
var ImChatSearch = common.Shortcut{
Service: "im",
Command: "+chat-search",
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only)",
Description: "Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, auto-pagination, and --exclude-muted (user identity only)",
Risk: "read",
Scopes: []string{"im:chat:read"},
AuthTypes: []string{"user", "bot"},
@@ -32,20 +37,27 @@ var ImChatSearch = common.Shortcut{
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
{Name: "types", Hidden: true, Desc: "compatibility input handled by +chat-search validation; use --chat-modes or --search-types"},
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)"},
{Name: "page-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+chat-search")},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"},
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
},
// DryRun previews the POST /open-apis/im/v2/chats/search request without executing.
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
return common.NewDryRunAPI().
dry := common.NewDryRunAPI()
if chatSearchShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
return dry.
POST("/open-apis/im/v2/chats/search").
Params(params).
Body(body)
@@ -58,6 +70,12 @@ var ImChatSearch = common.Shortcut{
if query == "" && memberIDs == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
}
if err := applyChatSearchTypesCompatibility(runtime); err != nil {
return err
}
if err := validateAliasEnum(runtime, "sort-by", "sort", "create_time_desc", "update_time_desc", "member_count_desc"); err != nil {
return err
}
if st := runtime.Str("search-types"); st != "" {
allowed := map[string]struct{}{
"private": {},
@@ -89,19 +107,28 @@ var ImChatSearch = common.Shortcut{
}
}
}
if n := runtime.Int("page-size"); n < 1 || n > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 100").WithParam("--page-size")
if _, err := validateIMPageSize(runtime, "+chat-search", 20); err != nil {
return err
}
if n := runtime.Int("page-limit"); n < 1 || n > chatSearchMaximumPageLimit {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
return nil
},
// Execute fetches one page, extracts per-item meta_data, optionally applies
// Execute fetches one or more pages, extracts per-item meta_data, optionally applies
// the --exclude-muted client-side filter (with a PreSkipReason when
// --search-types is exactly public_not_joined), and renders the result.
// outData["filter"] is populated only when --exclude-muted is set.
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildSearchChatBody(runtime)
params := buildSearchChatParams(runtime)
resData, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
var resData map[string]interface{}
var err error
if chatSearchShouldAutoPaginate(runtime) {
resData, err = fetchChatSearchAllPages(runtime, params, body)
} else {
resData, err = runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
}
if err != nil {
return err
}
@@ -207,6 +234,109 @@ var ImChatSearch = common.Shortcut{
},
}
// applyChatSearchTypesCompatibility accepts the one observed cross-command
// spelling without treating --types as a normal alias. +chat-list and
// +chat-search use different value domains, so the value must be inspected
// before it can be mapped safely. An explicit --chat-modes always wins.
func applyChatSearchTypesCompatibility(runtime *common.RuntimeContext) error {
if !runtime.Changed("types") || runtime.Changed("chat-modes") {
return nil
}
typesValue := runtime.Str("types")
types := common.SplitCSV(typesValue)
for _, chatType := range types {
if chatType == "p2p" {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--types %q is invalid for im +chat-search: this command only searches group chats and the service does not support p2p; use im +chat-list --types p2p to list p2p chats",
typesValue,
).WithParam("--types")
}
}
onlyGroup := len(types) > 0
for _, chatType := range types {
if chatType != "group" {
onlyGroup = false
break
}
}
if !onlyGroup {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --types value %q for im +chat-search; use --chat-modes (group|topic) or --search-types (private|external|public_joined|public_not_joined)",
typesValue,
).WithParam("--types")
}
if err := runtime.Cmd.Flags().Set("chat-modes", "group"); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to map --types to --chat-modes").WithCause(err)
}
if runtime.Factory != nil && runtime.Factory.IOStreams != nil && runtime.Factory.IOStreams.ErrOut != nil {
fmt.Fprintln(runtime.Factory.IOStreams.ErrOut, "note: --types on +chat-search maps to --chat-modes")
}
return nil
}
func chatSearchShouldAutoPaginate(runtime *common.RuntimeContext) bool {
return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token")
}
func fetchChatSearchAllPages(runtime *common.RuntimeContext, params, body map[string]interface{}) (map[string]interface{}, error) {
maxPages := runtime.Int("page-limit")
if maxPages < 1 {
maxPages = chatSearchDefaultPageLimit
}
if maxPages > chatSearchMaximumPageLimit {
maxPages = chatSearchMaximumPageLimit
}
allItems := make([]interface{}, 0)
var lastData map[string]interface{}
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__"
delete(params, "page_token")
for page := 0; page < maxPages; page++ {
if page > 0 {
params["page_token"] = lastPageToken
}
data, err := runtime.CallAPITyped("POST", "/open-apis/im/v2/chats/search", params, body)
if err != nil {
return nil, err
}
lastData = data
if items, ok := data["items"].([]interface{}); ok {
allItems = append(allItems, items...)
}
lastHasMore, lastPageToken = common.PaginationMeta(data)
fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d chats\n", page+1, len(allItems))
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == prevPageToken {
fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop")
break
}
if page+1 >= maxPages {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages)
break
}
prevPageToken = lastPageToken
}
if lastData == nil {
lastData = map[string]interface{}{}
}
lastData["items"] = allItems
lastData["has_more"] = lastHasMore
lastData["page_token"] = lastPageToken
return lastData, nil
}
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
// from the runtime flag values. The query string is normalized via
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object

View File

@@ -4,9 +4,13 @@
package im
import (
"bytes"
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -18,7 +22,14 @@ func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.Runtim
if _, ok := stringFlags["query"]; !ok {
stringFlags["query"] = "team"
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
rt := newChatSearchTestRuntimeContext(t, stringFlags, nil)
rt.Factory = &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
},
}
return rt
}
func TestChatSearch_SortMapping(t *testing.T) {
@@ -96,7 +107,98 @@ func TestChatSearch_SortFlagSurface(t *testing.T) {
if !aliasFlag.Hidden {
t.Errorf("--sort-by must be Hidden")
}
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
t.Errorf("--sort-by Enum = %q", got)
if len(aliasFlag.Enum) != 0 {
// Enforced by validateAliasEnum in Validate; a declared enum would be
// framework-validated before canonical-wins resolution runs.
t.Errorf("--sort-by (hidden alias) must not declare an Enum, got %q", aliasFlag.Enum)
}
}
func TestChatSearch_TypesGroupMatchesChatModesGroup(t *testing.T) {
for _, typesValue := range []string{"group", "group,group"} {
t.Run(typesValue, func(t *testing.T) {
typesRT := newSearchTestRT(t, map[string]string{"types": typesValue})
if err := ImChatSearch.Validate(context.Background(), typesRT); err != nil {
t.Fatalf("Validate() error = %v", err)
}
canonicalRT := newSearchTestRT(t, map[string]string{"chat-modes": "group"})
typesBody := buildSearchChatBody(typesRT)
canonicalBody := buildSearchChatBody(canonicalRT)
if !reflect.DeepEqual(typesBody, canonicalBody) {
t.Fatalf("--types body = %#v, --chat-modes body = %#v", typesBody, canonicalBody)
}
filter, _ := typesBody["filter"].(map[string]interface{})
if got := filter["chat_modes"]; !reflect.DeepEqual(got, []string{"default"}) {
t.Fatalf("filter.chat_modes = %#v, want []string{\"default\"}", got)
}
stderr := typesRT.IO().ErrOut.(*bytes.Buffer).String()
if stderr != "note: --types on +chat-search maps to --chat-modes\n" {
t.Fatalf("stderr = %q", stderr)
}
if stdout := typesRT.IO().Out.(*bytes.Buffer).String(); stdout != "" {
t.Fatalf("mapping note leaked to stdout: %q", stdout)
}
})
}
}
func TestChatSearch_TypesP2PReturnsActionableValidationError(t *testing.T) {
for _, typesValue := range []string{"p2p", "group,p2p"} {
t.Run(typesValue, func(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"types": typesValue})
err := ImChatSearch.Validate(context.Background(), rt)
assertAliasValidationError(t, err, "--types", "im +chat-list --types p2p")
if !strings.Contains(err.Error(), "service does not support p2p") {
t.Fatalf("error = %q, want service p2p limitation", err)
}
})
}
}
func TestChatSearch_TypesUnknownListsCanonicalValueDomains(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{"types": "xxx"})
err := ImChatSearch.Validate(context.Background(), rt)
assertAliasValidationError(t, err, "--types", "--chat-modes (group|topic)")
if !strings.Contains(err.Error(), "--search-types (private|external|public_joined|public_not_joined)") {
t.Fatalf("error = %q, want --search-types values", err)
}
}
func TestChatSearch_ChatModesWinsOverTypes(t *testing.T) {
rt := newSearchTestRT(t, map[string]string{
"types": "p2p",
"chat-modes": "topic",
})
if err := ImChatSearch.Validate(context.Background(), rt); err != nil {
t.Fatalf("Validate() error = %v", err)
}
body := buildSearchChatBody(rt)
filter, _ := body["filter"].(map[string]interface{})
if got := filter["chat_modes"]; !reflect.DeepEqual(got, []string{"thread"}) {
t.Fatalf("filter.chat_modes = %#v, want []string{\"thread\"}", got)
}
if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "" {
t.Fatalf("ignored --types emitted stderr: %q", stderr)
}
}
func TestChatSearch_TypesFlagIsHiddenAndHasNoEnum(t *testing.T) {
var typesFlag *common.Flag
for i := range ImChatSearch.Flags {
if ImChatSearch.Flags[i].Name == "types" {
typesFlag = &ImChatSearch.Flags[i]
break
}
}
if typesFlag == nil {
t.Fatal("--types flag is missing")
}
if !typesFlag.Hidden {
t.Fatal("--types must be hidden")
}
if len(typesFlag.Enum) != 0 {
t.Fatalf("--types enum = %v, want custom validation", typesFlag.Enum)
}
}

View File

@@ -422,7 +422,7 @@ func TestFeedGroupValidationErrors(t *testing.T) {
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-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "invalid --page-size 0: must be 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"},

View File

@@ -33,7 +33,7 @@ var ImFeedGroupList = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-size", Type: "int", Default: "50", Desc: imPageSizeDescription("+feed-group-list")},
{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)"},
@@ -72,8 +72,8 @@ var ImFeedGroupList = common.Shortcut{
}
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 _, err := validateIMPageSize(rt, "+feed-group-list", 50); err != nil {
return err
}
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")

View File

@@ -28,7 +28,7 @@ var ImFeedGroupListItem = common.Shortcut{
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-size", Type: "int", Default: "50", Desc: imPageSizeDescription("+feed-group-list-item")},
{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)"},
@@ -72,8 +72,8 @@ 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 _, err := validateIMPageSize(rt, "+feed-group-list-item", 50); err != nil {
return err
}
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")

View File

@@ -0,0 +1,377 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func TestChatMessagesListAliasesMatchCanonicalRequest(t *testing.T) {
aliasRT := newMsgListTestRT(t, map[string]string{
"chat-id": "oc_test",
"start-time": "2026-07-27 00:00:00 +08:00",
"end-time": "1785254400",
"sort-order": "asc",
"limit": "25",
})
canonicalRT := newMsgListTestRT(t, map[string]string{
"chat-id": "oc_test",
"start": "2026-07-27 00:00:00 +08:00",
"end": "1785254400",
"order": "asc",
"page-size": "25",
})
aliasParams, err := buildChatMessageListRequest(aliasRT, "oc_test")
if err != nil {
t.Fatal(err)
}
canonicalParams, err := buildChatMessageListRequest(canonicalRT, "oc_test")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(aliasParams, canonicalParams) {
t.Fatalf("alias request = %#v, canonical request = %#v", aliasParams, canonicalParams)
}
}
func TestChatMessagesListCanonicalFlagsWinOverAliases(t *testing.T) {
bothRT := newMsgListTestRT(t, map[string]string{
"chat-id": "oc_test",
"start": "2026-07-27 00:00:00 +08:00",
"start-time": "2026-07-26 00:00:00 +08:00",
"end": "2026-07-28 00:00:00 +08:00",
"end-time": "2026-07-29 00:00:00 +08:00",
"order": "asc",
"sort-order": "desc",
"page-size": "25",
"limit": "30",
})
canonicalRT := newMsgListTestRT(t, map[string]string{
"chat-id": "oc_test",
"start": "2026-07-27 00:00:00 +08:00",
"end": "2026-07-28 00:00:00 +08:00",
"order": "asc",
"page-size": "25",
})
got, err := buildChatMessageListRequest(bothRT, "oc_test")
if err != nil {
t.Fatal(err)
}
want, err := buildChatMessageListRequest(canonicalRT, "oc_test")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("both-set request = %#v, canonical request = %#v", got, want)
}
}
func TestChatMessagesListLimitAliasKeepsPageSizeValidation(t *testing.T) {
rt := newMsgListTestRT(t, map[string]string{"limit": "100"})
_, err := buildChatMessageListRequest(rt, "oc_test")
assertAliasValidationError(t, err, "--limit", "invalid --limit 100: must be between 1 and 50")
}
func TestThreadsMessagesListThreadIDAlias(t *testing.T) {
aliasRT := newThreadsTestRT(t, map[string]string{"thread-id": "omt_alias"})
canonicalRT := newThreadsTestRT(t, map[string]string{"thread": "omt_alias"})
if err := ImThreadsMessagesList.Validate(context.Background(), aliasRT); err != nil {
t.Fatalf("alias validation error = %v", err)
}
if got, want := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), aliasRT)), mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), canonicalRT)); got != want {
t.Fatalf("alias dry-run differs from canonical:\nalias=%s\ncanonical=%s", got, want)
}
}
func TestThreadsMessagesListCanonicalThreadWins(t *testing.T) {
rt := newThreadsTestRT(t, map[string]string{
"thread": "omt_canonical",
"thread-id": "omt_alias",
})
got, param := resolveThreadsInput(rt)
if got != "omt_canonical" {
t.Fatalf("resolveThreadsInput() = %q, want omt_canonical", got)
}
if param != "--thread" {
t.Fatalf("resolveThreadsInput() param = %q, want --thread (canonical wins)", param)
}
}
func TestThreadsMessagesListStillRequiresThreadInput(t *testing.T) {
rt := newChatListTestRuntimeContext(t, map[string]string{}, nil)
err := ImThreadsMessagesList.Validate(context.Background(), rt)
assertAliasValidationError(t, err, "--thread", "--thread is required (om_xxx or omt_xxx)")
}
func TestMessagesMGetMessageIDAlias(t *testing.T) {
aliasRT := newTestRuntimeContext(t, map[string]string{"message-id": "om_alias"}, nil)
canonicalRT := newTestRuntimeContext(t, map[string]string{"message-ids": "om_alias"}, nil)
if err := ImMessagesMGet.Validate(context.Background(), aliasRT); err != nil {
t.Fatalf("alias validation error = %v", err)
}
if got, want := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), aliasRT)), mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), canonicalRT)); got != want {
t.Fatalf("alias dry-run differs from canonical:\nalias=%s\ncanonical=%s", got, want)
}
}
func TestMessagesMGetCanonicalMessageIDsWin(t *testing.T) {
rt := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_canonical",
"message-id": "om_alias",
}, nil)
if got := resolveMessageIDsInput(rt); got != "om_canonical" {
t.Fatalf("resolveMessageIDsInput() = %q, want om_canonical", got)
}
}
func TestMessagesMGetStillRequiresMessageIDs(t *testing.T) {
rt := newTestRuntimeContext(t, map[string]string{}, nil)
err := ImMessagesMGet.Validate(context.Background(), rt)
assertAliasValidationError(t, err, "--message-ids", "--message-ids is required (comma-separated om_xxx)")
}
func TestMessagesSearchAliasesMatchCanonicalRequest(t *testing.T) {
aliasRT := newMessagesSearchTestRuntimeContext(t, map[string]string{
"keyword": "project",
"limit": "30",
}, nil)
canonicalRT := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "project",
"page-size": "30",
}, nil)
aliasReq, err := buildMessagesSearchRequest(aliasRT)
if err != nil {
t.Fatal(err)
}
canonicalReq, err := buildMessagesSearchRequest(canonicalRT)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(aliasReq, canonicalReq) {
t.Fatalf("alias request = %#v, canonical request = %#v", aliasReq, canonicalReq)
}
}
func TestMessagesSearchCanonicalFlagsWinOverAliases(t *testing.T) {
rt := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "canonical",
"keyword": "alias",
"page-size": "25",
"limit": "30",
}, nil)
req, err := buildMessagesSearchRequest(rt)
if err != nil {
t.Fatal(err)
}
if got := req.body["query"]; got != "canonical" {
t.Fatalf("query = %#v, want canonical", got)
}
if got := req.params["page_size"][0]; got != "25" {
t.Fatalf("page_size = %q, want 25", got)
}
}
func TestMessagesSearchLimitAliasKeepsPageSizeValidation(t *testing.T) {
rt := newMessagesSearchTestRuntimeContext(t, map[string]string{"limit": "100"}, nil)
_, err := buildMessagesSearchRequest(rt)
assertAliasValidationError(t, err, "--limit", "invalid --limit 100: must be between 1 and 50")
}
func TestIMFlagAliasesAreHiddenAndTypeCompatible(t *testing.T) {
tests := []struct {
shortcut *common.Shortcut
alias string
canonical string
}{
{&ImChatMessageList, "start-time", "start"},
{&ImChatMessageList, "end-time", "end"},
{&ImChatMessageList, "sort-order", "order"},
{&ImChatMessageList, "limit", "page-size"},
{&ImThreadsMessagesList, "thread-id", "thread"},
{&ImMessagesMGet, "message-id", "message-ids"},
{&ImMessagesSearch, "keyword", "query"},
{&ImMessagesSearch, "limit", "page-size"},
}
for _, tt := range tests {
t.Run(tt.shortcut.Command+"/"+tt.alias, func(t *testing.T) {
alias := findIMFlag(t, tt.shortcut, tt.alias)
canonical := findIMFlag(t, tt.shortcut, tt.canonical)
if !alias.Hidden {
t.Fatalf("--%s must be hidden", tt.alias)
}
if alias.Required {
t.Fatalf("--%s must not use Cobra required validation", tt.alias)
}
if alias.Type != canonical.Type {
t.Fatalf("--%s type = %q, --%s type = %q", tt.alias, alias.Type, tt.canonical, canonical.Type)
}
if len(alias.Enum) != 0 {
// Declared enums are framework-validated before canonical-wins
// resolution, so an inert alias value would fail the command
// even when the canonical flag is present. Value sets for
// aliases are enforced by validateAliasEnum in Validate.
t.Fatalf("--%s (hidden alias) must not declare an Enum, got %v", tt.alias, alias.Enum)
}
if alias.Default != "" {
t.Fatalf("--%s default = %q, want empty", tt.alias, alias.Default)
}
})
}
if findIMFlag(t, &ImThreadsMessagesList, "thread").Required {
t.Fatal("--thread must use shortcut validation so --thread-id can satisfy the requirement")
}
if findIMFlag(t, &ImMessagesMGet, "message-ids").Required {
t.Fatal("--message-ids must use shortcut validation so --message-id can satisfy the requirement")
}
}
func TestExistingIMAliasesNowWriteCanonicalNotes(t *testing.T) {
tests := []struct {
name string
rt *common.RuntimeContext
run func(*common.RuntimeContext)
note string
}{
{
name: "chat list sort type",
rt: newChatListTestRuntimeContext(t, map[string]string{"sort-type": "ByActiveTimeDesc"}, nil),
run: func(rt *common.RuntimeContext) { _ = buildChatListParams(rt, "") },
note: "note: --sort-type is an alias for --sort\n",
},
{
name: "chat messages sort",
rt: newMsgListTestRT(t, map[string]string{"sort": "desc"}),
run: func(rt *common.RuntimeContext) {
_, _ = buildChatMessageListRequest(rt, "oc_test")
},
note: "note: --sort is an alias for --order\n",
},
{
name: "chat search sort by",
rt: newSearchTestRT(t, map[string]string{"query": "team", "sort-by": "create_time_desc"}),
run: func(rt *common.RuntimeContext) { _ = buildSearchChatBody(rt) },
note: "note: --sort-by is an alias for --sort\n",
},
{
name: "thread messages sort",
rt: newThreadsTestRT(t, map[string]string{"sort": "desc"}),
run: func(rt *common.RuntimeContext) { _ = resolveThreadsOrder(rt) },
note: "note: --sort is an alias for --order\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(tt.rt)
if got := tt.rt.IO().ErrOut.(*bytes.Buffer).String(); got != tt.note {
t.Fatalf("stderr = %q, want %q", got, tt.note)
}
if got := tt.rt.IO().Out.(*bytes.Buffer).String(); got != "" {
t.Fatalf("alias note leaked to stdout: %q", got)
}
})
}
}
func findIMFlag(t *testing.T, shortcut *common.Shortcut, name string) *common.Flag {
t.Helper()
for i := range shortcut.Flags {
if shortcut.Flags[i].Name == name {
return &shortcut.Flags[i]
}
}
t.Fatalf("%s is missing --%s", shortcut.Command, name)
return nil
}
func assertAliasValidationError(t *testing.T, err error, wantParam, wantMessage string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %#v", problem)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error is not *errs.ValidationError: %T %v", err, err)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
if !strings.Contains(err.Error(), wantMessage) {
t.Fatalf("error = %q, want substring %q", err, wantMessage)
}
}
// --- review regressions: error attribution and inert-alias enum handling ---
// Alias-supplied values must attribute failures to the flag the caller
// actually typed, not to the canonical flag it maps to.
func TestChatMessagesListAliasErrorsNameTypedFlag(t *testing.T) {
rt := newTestRuntimeContext(t, map[string]string{"start-time": "bad-time"}, nil)
_, err := buildChatMessageListRequest(rt, "oc_x")
assertAliasValidationError(t, err, "--start-time", "--start-time: cannot parse time")
rt = newTestRuntimeContext(t, map[string]string{"end-time": "also-bad"}, nil)
_, err = buildChatMessageListRequest(rt, "oc_x")
assertAliasValidationError(t, err, "--end-time", "--end-time: cannot parse time")
}
func TestThreadsMessagesListThreadIDAliasErrorNamesTypedFlag(t *testing.T) {
rt := newThreadsTestRT(t, map[string]string{"thread-id": "not-a-thread"})
err := ImThreadsMessagesList.Validate(context.Background(), rt)
assertAliasValidationError(t, err, "--thread-id", `invalid --thread-id "not-a-thread"`)
}
func TestMessagesMGetMessageIDAliasErrorNamesTypedFlag(t *testing.T) {
rt := newTestRuntimeContext(t, map[string]string{"message-id": "not-om"}, nil)
err := ImMessagesMGet.Validate(context.Background(), rt)
assertAliasValidationError(t, err, "--message-id", `invalid message ID "not-om"`)
}
// A hidden alias with an invalid value must be ignored entirely when the
// canonical flag is present (canonical wins), and rejected under its own
// name when it is the flag in effect.
func TestValidateAliasEnum(t *testing.T) {
rt := newTestRuntimeContext(t, map[string]string{"order": "asc", "sort-order": "unexpected"}, nil)
if err := validateAliasEnum(rt, "sort-order", "order", "asc", "desc"); err != nil {
t.Fatalf("inert alias value must not fail the command: %v", err)
}
params, err := buildChatMessageListRequest(rt, "oc_x")
if err != nil {
t.Fatalf("buildChatMessageListRequest() error = %v", err)
}
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
t.Fatalf("sort_type = %q, want ByCreateTimeAsc (canonical --order asc wins)", got)
}
rt = newTestRuntimeContext(t, map[string]string{"sort-order": "unexpected"}, nil)
err = validateAliasEnum(rt, "sort-order", "order", "asc", "desc")
assertAliasValidationError(t, err, "--sort-order", `invalid value "unexpected" for --sort-order, allowed: asc, desc`)
rt = newTestRuntimeContext(t, map[string]string{"sort-order": "desc"}, nil)
if err := validateAliasEnum(rt, "sort-order", "order", "asc", "desc"); err != nil {
t.Fatalf("valid alias value must pass: %v", err)
}
}

View File

@@ -25,7 +25,7 @@ var ImFlagList = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-size", Type: "int", Default: "50", Desc: imPageSizeDescription("+flag-list")},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages with --page-all (default 20; configurable range 1-1000)"},
@@ -71,8 +71,8 @@ var ImFlagList = common.Shortcut{
}
func validateListOptions(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 _, err := validateIMPageSize(rt, "+flag-list", 50); err != nil {
return err
}
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")

View File

@@ -0,0 +1,495 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
type listPageAllCase struct {
name string
shortcut common.Shortcut
path string
method string
outputKey string
outputID string
baseFlags map[string]string
makeRawItem func(string) interface{}
}
func listPageAllCases() []listPageAllCase {
messageItem := func(id string) interface{} {
return map[string]interface{}{
"message_id": id,
"msg_type": "text",
"body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, id)},
"create_time": "0",
}
}
chatItem := func(id string) interface{} {
return map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}
}
searchItem := func(id string) interface{} {
return map[string]interface{}{"meta_data": chatItem(id)}
}
return []listPageAllCase{
{
name: "chat-messages-list", shortcut: ImChatMessageList,
path: "/open-apis/im/v1/messages", method: http.MethodGet,
outputKey: "messages", outputID: "message_id",
baseFlags: map[string]string{"chat-id": "oc_test", "no-reactions": "true"},
makeRawItem: messageItem,
},
{
name: "threads-messages-list", shortcut: ImThreadsMessagesList,
path: "/open-apis/im/v1/messages", method: http.MethodGet,
outputKey: "messages", outputID: "message_id",
baseFlags: map[string]string{"thread": "omt_test", "no-reactions": "true"},
makeRawItem: messageItem,
},
{
name: "chat-list", shortcut: ImChatList,
path: "/open-apis/im/v1/chats", method: http.MethodGet,
outputKey: "chats", outputID: "chat_id",
baseFlags: map[string]string{},
makeRawItem: chatItem,
},
{
name: "chat-search", shortcut: ImChatSearch,
path: "/open-apis/im/v2/chats/search", method: http.MethodPost,
outputKey: "chats", outputID: "chat_id",
baseFlags: map[string]string{"query": "team"},
makeRawItem: searchItem,
},
}
}
func newListPageAllCommand(t *testing.T, shortcut common.Shortcut, flags map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "bool":
cmd.Flags().Bool(flag.Name, flag.Default == "true", flag.Desc)
case "int":
defaultValue := 0
if flag.Default != "" {
defaultValue, _ = strconv.Atoi(flag.Default)
}
cmd.Flags().Int(flag.Name, defaultValue, flag.Desc)
case "string_slice":
cmd.Flags().StringSlice(flag.Name, nil, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, value := range flags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%s: %v", name, value, err)
}
}
return cmd
}
func mergeListPageAllFlags(base map[string]string, overrides map[string]string) map[string]string {
flags := make(map[string]string, len(base)+len(overrides))
for name, value := range base {
flags[name] = value
}
for name, value := range overrides {
flags[name] = value
}
return flags
}
func newListPageAllRuntime(t *testing.T, tc listPageAllCase, flags map[string]string, responder func(*http.Request, int) map[string]interface{}) (*common.RuntimeContext, *int) {
t.Helper()
calls := 0
transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != tc.method || req.URL.Path != tc.path {
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
}
calls++
data := responder(req, calls)
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{"code": 0, "data": data}), nil
})
runtime := newUserShortcutRuntime(t, transport)
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, mergeListPageAllFlags(tc.baseFlags, flags))
runtime.Format = "json"
return runtime, &calls
}
func listPageAllOutputData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
t.Helper()
out, ok := runtime.IO().Out.(*bytes.Buffer)
if !ok {
t.Fatal("stdout is not a bytes.Buffer")
}
var envelope map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, out.String())
}
data, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("stdout data has unexpected shape: %#v", envelope["data"])
}
return data
}
func assertListPageAllOrder(t *testing.T, data map[string]interface{}, tc listPageAllCase, want ...string) {
t.Helper()
items, ok := data[tc.outputKey].([]interface{})
if !ok {
t.Fatalf("%s has unexpected shape: %#v", tc.outputKey, data[tc.outputKey])
}
if len(items) != len(want) {
t.Fatalf("%s length = %d, want %d: %#v", tc.outputKey, len(items), len(want), items)
}
for i, item := range items {
row, _ := item.(map[string]interface{})
if got, _ := row[tc.outputID].(string); got != want[i] {
t.Fatalf("%s[%d].%s = %q, want %q", tc.outputKey, i, tc.outputID, got, want[i])
}
}
}
func TestIMListPageAllMergesPagesAndUsesFinalPaginationMeta(t *testing.T) {
for _, tc := range listPageAllCases() {
t.Run(tc.name, func(t *testing.T) {
var requestTokens []string
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(req *http.Request, call int) map[string]interface{} {
requestTokens = append(requestTokens, req.URL.Query().Get("page_token"))
if call == 1 {
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("first")}, "has_more": true, "page_token": "next", "total": 2}
}
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("second")}, "has_more": false, "page_token": "final", "total": 2}
})
if err := tc.shortcut.Validate(context.Background(), runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if *calls != 2 {
t.Fatalf("API calls = %d, want 2", *calls)
}
if len(requestTokens) != 2 || requestTokens[0] != "" || requestTokens[1] != "next" {
t.Fatalf("request page tokens = %v, want [\"\" \"next\"]", requestTokens)
}
data := listPageAllOutputData(t, runtime)
assertListPageAllOrder(t, data, tc, "first", "second")
if hasMore, _ := data["has_more"].(bool); hasMore {
t.Fatalf("has_more = true, want final page value false")
}
if token, _ := data["page_token"].(string); token != "final" {
t.Fatalf("page_token = %q, want final", token)
}
})
}
}
func TestIMListPageAllStopsOnRepeatedToken(t *testing.T) {
for _, tc := range listPageAllCases() {
t.Run(tc.name, func(t *testing.T) {
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(_ *http.Request, call int) map[string]interface{} {
return map[string]interface{}{"items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, "has_more": true, "page_token": "same", "total": 10}
})
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if *calls != 2 {
t.Fatalf("API calls = %d, want 2", *calls)
}
stderr := runtime.IO().ErrOut.(*bytes.Buffer).String()
if !strings.Contains(stderr, "page_token did not change") {
t.Fatalf("stderr missing repeated-token warning: %q", stderr)
}
if strings.Contains(stderr, "reached page limit") {
t.Fatalf("repeated token must not report a page-limit stop: %q", stderr)
}
})
}
}
func TestIMListPageAllReportsIncompleteResultOnPageLimit(t *testing.T) {
for _, tc := range listPageAllCases() {
t.Run(tc.name, func(t *testing.T) {
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true", "page-limit": "2"}, func(_ *http.Request, call int) map[string]interface{} {
return map[string]interface{}{"items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, "has_more": true, "page_token": fmt.Sprintf("token-%d", call), "total": 10}
})
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if *calls != 2 {
t.Fatalf("API calls = %d, want 2", *calls)
}
data := listPageAllOutputData(t, runtime)
assertListPageAllOrder(t, data, tc, "item-1", "item-2")
if hasMore, _ := data["has_more"].(bool); !hasMore {
t.Fatal("has_more = false, want true for incomplete result")
}
if token, _ := data["page_token"].(string); token != "token-2" {
t.Fatalf("page_token = %q, want token-2", token)
}
if _, exists := data["pages"]; exists {
t.Fatalf("output shape changed: unexpected pages field in %#v", data)
}
stderr := runtime.IO().ErrOut.(*bytes.Buffer).String()
for _, want := range []string{"reached page limit (2)", "has_more=true", "result is incomplete", "up to 1000", "page_token returned in stdout"} {
if !strings.Contains(stderr, want) {
t.Fatalf("stderr = %q, want %q", stderr, want)
}
}
stdout := runtime.IO().Out.(*bytes.Buffer).String()
for _, forbidden := range []string{"[pagination]", "result is incomplete", "Increase --page-limit"} {
if strings.Contains(stdout, forbidden) {
t.Fatalf("stdout contains pagination notice %q: %s", forbidden, stdout)
}
}
})
}
}
func TestIMListExplicitPageTokenDisablesPageAll(t *testing.T) {
for _, tc := range listPageAllCases() {
t.Run(tc.name, func(t *testing.T) {
runtime, calls := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true", "page-token": "resume"}, func(req *http.Request, _ int) map[string]interface{} {
if token := req.URL.Query().Get("page_token"); token != "resume" {
t.Fatalf("page_token = %q, want resume", token)
}
return map[string]interface{}{"items": []interface{}{tc.makeRawItem("only")}, "has_more": true, "page_token": "next", "total": 10}
})
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if *calls != 1 {
t.Fatalf("API calls = %d, want 1", *calls)
}
data := listPageAllOutputData(t, runtime)
assertListPageAllOrder(t, data, tc, "only")
})
}
}
func TestIMListPageLimitValidation(t *testing.T) {
for _, tc := range listPageAllCases() {
for _, limit := range []string{"0", "1001"} {
t.Run(tc.name+"/"+limit, func(t *testing.T) {
runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-limit": limit}, func(_ *http.Request, _ int) map[string]interface{} {
t.Fatal("validation must fail before an API request")
return nil
})
err := tc.shortcut.Validate(context.Background(), runtime)
assertValidationError(t, tc.name, err, "--page-limit")
})
}
}
}
func TestIMListPageAllDryRunAndFlagSurface(t *testing.T) {
for _, tc := range listPageAllCases() {
t.Run(tc.name, func(t *testing.T) {
runtime, _ := newListPageAllRuntime(t, tc, map[string]string{"page-all": "true"}, func(_ *http.Request, _ int) map[string]interface{} {
t.Fatal("dry-run must not make an API request")
return nil
})
dryRun := mustMarshalDryRun(t, tc.shortcut.DryRun(context.Background(), runtime))
var dryRunData map[string]interface{}
if err := json.Unmarshal([]byte(dryRun), &dryRunData); err != nil {
t.Fatalf("decode dry-run: %v", err)
}
if description, _ := dryRunData["description"].(string); description != "Auto-paginates through all pages (capped by --page-limit when > 0)" {
t.Fatalf("dry-run missing auto-pagination description: %s", dryRun)
}
flags := make(map[string]common.Flag)
for _, flag := range tc.shortcut.Flags {
flags[flag.Name] = flag
}
if flag := flags["page-all"]; flag.Type != "bool" || flag.Desc != "automatically paginate, capped by --page-limit" {
t.Fatalf("page-all flag = %#v", flag)
}
if flag := flags["page-limit"]; flag.Type != "int" || flag.Default != "10" || !strings.Contains(flag.Desc, "1-1000") {
t.Fatalf("page-limit flag = %#v", flag)
}
})
}
}
func TestMessageListPageAllEnrichesMergedMessagesOnce(t *testing.T) {
messageItem := func(id string) interface{} {
return map[string]interface{}{
"message_id": id,
"msg_type": "text",
"body": map[string]interface{}{"content": fmt.Sprintf(`{"text":%q}`, id)},
"create_time": "0",
}
}
tests := []struct {
name string
shortcut common.Shortcut
flags map[string]string
}{
{name: "chat-messages-list", shortcut: ImChatMessageList, flags: map[string]string{"chat-id": "oc_test", "page-all": "true"}},
{name: "threads-messages-list", shortcut: ImThreadsMessagesList, flags: map[string]string{"thread": "omt_test", "page-all": "true"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pageCalls := 0
reactionCalls := 0
reactionQueries := 0
transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/open-apis/im/v1/messages":
pageCalls++
if pageCalls == 1 {
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{messageItem("first")}, "has_more": true, "page_token": "next"},
}), nil
}
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{messageItem("second")}, "has_more": false, "page_token": "final"},
}), nil
case "/open-apis/im/v1/messages/reactions/batch_query":
reactionCalls++
var body struct {
Queries []map[string]interface{} `json:"queries"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatalf("decode reaction request: %v", err)
}
reactionQueries = len(body.Queries)
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{},
"success_msg_reaction_details": []interface{}{},
},
}), nil
default:
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}
})
runtime := newUserShortcutRuntime(t, transport)
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, tc.flags)
runtime.Format = "json"
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if pageCalls != 2 {
t.Fatalf("message page calls = %d, want 2", pageCalls)
}
if reactionCalls != 1 {
t.Fatalf("reaction batch calls = %d, want 1 after page merge", reactionCalls)
}
if reactionQueries != 2 {
t.Fatalf("reaction query count = %d, want both merged messages", reactionQueries)
}
})
}
}
func TestChatListPageAllFiltersMergedChatsOnce(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
path string
flags map[string]string
makeItem func(string) interface{}
}{
{
name: "chat-list", shortcut: ImChatList, path: "/open-apis/im/v1/chats",
flags: map[string]string{"page-all": "true", "exclude-muted": "true"},
makeItem: func(id string) interface{} {
return map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}
},
},
{
name: "chat-search", shortcut: ImChatSearch, path: "/open-apis/im/v2/chats/search",
flags: map[string]string{"query": "team", "page-all": "true", "exclude-muted": "true"},
makeItem: func(id string) interface{} {
return map[string]interface{}{"meta_data": map[string]interface{}{"chat_id": id, "name": id, "chat_mode": "group"}}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pageCalls := 0
muteCalls := 0
muteChatIDs := 0
transport := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case tc.path:
pageCalls++
if pageCalls == 1 {
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{tc.makeItem("oc_first")}, "has_more": true, "page_token": "next"},
}), nil
}
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"items": []interface{}{tc.makeItem("oc_second")}, "has_more": false, "page_token": "final"},
}), nil
case BatchGetMuteStatusPath:
muteCalls++
var body struct {
ChatIDs []string `json:"chat_ids"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
t.Fatalf("decode mute-status request: %v", err)
}
muteChatIDs = len(body.ChatIDs)
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"chat_id": "oc_first", "is_muted": false},
map[string]interface{}{"chat_id": "oc_second", "is_muted": false},
},
},
}), nil
default:
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}
})
runtime := newUserShortcutRuntime(t, transport)
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, tc.flags)
runtime.Format = "json"
if err := tc.shortcut.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if pageCalls != 2 {
t.Fatalf("chat page calls = %d, want 2", pageCalls)
}
if muteCalls != 1 {
t.Fatalf("mute-status calls = %d, want 1 after page merge", muteCalls)
}
if muteChatIDs != 2 {
t.Fatalf("mute-status chat ID count = %d, want both merged chats", muteChatIDs)
}
})
}
}

View File

@@ -28,12 +28,13 @@ var ImMessagesMGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)"},
{Name: "message-id", Hidden: true, Desc: "alias of --message-ids (hidden)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := common.SplitCSV(runtime.Str("message-ids"))
ids := common.SplitCSV(resolveMessageIDsInput(runtime))
d := common.NewDryRunAPI().GET(buildMGetURL(ids))
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
@@ -45,22 +46,23 @@ var ImMessagesMGet = common.Shortcut{
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
raw, param := resolveMessageIDsInputWithParam(runtime)
ids := common.SplitCSV(raw)
if len(ids) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids is required (comma-separated om_xxx)").WithParam("--message-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required (comma-separated om_xxx)", param).WithParam(param)
}
if len(ids) > maxMGetMessageIDs {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-ids supports at most %d IDs per request (got %d)", maxMGetMessageIDs, len(ids)).WithParam("--message-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s supports at most %d IDs per request (got %d)", param, maxMGetMessageIDs, len(ids)).WithParam(param)
}
for _, id := range ids {
if _, err := validateMessageID(id); err != nil {
if _, err := validateMessageIDForParam(id, param); err != nil {
return err
}
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
ids := common.SplitCSV(resolveMessageIDsInput(runtime))
mgetURL := buildMGetURL(ids)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, mgetURL, nil, nil)
@@ -127,3 +129,17 @@ var ImMessagesMGet = common.Shortcut{
return nil
},
}
func resolveMessageIDsInput(runtime *common.RuntimeContext) string {
ids, _ := resolveMessageIDsInputWithParam(runtime)
return ids
}
// resolveMessageIDsInputWithParam also reports which flag supplied the value,
// so validation errors are attributed to the flag the caller actually typed.
func resolveMessageIDsInputWithParam(runtime *common.RuntimeContext) (string, string) {
if old, ok := aliasFlagValue(runtime, "message-id", "message-ids"); ok {
return old, "--message-id"
}
return runtime.Str("message-ids"), "--message-ids"
}

View File

@@ -30,8 +30,8 @@ var ImMessagesResourcesDownload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)", Required: true},
{Name: "file-key", Desc: "resource key (img_xxx or file_xxx)", Required: true},
{Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}},
{Name: "file-key", Desc: "resource key (img_xxx or file_xxx; required)"},
{Name: "type", Desc: "resource type (required)", Enum: []string{"image", "file"}},
{Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -52,6 +52,9 @@ var ImMessagesResourcesDownload = common.Shortcut{
} else if _, err := validateMessageID(messageId); err != nil {
return err
}
if err := validateIMResourceDownloadRequiredFlags(runtime.Str("file-key"), runtime.Str("type")); err != nil {
return err
}
relPath, err := normalizeDownloadOutputPath(runtime.Str("file-key"), runtime.Str("output"))
if err != nil {
return err
@@ -86,6 +89,33 @@ var ImMessagesResourcesDownload = common.Shortcut{
},
}
const imResourceDownloadRequiredFlagsHint = "get --file-key from message content with `lark-cli im +messages-mget --message-ids om_xxx` (images use img_xxx; files use file_xxx), or download all attachments with `lark-cli im +chat-messages-list --download-resources` without supplying each file key"
func validateIMResourceDownloadRequiredFlags(fileKey, fileType string) error {
missingFileKey := strings.TrimSpace(fileKey) == ""
missingType := strings.TrimSpace(fileType) == ""
if !missingFileKey && !missingType {
return nil
}
if missingFileKey && missingType {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-key and --type are required").
WithParams(
errs.InvalidParam{Name: "--file-key", Reason: "required"},
errs.InvalidParam{Name: "--type", Reason: "required"},
).
WithHint("%s", imResourceDownloadRequiredFlagsHint)
}
if missingFileKey {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-key is required").
WithParam("--file-key").
WithHint("%s", imResourceDownloadRequiredFlagsHint)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required").
WithParam("--type").
WithHint("%s", imResourceDownloadRequiredFlagsHint)
}
func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
fileKey = strings.TrimSpace(fileKey)
if fileKey == "" {

View File

@@ -19,7 +19,6 @@ import (
const (
messagesSearchDefaultPageSize = 20
messagesSearchMaxPageSize = 50
messagesSearchDefaultPageLimit = 20
messagesSearchMaxPageLimit = 40
messagesSearchMGetBatchSize = 50
@@ -35,6 +34,7 @@ var ImMessagesSearch = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "keyword", Hidden: true, Desc: "alias of --query (hidden)"},
{Name: "chat-id", Desc: "limit to chat IDs, comma-separated"},
{Name: "sender", Desc: "sender open_ids, comma-separated"},
{Name: "include-attachment-type", Desc: "include attachment type filter", Enum: []string{"file", "image", "video", "link"}},
@@ -45,7 +45,8 @@ var ImMessagesSearch = common.Shortcut{
{Name: "at-chatter-ids", Desc: "filter by @mentioned user open_ids, comma-separated (also matches messages that @all)"},
{Name: "start", Desc: "start time(ISO 8601) with local timezone offset (e.g. 2026-03-24T00:00:00+08:00)"},
{Name: "end", Desc: "end time(ISO 8601) with local timezone offset (e.g. 2026-03-25T23:59:59+08:00)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-50)"},
{Name: "page-size", Type: "int", Default: "20", Desc: imPageSizeDescription("+messages-search")},
{Name: "limit", Type: "int", Hidden: true, Desc: "alias of --page-size (hidden)"},
{Name: "page-token", Desc: "page token"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
@@ -264,6 +265,9 @@ type messagesSearchRequest struct {
func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearchRequest, error) {
query := runtime.Str("query")
if old, ok := aliasFlagValue(runtime, "keyword", "query"); ok {
query = old
}
chatFlag := runtime.Str("chat-id")
senderFlag := runtime.Str("sender")
includeAttachmentTypeFlag := runtime.Str("include-attachment-type")
@@ -365,12 +369,13 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
body["filter"] = filter
}
pageSize := runtime.Int("page-size")
if pageSize < 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
pageSizeFlag := "page-size"
if _, ok := aliasIntFlagValue(runtime, "limit", "page-size"); ok {
pageSizeFlag = "limit"
}
if pageSize > messagesSearchMaxPageSize {
pageSize = messagesSearchMaxPageSize
pageSize, err := validateIMPageSizeFlag(runtime, "+messages-search", pageSizeFlag, messagesSearchDefaultPageSize)
if err != nil {
return nil, err
}
params := larkcore.QueryParams{

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
const imPageSizeMinimum = 1
// imPageSizeLimits is the single source of truth for shortcut page-size
// declarations and local validation in the IM domain.
//
// Verified against the corresponding OpenAPI contract or a read-only request:
// - GET /open-apis/im/v1/messages: 50
// - POST /open-apis/im/v1/messages/search: 50
// - GET /open-apis/im/v1/flags: 50
// - GET /open-apis/im/v1/groups: 50
// - POST /open-apis/im/v2/chats/search: 100
// - GET /open-apis/im/v1/chats: 100
// - GET /open-apis/im/v1/chats/:chat_id/members/list: 100
//
// GET /open-apis/im/v1/groups/:group_id/list_item has no public specification.
// Its limit was established by probing the endpoint: page_size 51 and above
// returns code 230001 "param is invalid", 50 succeeds.
var imPageSizeLimits = map[string]int{
"+threads-messages-list": 50,
"+chat-messages-list": 50,
"+messages-search": 50,
"+flag-list": 50,
"+feed-group-list": 50,
"+feed-group-list-item": 50,
"+chat-search": 100,
"+chat-list": 100,
"+chat-members-list": 100,
}
func imPageSizeLimit(command string) int {
limit, ok := imPageSizeLimits[command]
if !ok {
panic(fmt.Sprintf("missing IM page-size limit for %s", command))
}
return limit
}
func imPageSizeDescription(command string) string {
return fmt.Sprintf("page size (1-%d)", imPageSizeLimit(command))
}
func validateIMPageSize(runtime *common.RuntimeContext, command string, defaultValue int) (int, error) {
return validateIMPageSizeFlag(runtime, command, "page-size", defaultValue)
}
func validateIMPageSizeFlag(runtime *common.RuntimeContext, command, flagName string, defaultValue int) (int, error) {
return common.ValidatePageSizeTyped(
runtime,
flagName,
defaultValue,
imPageSizeMinimum,
imPageSizeLimit(command),
)
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"net/http"
"reflect"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
type imPageSizeLimitCase struct {
shortcut common.Shortcut
flags map[string]string
limit int
}
func imPageSizeLimitCases() []imPageSizeLimitCase {
return []imPageSizeLimitCase{
{shortcut: ImThreadsMessagesList, flags: map[string]string{"thread": "omt_test"}, limit: 50},
{shortcut: ImChatMessageList, flags: map[string]string{"chat-id": "oc_test"}, limit: 50},
{shortcut: ImMessagesSearch, flags: map[string]string{"query": "test"}, limit: 50},
{shortcut: ImFlagList, flags: map[string]string{}, limit: 50},
{shortcut: ImFeedGroupList, flags: map[string]string{}, limit: 50},
{shortcut: ImFeedGroupListItem, flags: map[string]string{"feed-group-id": "ofg_test"}, limit: 50},
{shortcut: ImChatSearch, flags: map[string]string{"query": "test"}, limit: 100},
{shortcut: ImChatList, flags: map[string]string{}, limit: 100},
{shortcut: ImChatMembersList, flags: map[string]string{"chat-id": "oc_test"}, limit: 100},
}
}
func TestIMPageSizeLimitsTable(t *testing.T) {
want := map[string]int{
"+threads-messages-list": 50,
"+chat-messages-list": 50,
"+messages-search": 50,
"+flag-list": 50,
"+feed-group-list": 50,
"+feed-group-list-item": 50,
"+chat-search": 100,
"+chat-list": 100,
"+chat-members-list": 100,
}
if !reflect.DeepEqual(imPageSizeLimits, want) {
t.Fatalf("imPageSizeLimits = %#v, want %#v", imPageSizeLimits, want)
}
}
func TestIMPageSizeFlagsMatchLimitsTable(t *testing.T) {
for _, tc := range imPageSizeLimitCases() {
t.Run(tc.shortcut.Command, func(t *testing.T) {
if got := imPageSizeLimit(tc.shortcut.Command); got != tc.limit {
t.Fatalf("imPageSizeLimit(%q) = %d, want %d", tc.shortcut.Command, got, tc.limit)
}
var pageSizeFlag *common.Flag
for i := range tc.shortcut.Flags {
if tc.shortcut.Flags[i].Name == "page-size" {
pageSizeFlag = &tc.shortcut.Flags[i]
break
}
}
if pageSizeFlag == nil {
t.Fatal("page-size flag is missing")
}
if want := imPageSizeDescription(tc.shortcut.Command); pageSizeFlag.Desc != want {
t.Fatalf("page-size description = %q, want %q", pageSizeFlag.Desc, want)
}
})
}
}
func TestIMPageSizeValidationAcceptsLimitAndRejectsNextValue(t *testing.T) {
for _, tc := range imPageSizeLimitCases() {
t.Run(tc.shortcut.Command, func(t *testing.T) {
for _, test := range []struct {
name string
pageSize int
wantError bool
}{
{name: "accepts-server-limit", pageSize: tc.limit},
{name: "rejects-limit-plus-one", pageSize: tc.limit + 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
requestCount := 0
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
requestCount++
t.Fatalf("validation sent an HTTP request: %s %s", req.Method, req.URL.String())
return nil, nil
}))
flags := mergeListPageAllFlags(tc.flags, map[string]string{"page-size": fmt.Sprintf("%d", test.pageSize)})
runtime.Cmd = newListPageAllCommand(t, tc.shortcut, flags)
err := tc.shortcut.Validate(context.Background(), runtime)
if !test.wantError {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
} else {
assertValidationError(t, tc.shortcut.Command, err, "--page-size")
wantMessage := fmt.Sprintf("invalid --page-size %d: must be between 1 and %d", test.pageSize, tc.limit)
if err.Error() != wantMessage {
t.Fatalf("Validate() error = %q, want %q", err.Error(), wantMessage)
}
}
if requestCount != 0 {
t.Fatalf("HTTP request count = %d, want 0", requestCount)
}
})
}
})
}
}

View File

@@ -17,12 +17,17 @@ import (
convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib"
)
const threadsMessagesMaxPageSize = 500
const (
threadsMessagesListDefaultPageLimit = 10
threadsMessagesListMaxPageLimit = 1000
)
var threadsMessagesMaxPageSize = imPageSizeLimit("+threads-messages-list")
var ImThreadsMessagesList = common.Shortcut{
Service: "im",
Command: "+threads-messages-list",
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports --order asc|desc sorting, auto-pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read"},
@@ -30,28 +35,36 @@ var ImThreadsMessagesList = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)"},
{Name: "thread-id", Hidden: true, Desc: "alias of --thread (hidden)"},
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)"},
{Name: "page-size", Default: "50", Desc: imPageSizeDescription("+threads-messages-list")},
{Name: "page-token", Desc: "page token"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages with --page-all (default 10; configurable range 1-1000)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
downloadResourcesFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
threadFlag, _ := resolveThreadsInput(runtime)
dir := resolveThreadsOrder(runtime)
pageSizeStr := runtime.Str("page-size")
pageToken := runtime.Str("page-token")
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
d := common.NewDryRunAPI()
pageSize, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize)
if err != nil {
return d.Desc(err.Error())
}
containerID := threadFlag
if messageIDRe.MatchString(threadFlag) {
d.Desc("(--thread provided as message ID) Will resolve thread_id via GET /open-apis/im/v1/messages/:message_id at execution time")
containerID = "<resolved_thread_id>"
}
if threadsMessagesListShouldAutoPaginate(runtime) {
d.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
@@ -69,29 +82,45 @@ var ImThreadsMessagesList = common.Shortcut{
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
threadId := runtime.Str("thread")
threadId, threadParam := resolveThreadsInput(runtime)
if threadId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--thread is required (om_xxx or omt_xxx)").WithParam("--thread")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required (om_xxx or omt_xxx)", threadParam).WithParam(threadParam)
}
if !strings.HasPrefix(threadId, "om_") && !strings.HasPrefix(threadId, "omt_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --thread %q: must start with om_ or omt_", threadId).WithParam("--thread")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: must start with om_ or omt_", threadParam, threadId).WithParam(threadParam)
}
_, err := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
return err
if err := validateAliasEnum(runtime, "sort", "order", "asc", "desc"); err != nil {
return err
}
if _, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize); err != nil {
return err
}
if n := runtime.Int("page-limit"); n < 1 || n > threadsMessagesListMaxPageLimit {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
threadId, err := resolveThreadID(runtime, runtime.Str("thread"))
pageSize, err := validateIMPageSize(runtime, "+threads-messages-list", threadsMessagesMaxPageSize)
if err != nil {
return err
}
threadInput, _ := resolveThreadsInput(runtime)
threadId, err := resolveThreadID(runtime, threadInput)
if err != nil {
return err
}
dir := resolveThreadsOrder(runtime)
pageToken := runtime.Str("page-token")
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
var data map[string]interface{}
if threadsMessagesListShouldAutoPaginate(runtime) {
data, err = fetchThreadsMessagesListAllPages(runtime, params)
} else {
data, err = runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
}
if err != nil {
return err
}
@@ -162,6 +191,71 @@ var ImThreadsMessagesList = common.Shortcut{
},
}
func threadsMessagesListShouldAutoPaginate(runtime *common.RuntimeContext) bool {
return runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token")
}
func fetchThreadsMessagesListAllPages(runtime *common.RuntimeContext, params map[string][]string) (map[string]interface{}, error) {
maxPages := runtime.Int("page-limit")
if maxPages < 1 {
maxPages = threadsMessagesListDefaultPageLimit
}
if maxPages > threadsMessagesListMaxPageLimit {
maxPages = threadsMessagesListMaxPageLimit
}
allItems := make([]interface{}, 0)
var lastData map[string]interface{}
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__"
delete(params, "page_token")
for page := 0; page < maxPages; page++ {
if page > 0 {
params["page_token"] = []string{lastPageToken}
}
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
if err != nil {
return nil, err
}
lastData = data
if items, ok := data["items"].([]interface{}); ok {
allItems = append(allItems, items...)
}
lastHasMore, lastPageToken = common.PaginationMeta(data)
fmt.Fprintf(runtime.IO().ErrOut, "page %d: %d thread messages\n", page+1, len(allItems))
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == prevPageToken {
fmt.Fprintln(runtime.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop")
break
}
if page+1 >= maxPages {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages)
break
}
prevPageToken = lastPageToken
}
if lastData == nil {
lastData = map[string]interface{}{}
}
lastData["items"] = allItems
lastData["has_more"] = lastHasMore
lastData["page_token"] = lastPageToken
return lastData, nil
}
func resolveThreadsInput(runtime *common.RuntimeContext) (string, string) {
if old, ok := aliasFlagValue(runtime, "thread-id", "thread"); ok {
return old, "--thread-id" // attribute errors to the flag the caller actually typed
}
return runtime.Str("thread"), "--thread"
}
// buildThreadsMessagesListParams builds the upstream query params shared by
// DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one
// place (precondition for the dry-run == real alias-parity test).

View File

@@ -17,7 +17,9 @@ func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.Runti
stringFlags = map[string]string{}
}
if _, ok := stringFlags["thread"]; !ok {
stringFlags["thread"] = "omt_test"
if _, aliasSet := stringFlags["thread-id"]; !aliasSet {
stringFlags["thread"] = "omt_test"
}
}
return newChatListTestRuntimeContext(t, stringFlags, nil)
}

View File

@@ -3,16 +3,77 @@
package im
import "github.com/larksuite/cli/shortcuts/common"
import (
"fmt"
"strings"
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
// alias. It returns (oldValue, true) only when the old flag was explicitly used
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
"github.com/larksuite/cli/shortcuts/common"
)
const aliasFlagNoticeAnnotation = "lark-cli.im/alias-notice-emitted"
// aliasFlagValue handles a renamed string flag whose old name is kept as a
// hidden alias. It is only for flags with identical semantics and value
// domains; value-aware compatibility such as +chat-search --types stays in
// that command's validation. It returns (oldValue, true) only when the old
// flag was explicitly used and the new one was not. The canonical flag wins
// when both are present. A note is emitted once per invocation when the alias
// is used.
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
if rt.Changed(oldName) && !rt.Changed(newName) {
emitAliasFlagNote(rt, oldName, newName)
return rt.Str(oldName), true
}
return "", false
}
// aliasIntFlagValue is the typed equivalent of aliasFlagValue for int flags.
func aliasIntFlagValue(rt *common.RuntimeContext, oldName, newName string) (int, bool) {
if rt.Changed(oldName) && !rt.Changed(newName) {
emitAliasFlagNote(rt, oldName, newName)
return rt.Int(oldName), true
}
return 0, false
}
func emitAliasFlagNote(rt *common.RuntimeContext, oldName, newName string) {
if rt == nil || rt.Cmd == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
return
}
flag := rt.Cmd.Flags().Lookup(oldName)
if flag == nil {
return
}
if len(flag.Annotations[aliasFlagNoticeAnnotation]) > 0 {
return
}
if flag.Annotations == nil {
flag.Annotations = make(map[string][]string)
}
flag.Annotations[aliasFlagNoticeAnnotation] = []string{newName}
fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "note: --%s is an alias for --%s\n", oldName, newName)
}
// validateAliasEnum enforces the fixed value set of a hidden alias flag, but
// only when the alias is actually in effect (alias set, canonical flag not).
// When the canonical flag is present the alias is ignored entirely — including
// its value — so a stray invalid alias value must not fail the command. The
// enum therefore cannot live on the Flag declaration (the framework validates
// declared enums before canonical-wins resolution runs); each command calls
// this from Validate instead.
func validateAliasEnum(rt *common.RuntimeContext, oldName, newName string, allowed ...string) error {
if !rt.Changed(oldName) || rt.Changed(newName) {
return nil
}
val := rt.Str(oldName)
if val == "" {
return nil
}
for _, a := range allowed {
if val == a {
return nil
}
}
return common.ValidationErrorf("invalid value %q for --%s, allowed: %s", val, oldName, strings.Join(allowed, ", ")).
WithParam("--" + oldName)
}

View File

@@ -4,8 +4,11 @@
package im
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -26,7 +29,13 @@ func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[s
t.Fatalf("Set(%q) error = %v", k, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
return &common.RuntimeContext{
Cmd: cmd,
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
}},
}
}
func TestAliasFlagValue(t *testing.T) {
@@ -51,3 +60,47 @@ func TestAliasFlagValue(t *testing.T) {
})
}
}
func TestAliasFlagValueWritesOneNoteToStderr(t *testing.T) {
rt := newAliasTestRT(t, "start", "", "start-time", map[string]string{
"start-time": "2026-07-27 00:00:00 +08:00",
})
for range 2 {
if _, ok := aliasFlagValue(rt, "start-time", "start"); !ok {
t.Fatal("aliasFlagValue() did not select --start-time")
}
}
stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
if got := strings.Count(stderr, "note: --start-time is an alias for --start\n"); got != 1 {
t.Fatalf("alias note count = %d, want 1; stderr=%q", got, stderr)
}
if stdout := rt.IO().Out.(*bytes.Buffer).String(); stdout != "" {
t.Fatalf("alias note leaked to stdout: %q", stdout)
}
}
func TestAliasIntFlagValue(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("limit", 0, "")
if err := cmd.Flags().Set("limit", "50"); err != nil {
t.Fatal(err)
}
rt := &common.RuntimeContext{
Cmd: cmd,
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
}},
}
got, ok := aliasIntFlagValue(rt, "limit", "page-size")
if !ok || got != 50 {
t.Fatalf("aliasIntFlagValue() = (%d, %v), want (50, true)", got, ok)
}
if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); stderr != "note: --limit is an alias for --page-size\n" {
t.Fatalf("stderr = %q", stderr)
}
}

View File

@@ -14,7 +14,7 @@ import (
// never appear (AC1/AC5). Covers chat-messages-list, threads-messages-list, and the
// shared mget URL used by messages-mget and messages-search.
func TestReadRequestsSendWithSenderName(t *testing.T) {
if got := buildChatMessageListParams("desc", "50", "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
if got := buildChatMessageListParams("desc", 50, "oc_x")["with_sender_name"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("chat-messages-list with_sender_name = %#v, want [true]", got)
}
if got := buildThreadsMessagesListParams("desc", "t_x", 50, "")["with_sender_name"]; len(got) != 1 || got[0] != "true" {

View File

@@ -32,6 +32,7 @@ const (
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
markdownUploadFinishAction = "finalize markdown multipart upload failed"
markdownFetchNameAction = "fetch existing markdown file name failed"
markdownSourceFilePreviewType = "16"
)
var markdownUploadRetryBackoffs = []time.Duration{
@@ -192,9 +193,14 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
query, err := markdownSourceFilePreviewQuery("", "")
if err != nil {
return nil, err
}
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
})
if err != nil {
return nil, wrapMarkdownDownloadError(err)
@@ -230,15 +236,15 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
return size, nil
}
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (*http.Response, string, error) {
query, err := markdownSourceFilePreviewQuery(version, versionParam)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(version) != "" {
req.QueryParams = larkcore.QueryParams{
"version": []string{strings.TrimSpace(version)},
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: query,
}
resp, err := runtime.DoAPIStream(ctx, req)
@@ -248,6 +254,58 @@ func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeCon
return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil
}
func markdownSourceFilePreviewQuery(version, versionParam string) (larkcore.QueryParams, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
query := larkcore.QueryParams{
"preview_type": []string{markdownSourceFilePreviewType},
}
if version != "" {
query["version"] = []string{version}
}
return query, nil
}
func markdownSourceFilePreviewDryRunParams(version, versionParam string) (map[string]interface{}, error) {
if err := validateMarkdownSourceFilePreviewVersion(version, versionParam); err != nil {
return nil, err
}
params := map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
}
if version != "" {
params["version"] = version
}
return params, nil
}
func markdownSourceFilePreviewDryRunParamsForValidatedVersion(version, versionParam string) map[string]interface{} {
params, err := markdownSourceFilePreviewDryRunParams(version, versionParam)
if err != nil {
// Shortcut validation runs before DryRun. If a caller bypasses that
// contract, preserve the supplied value instead of silently dropping it.
params = map[string]interface{}{
"preview_type": markdownSourceFilePreviewType,
"version": version,
}
}
return params
}
func validateMarkdownSourceFilePreviewVersion(version, flagName string) error {
if version == "" {
return nil
}
if strings.TrimSpace(version) != "" {
return nil
}
if flagName == "" {
flagName = "--version"
}
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
}
func markdownDryRunFileField(spec markdownUploadSpec) string {
if spec.FilePath != "" {
return "@" + spec.FilePath

View File

@@ -112,9 +112,8 @@ func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffS
}
func validateMarkdownDiffVersionValue(value, flagName string) error {
value = strings.TrimSpace(value)
if value == "" {
return markdownValidationParamError(flagName, "%s cannot be empty", flagName)
if err := validateMarkdownSourceFilePreviewVersion(value, flagName); err != nil {
return err
}
if !markdownDiffVersionRe.MatchString(value) {
return markdownValidationParamError(flagName, "%s must be a numeric version string", flagName)
@@ -134,31 +133,33 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
switch markdownDiffMode(spec) {
case markdownDiffModeRemoteVsLocal:
if spec.FromVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the specified remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the specified remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
}
dry.Set("local_file", spec.FilePath)
dry.Set("mode", markdownDiffModeRemoteVsLocal)
default:
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the base remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the base remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.FromVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.FromVersion, "--from-version"))
if spec.ToVersion != "" {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the target remote Markdown version").
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the target remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"version": spec.ToVersion})
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion(spec.ToVersion, "--to-version"))
} else {
dry.GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[2] Download the latest remote Markdown version").
Set("file_token", spec.FileToken)
dry.GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the latest remote Markdown source file preview artifact").
Set("file_token", spec.FileToken).
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", ""))
}
dry.Set("mode", markdownDiffModeRemoteVsRemote)
}
@@ -166,8 +167,8 @@ func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI {
return dry
}
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version)
func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version, versionParam string) (string, string, error) {
resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version, versionParam)
if err != nil {
return "", "", err
}
@@ -446,8 +447,8 @@ var MarkdownDiff = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownDiffSpec(runtime, markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
Format: runtime.Format,
@@ -456,8 +457,8 @@ var MarkdownDiff = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return markdownDiffDryRun(markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
})
@@ -465,8 +466,8 @@ var MarkdownDiff = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownDiffSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FromVersion: strings.TrimSpace(runtime.Str("from-version")),
ToVersion: strings.TrimSpace(runtime.Str("to-version")),
FromVersion: runtime.Str("from-version"),
ToVersion: runtime.Str("to-version"),
FilePath: strings.TrimSpace(runtime.Str("file")),
ContextLines: runtime.Int("context-lines"),
}
@@ -487,7 +488,7 @@ var MarkdownDiff = common.Shortcut{
} else {
fromLabel += "@latest"
}
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
if err != nil {
return err
}
@@ -499,17 +500,17 @@ var MarkdownDiff = common.Shortcut{
}
default:
fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion)
_, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion, "--from-version")
if err != nil {
return err
}
if spec.ToVersion != "" {
toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion)
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion, "--to-version")
} else {
toLabel = "b/" + spec.FileToken + "@latest"
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "")
_, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "", "")
}
if err != nil {
return err

View File

@@ -48,6 +48,73 @@ func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) {
}
}
func TestMarkdownDiffRejectsBlankVersion(t *testing.T) {
tests := []struct {
name string
args []string
wantParam string
}{
{
name: "from version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", " \t",
"--file", "./local.md",
},
wantParam: "--from-version",
},
{
name: "to version",
args: []string{
"+diff",
"--file-token", "box_md_diff",
"--from-version", "7633658129540910621",
"--to-version", " ",
},
wantParam: "--to-version",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownDiff, tt.args, f, stdout)
requireMarkdownValidationParam(t, err, tt.wantParam)
if !strings.Contains(err.Error(), "cannot be empty") {
t.Fatalf("expected empty version validation error, got %v", err)
}
})
}
}
func TestMarkdownSourceFilePreviewParamsValidateAndPreserveVersion(t *testing.T) {
version := " 7633658129540910621 "
query, err := markdownSourceFilePreviewQuery(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewQuery() error: %v", err)
}
if got := query["version"]; len(got) != 1 || got[0] != version {
t.Fatalf("query version = %#v, want original %q", got, version)
}
params, err := markdownSourceFilePreviewDryRunParams(version, "--from-version")
if err != nil {
t.Fatalf("markdownSourceFilePreviewDryRunParams() error: %v", err)
}
if got := params["version"]; got != version {
t.Fatalf("dry-run version = %#v, want original %q", got, version)
}
_, err = markdownSourceFilePreviewQuery(" \n", "--from-version")
requireMarkdownValidationParam(t, err, "--from-version")
_, err = markdownSourceFilePreviewDryRunParams(" \t", "--to-version")
requireMarkdownValidationParam(t, err, "--to-version")
}
func TestMarkdownDiffMissingVersionAndFileNamesCandidateParams(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
@@ -79,7 +146,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta\n"),
Headers: http.Header{
@@ -88,7 +155,7 @@ func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
Status: 200,
RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"),
Headers: http.Header{
@@ -151,7 +218,7 @@ func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n\nhello old\n"),
Headers: http.Header{
@@ -191,7 +258,7 @@ func TestMarkdownDiffRejectsOversizedRemoteContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: bytes.Repeat([]byte("x"), markdownDiffMaxContentBytes+1),
})
@@ -218,7 +285,7 @@ func TestMarkdownDiffRejectsOversizedLocalContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -337,7 +404,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"),
Headers: http.Header{
@@ -346,7 +413,7 @@ func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) {
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910628",
Status: 200,
RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"),
Headers: http.Header{
@@ -398,13 +465,13 @@ func TestMarkdownDiffNoChangesPretty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16&version=7633658129540910621",
Status: 200,
RawBody: []byte("# Title\n"),
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_diff/download",
URL: "/open-apis/drive/v1/medias/box_md_diff/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# Title\n"),
})
@@ -445,8 +512,11 @@ func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") {
t.Fatalf("dry-run missing download call: %s", stdout.String())
if !strings.Contains(stdout.String(), "/open-apis/drive/v1/medias/box_md_diff/preview_download") {
t.Fatalf("dry-run missing source preview download call: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"preview_type": "16"`) {
t.Fatalf("dry-run missing source_file preview_type: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) {
t.Fatalf("dry-run missing local file metadata: %s", stdout.String())

View File

@@ -5,14 +5,10 @@ package markdown
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -47,8 +43,9 @@ var MarkdownFetch = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
dry := common.NewDryRunAPI().
Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("download markdown source file preview artifact bytes; when --output is omitted the CLI returns content as UTF-8 text").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Set("file_token", runtime.Str("file-token"))
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath)
@@ -61,12 +58,9 @@ var MarkdownFetch = common.Shortcut{
fileToken := strings.TrimSpace(runtime.Str("file-token"))
outputPath := strings.TrimSpace(runtime.Str("output"))
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
resp, err := openMarkdownDownload(ctx, runtime, fileToken)
if err != nil {
return wrapMarkdownDownloadError(err)
return err
}
defer resp.Body.Close()

View File

@@ -62,8 +62,9 @@ var MarkdownPatch = common.Shortcut{
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[1] Download the current Markdown source file preview artifact").
Params(markdownSourceFilePreviewDryRunParamsForValidatedVersion("", "")).
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").

View File

@@ -85,9 +85,12 @@ func TestMarkdownPatchDryRunLiteral(t *testing.T) {
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
if got := dry.API[0].URL; got != "/open-apis/drive/v1/medias/box_md_patch/preview_download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[0].Params["preview_type"]; got != markdownSourceFilePreviewType {
t.Fatalf("download preview_type = %#v", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
@@ -120,7 +123,7 @@ func TestMarkdownPatchDryRunRegex(t *testing.T) {
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown source file preview artifact") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
@@ -144,7 +147,7 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -187,7 +190,7 @@ func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
})
@@ -224,7 +227,7 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
@@ -299,7 +302,7 @@ func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
@@ -360,7 +363,7 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
@@ -429,7 +432,7 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("hello world\n"),
})
@@ -478,7 +481,7 @@ func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
URL: "/open-apis/drive/v1/medias/box_md_patch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("hello\n"),
})
@@ -509,9 +512,10 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Desc string `json:"desc"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}

View File

@@ -1984,7 +1984,7 @@ func TestMarkdownFetchReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2050,7 +2050,7 @@ func TestMarkdownFetchPrettyReturnsContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2078,7 +2078,7 @@ func TestMarkdownFetchSavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2122,7 +2122,7 @@ func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2151,7 +2151,7 @@ func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2189,7 +2189,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2226,7 +2226,7 @@ func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testi
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2260,7 +2260,7 @@ func TestMarkdownFetchPrettySavesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{
@@ -2295,7 +2295,7 @@ func TestMarkdownFetchSaveFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_fetch/download",
URL: "/open-apis/drive/v1/medias/box_md_fetch/preview_download?preview_type=16",
Status: 200,
RawBody: []byte("# hello\n"),
Headers: map[string][]string{

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.3
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。"
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive认证/授权转 lark-shared。"
metadata:
requires:
bins: ["lark-cli"]
@@ -23,14 +23,15 @@ metadata:
不要使用本 skill
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`
- 把本地 Excel / CSV / `.base` 导入成 Base`lark-drive +import --type bitable`
- 把本地文件导入成 Base或将 Base 导出为本地文件,转 `lark-drive`
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参。
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先`lark-cli drive +import --type bitable`导入完成后再回到 Base 命令。
- 本地文件与 Base 之间的导入/导出`lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 先获取 Base Token 和所需 ID
@@ -49,6 +50,7 @@ metadata:
|---|---|---|
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema写入后报告新 Base 标识和 `permission_grant` |
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow资源内容继续用对应命令 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
@@ -63,8 +65,9 @@ metadata:
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md)删除前确认目标表单 |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list``+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`删除前确认目标表单 |
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
@@ -116,6 +119,9 @@ metadata:
## 表单与视图细节
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list``+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id``+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`
- 表单问题由数据表字段承载question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-questions-update` 是题目配置全量覆盖,不是 patch未传字段会回落默认值传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`

View File

@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1.`+form-questions-list` 查看现有问题
2. 确认要添加的问题内容
3. 执行命令并报告新建的问题 ID
1.确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`
2. `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
## 参考

View File

@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Goal | Command | Notes |
|------|---------|-------|
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
## Required order
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
## Safety boundaries
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.

View File

@@ -154,12 +154,34 @@
"table_rule_map": {
"订单表": {
"perm": "edit",
"view_rule": { "..." : "..." },
"record_rule": { "..." : "..." },
"field_rule": { "..." : "..." }
"view_rule": {
"allow_edit": true,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": ["add", "delete"],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_edit"
}
},
"用户表": {
"perm": "read_only"
"perm": "read_only",
"view_rule": {
"allow_edit": false,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": [],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_read"
}
},
"内部表": {
"perm": "no_perm"
}
}
}
@@ -172,7 +194,11 @@
| `record_rule` | RecordRule | 记录权限配置 |
| `field_rule` | FieldRule | 字段权限配置 |
**注意**: 当 `perm``no_perm` 时,`view_rule``record_rule``field_rule` 均无须再设置。
**`+role-create` 硬约束**:
-`perm``no_perm` 时,不要设置 `view_rule``record_rule``field_rule`
-`perm` 为其他值时,必须同时提供完整的 `view_rule``record_rule``field_rule`,缺少任意一项都会导致创建失败。
- `+role-update` 是 delta merge只提交要修改的字段不要为局部更新补造未变更配置。
---

View File

@@ -43,7 +43,7 @@ metadata:
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`
- 用户要查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物,使用 `lark-cli drive +preview`
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要导出云文档时,优先使用 `lark-cli drive +export --url '<文档 URL>' --file-extension <格式>`详细参数、Wiki token 和错误码处理见 [`references/lark-drive-export.md`](references/lark-drive-export.md)。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
@@ -121,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件 PDF / HTML / 文本 / 图片等预览产物。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件内容,或者查看文件可用预览格式并获取 PDF / HTML / 文本 / 图片等转换预览产物。 |
| [`+cover`](references/lark-drive-cover.md) | 查看或下载文件封面图规格。 |
| [`+status`](references/lark-drive-status.md) | 比较本地目录与 Drive 文件夹差异;默认按 SHA-256 精确比较,`--quick` 使用修改时间近似比较。 |
| [`+pull`](references/lark-drive-pull.md) | 从 Drive 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |

View File

@@ -70,7 +70,7 @@ API 成功时返回空 `data`(仅 `code: 0, msg: "success"`),对应 CLI
## 与 wiki URL 的关系
传入 `/wiki/<node_token>`shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),先使用与后续权限申请相同的身份调用 `wiki +node-get --node-token '<wiki_url>' --as user --format json`(下游使用 bot 时两步都改为 `--as bot`),读取 `data.obj_token``data.obj_type`,再 bare `obj_token` 传给 `--token`、把真实 `obj_type` 传给 `--type`(例如 `data.obj_type``docx` 时使用 `--type docx`
传入 `/wiki/<node_token>`shortcut 会直接用 `node_token` 作为路径参数并以 `type=wiki` 调用接口。如果需要先把 wiki 节点解析成 `obj_token`(例如想显式对底层 docx 申请),自行先调 `wiki spaces get_node``obj_token + obj_type`,再 bare token + `--type docx` 调本命令
## 参考

View File

@@ -25,6 +25,10 @@ https://xxx.feishu.cn/drive/file/boxbc_xxx
file_token
```
## 排障
- 如果返回 `HTTP 403`,可以使用 [lark-drive-preview](lark-drive-preview.md) 下载源文件产物。
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令

View File

@@ -2,15 +2,24 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、权限处理和安全规则。
列出或下载 Drive 文件可用的预览产物。这个 shortcut 不猜测默认类型:
查看或下载 Drive 文件内容,或列出并获取文件可用的预览产物。这个 shortcut 不猜测默认类型:
- 如果只需要查看或下载文件内容,或不关心 PDF/text/image 等转换预览,优先使用 `--type source_file --output <path>`
- 只想看候选项时,用 `--list-only`
- 如果需要服务端生成的预览效果,例如 doc/docx 的 PDF 版式预览,先用 `--list-only` 查看候选项,再按候选项选择 `--type pdf` / `text` / `image`
- 想下载时,必须显式传 `--type``--output`
- 如果 `--list-only` 没有可用预览候选项,或错误提示明确建议使用 `--type source_file`,可以改用 `--type source_file --output <path>` 查看文件内容资源不存在、token 无效等终态错误需要先修正输入
- 如果某个候选项还在生成中,会返回结构化错误并提示先重新 `--list-only`
### 命令
```bash
# 查看文件内容
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
--type source_file \
--output ./artifacts/source
# 列出可用预览候选项
lark-cli drive +preview \
--file-token "<FILE_TOKEN>" \
@@ -78,6 +87,7 @@ lark-cli drive +preview \
- 不传 `--list-only` 时,必须显式传 `--type``--output`
- 不会隐式选择“第一个候选项”作为默认下载目标
- `--type source_file` 用于查看文件内容,不依赖 `--list-only` 返回的候选项;它适合读取或保存源内容,不等同于 PDF/text/image 等转换预览
- 候选项状态来自后端 `preview_status` 枚举,例如 `READY` / `PROCESSING` / `FAILED` / `NO_SUPPORT`
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名

View File

@@ -104,17 +104,17 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, auto-pagination, --exclude-muted (user-only) |
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range, --order asc|desc sorting, auto-pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, auto-pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
| [`+messages-mget`](references/lark-im-messages-mget.md) | Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies |
| [`+messages-reply`](references/lark-im-messages-reply.md) | Reply to a message (supports thread replies); user/bot; supports text/markdown/post/media replies, reply-in-thread, idempotency key |
| [`+messages-resources-download`](references/lark-im-messages-resources-download.md) | Download images/files from a message; user/bot; supports automatic chunked download for large files (8MB chunks), auto-detects file extension from Content-Type |
| [`+messages-search`](references/lark-im-messages-search.md) | Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, supports auto-pagination via `--page-all` / `--page-limit`, enriches results via batched mget and chats batch_query |
| [`+messages-send`](references/lark-im-messages-send.md) | Send a message to a chat or direct message; user/bot; sends to chat-id or user-id with text/markdown/post/media, supports idempotency key |
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination |
| [`+threads-messages-list`](references/lark-im-threads-messages-list.md) | List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports --order asc|desc sorting, auto-pagination |
| [`+flag-create`](references/lark-im-flag-create.md) | 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) |
| [`+flag-cancel`](references/lark-im-flag-cancel.md) | 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 |
| [`+flag-list`](references/lark-im-flag-list.md) | List bookmarks; user-only; auto-enriches feed-type thread entries with message content; `--page-all` is capped by `--page-limit` (default 20, max 1000), and `has_more=true` means the result is incomplete |

View File

@@ -23,6 +23,9 @@ lark-cli im +chat-list --page-size 50
# Pagination
lark-cli im +chat-list --page-token "xxx"
# Fetch multiple pages automatically, up to 10 pages by default
lark-cli im +chat-list --page-all
# Drop muted chats (user identity only)
lark-cli im +chat-list --exclude-muted
@@ -51,12 +54,16 @@ lark-cli im +chat-list --as user --types p2p
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
## Output Fields
| Field | Description |
@@ -156,7 +163,7 @@ done
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | Use an integer between 1 and 100 |
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |

View File

@@ -2,7 +2,7 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
List the members of a chat. Users and bots are returned in **separate buckets**`users[]` and `bots[]` — with per-bucket totals (`user_total` / `bot_total`). Use `--member-types` to return only one kind.
List the members of a chat. Users and bots are returned in **separate buckets**`users[]` and `bots[]` — with per-bucket totals (`user_total` / `bot_total`). Use `--member-types` to return only one kind. `all` explicitly selects the default unfiltered behavior; plural `users` and `bots` are accepted as `user` and `bot`.
This skill maps to the shortcut: `lark-cli im +chat-members-list` (internally calls `GET /open-apis/im/v1/chats/{chat_id}/members/list`).
@@ -16,6 +16,9 @@ lark-cli im +chat-members-list --chat-id oc_xxx
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user
lark-cli im +chat-members-list --chat-id oc_xxx --member-types user,bot
# Explicitly request all member types (same request as omitting --member-types)
lark-cli im +chat-members-list --chat-id oc_xxx --member-types all
# Walk every page (capped by --page-limit; 0 = unlimited)
lark-cli im +chat-members-list --chat-id oc_xxx --page-all --page-limit 0
@@ -32,7 +35,7 @@ lark-cli im +chat-members-list --chat-id oc_xxx --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--chat-id <id>` | Yes | `oc_xxx` | Target chat |
| `--member-types <strings>` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all |
| `--member-types <strings>` | No | `user`, `bot`, `all` (comma-separated or repeated) | Member types to return. Omitted or `all` = no filter. `users` and `bots` are accepted as plural spellings. If `all` appears with another value, no filter is applied |
| `--member-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response |
| `--page-size <n>` | No | 1-100, default 20 | Results per page. With `--page-all` and no explicit `--page-size`, the max (100) is used automatically to minimize round-trips |
| `--page-token <token>` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) |
@@ -78,6 +81,6 @@ A truncated result is *not* fixable by paging further — it is a server-side ca
| Symptom | Root Cause | | Solution |
|---------|---------|---|---------|
| `--chat-id is required` | `--chat-id` omitted | | Provide the `oc_xxx` chat ID |
| `--page-size must be an integer between 1 and 100` | out of range | | Use 1-100 |
| `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both |
| `invalid --page-size 101: must be between 1 and 100` | out of range | | Use 1-100 |
| `--member-types contains invalid value` | value other than `user`, `bot`, `all`, `users`, or `bots` | | Use a supported singular, plural, or `all` |
| Permission denied | missing `im:chat.members:read` | | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:read"` |

View File

@@ -29,6 +29,9 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
# Pagination
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
# Fetch multiple pages automatically, up to 10 pages by default
lark-cli im +chat-messages-list --chat-id oc_xxx --page-all
# JSON output
lark-cli im +chat-messages-list --chat-id oc_xxx --format json
```
@@ -39,11 +42,13 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|------|------|------|
| `--chat-id <id>` | One of two | Specify the conversation by its chat_id directly (e.g., group chat `oc_xxx`) |
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
| `--start <time>` | No | Start time (ISO 8601 or date only) |
| `--end <time>` | No | End time (ISO 8601 or date only) |
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
| `--page-size <n>` | No | Page size (default 50, max 50) |
| `--start <time>` | No | Start time (ISO 8601 or date only). `--start-time` is an alias for `--start`; prefer the canonical flag |
| `--end <time>` | No | End time (ISO 8601 or date only). `--end-time` is an alias for `--end`; prefer the canonical flag |
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`). `--sort-order` is an alias for `--order`; prefer the canonical flag |
| `--page-size <n>` | No | Page size (default 50, max 50). `--limit` is an alias for `--page-size`; prefer the canonical flag |
| `--page-token <token>` | No | Pagination token |
| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted |
@@ -106,12 +111,14 @@ Each message contains:
## Pagination (`has_more` / `page_token`)
`im +chat-messages-list` returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
By default, `im +chat-messages-list` fetches one page. It returns `has_more` and `page_token` when more data is available. Use `--page-token` to continue:
```bash
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token <PAGE_TOKEN>
```
Use `--page-all` to fetch and merge multiple pages. `--page-limit` defaults to 10 and accepts values from 1 to 1000. If the command reaches this limit while the output still has `has_more=true`, the result is incomplete; resume with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
You can also fall back to the generic API:
```bash

View File

@@ -33,6 +33,9 @@ lark-cli im +chat-search --query "project" --page-size 10
# Pagination
lark-cli im +chat-search --query "project" --page-token "xxx"
# Fetch multiple pages automatically, up to 10 pages by default
lark-cli im +chat-search --query "project" --page-all
# JSON output
lark-cli im +chat-search --query "project" --format json
@@ -53,12 +56,16 @@ lark-cli im +chat-search --query "project" --dry-run
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
| `--page-token <token>` | No | - | Pagination token from the previous response |
| `--page-all` | No | - | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | 1-1000, default 10 | Maximum pages fetched by `--page-all` |
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request without executing it |
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
By default, the command fetches one page. With `--page-all`, it fetches and merges subsequent pages up to `--page-limit`. If the limit is reached while the output still has `has_more=true`, the result is incomplete; continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
> **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected).
## Output Fields
@@ -121,7 +128,7 @@ lark-cli im +messages-send --chat-id "$CHAT_ID" --text "Today's progress update"
|---------|---------|---------|
| `--query and --member-ids cannot both be empty` | Both were omitted | Provide at least `--query` or `--member-ids` |
| Empty results | No visible chats matched the keyword or filters | Relax the keyword or filters and try again |
| `--page-size must be an integer between 1 and 100` | page-size is out of range or not an integer | Use an integer between 1 and 100 |
| `invalid --page-size 101: must be between 1 and 100` | page-size is out of range | Use an integer between 1 and 100 |
| Permission denied (99991672) | The bot app does not have `im:chat:read` TAT permission enabled | Enable the permission for the app in the Open Platform console |
| Permission denied (99991679) with `--as user` | UAT is not authorized for `im:chat:read` | Run `lark-cli auth login --scope "im:chat:read"` |
| `Bot ability is not activated` (232025) | The app does not have bot capability enabled | Enable bot capability in the Open Platform console |

View File

@@ -10,7 +10,7 @@ Lists **one page** of the **current user's** feed shortcuts.
- Only **CHAT-type** shortcuts are exposed via OpenAPI today (others in the IDL are not yet whitelisted).
- The shortcut is a **thin one-page wrapper** — there is no built-in auto-pagination. Callers drive their own loop when they actually need to paginate.
- Server-side page size is controlled by the service; in normal use one page usually covers the list.
- Server-side page size is controlled by the service, so this command has no `--page-size` flag; in normal use one page usually covers the list.
- Pagination tokens are opaque. If a token is rejected because the shortcut list changed, restart by omitting `--page-token`.
## Commands

View File

@@ -30,7 +30,7 @@ lark-cli im +messages-mget --message-ids "om_aaa" --dry-run
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--message-ids <ids>` | Yes | At least one, max 50, `om_xxx` format, comma-separated | Message ID list |
| `--message-ids <ids>` | Yes | At least one, max 50, `om_xxx` format, comma-separated | Message ID list. `--message-id` is an alias for `--message-ids`; prefer the canonical flag |
| `--no-reactions` | No | — | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | — | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |

View File

@@ -2,10 +2,14 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`, both of which come directly from message content returned by `im +chat-messages-list`.
Download image or file resources from a message. Supports **automatic chunked download for large files** using HTTP Range requests. Resources are identified by the combination of `message_id` + `file_key`. For a known message ID, run `lark-cli im +messages-mget --message-ids om_xxx` and read the resource key from the returned message content: images use `img_xxx`, while files use `file_xxx`.
> **Note:** read-only message commands render resource keys in message content, but they do not download binaries automatically. Use this command whenever you need to fetch the actual image/file bytes or save them to a specific path.
To download every attachment from a message result or chat without supplying each `file_key`, use `lark-cli im +chat-messages-list --download-resources`.
There is no `--overwrite` flag. Saving to a path that already exists replaces that file atomically; use a different `--output` path to keep the existing file.
This skill maps to the shortcut: `lark-cli im +messages-resources-download` (internally calls `GET /open-apis/im/v1/messages/{message_id}/resources/{file_key}`).
## Commands
@@ -70,8 +74,8 @@ Different resource markers in message content correspond to different `file_key`
### Scenario: Extract and download an image from a message
```bash
# Step 1: Fetch messages and find one containing an image
lark-cli im +chat-messages-list --chat-id oc_xxx
# Step 1: Fetch the known message and find its image key
lark-cli im +messages-mget --message-ids om_xxx
# In the response you see: { "msg_type": "image", "content": "{\"image_key\":\"img_v3_xxx\"}" }
# Step 2: Download the image

View File

@@ -68,7 +68,7 @@ lark-cli im +messages-search --query "test" --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--query <text>` | No | Search keyword (may be empty when used with other filters) |
| `--query <text>` | No | Search keyword (may be empty when used with other filters). `--keyword` is an alias for `--query`; prefer the canonical flag |
| `--chat-id <id>` | No | Restrict to chat IDs, comma-separated (`oc_xxx,oc_yyy`) |
| `--sender <ids>` | No | Sender open_ids, comma-separated (`ou_xxx`) |
| `--include-attachment-type <type>` | No | Attachment filter: `file` / `image` / `video` / `link` |
@@ -79,7 +79,7 @@ lark-cli im +messages-search --query "test" --dry-run
| `--at-chatter-ids <ids>` | No | Filter by @mentioned user open_ids, comma-separated (`ou_xxx,ou_yyy`). Matched results also include messages that `@all` |
| `--start <time>` | No | Start time with local timezone offset required (e.g. `2026-03-24T00:00:00+08:00`) |
| `--end <time>` | No | End time with local timezone offset required (e.g. `2026-03-25T23:59:59+08:00`) |
| `--page-size <n>` | No | Page size (default 20, range 1-50) |
| `--page-size <n>` | No | Page size (default 20, range 1-50). `--limit` is an alias for `--page-size`; prefer the canonical flag |
| `--page-token <token>` | No | Pagination token for the next page |
| `--page-all` | No | Automatically paginate through all result pages (up to 40 pages) |
| `--page-limit <n>` | No | Max pages to fetch when auto-pagination is enabled (default 20, max 40). Setting it explicitly also enables auto-pagination |

View File

@@ -23,6 +23,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
# Pagination
lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
# Fetch multiple pages automatically, up to 10 pages by default
lark-cli im +threads-messages-list --thread omt_xxx --page-all
# Output format options
lark-cli im +threads-messages-list --thread omt_xxx --format pretty
lark-cli im +threads-messages-list --thread omt_xxx --format table
@@ -39,12 +42,14 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format) |
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format). `--thread-id` is an alias for `--thread`; prefer the canonical flag |
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
| `--page-size <n>` | No | Number of items per page (default 50, range 1-50) |
| `--page-token <token>` | No | Pagination token for the next page |
| `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` |
| `--page-limit <n>` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) |
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
| `--as <identity>` | No | Identity type: `user` (default) / `bot` |
| `--dry-run` | No | Print the request only, do not execute it |
@@ -61,8 +66,9 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
### 3. Pagination (`has_more` / `page_token`)
- When the result includes `has_more=true`, use `page_token` to fetch the next page
- If you need the complete thread, keep paginating; if you only need an overview, the first page is often enough
By default, the command fetches one page. When the result includes `has_more=true`, use `page_token` to fetch the next page, or add `--page-all` to fetch and merge subsequent pages automatically. `--page-limit` defaults to 10 and accepts values from 1 to 1000.
If automatic pagination reaches the limit while the output still has `has_more=true`, the result is incomplete. Continue with the returned `page_token`, or rerun with a larger `--page-limit`. An explicitly supplied `--page-token` takes precedence and fetches only that page even when `--page-all` is also present.
### 4. Recommended expansion strategy

View File

@@ -274,10 +274,10 @@ N. 结尾页:[结尾文案]
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先用 Wiki shortcut 查询节点,确认 `data.obj_type == "slides"`,再用 `data.obj_token` 作为真实 presentation ID。
知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先查询 wiki 节点,确认 `node.obj_type == "slides"`,再用 `node.obj_token` 作为真实 presentation ID。
```bash
lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
```
Shortcut `+replace-slide``+media-upload` 会自动解析 `/wiki/` URL手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。

View File

@@ -27,14 +27,14 @@ metadata:
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 Wiki 节点列表做局部遍历。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先确定下游成员操作的身份(默认 `user`;用户明确要求应用 / bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json`,从 `data.space_id` 获取空间 ID下游使用 bot 时将示例中的身份改为 `--as bot`。节点解析与后续成员操作必须使用相同身份
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>`先确定后续 `wiki +delete-space` 的身份(默认 `user`;明确要求 bot 视角时用 `bot`),再调用 `lark-cli wiki +node-get --node-token '<wiki_url>' --as user --format json`,读 `data.space_id`;下游使用 bot 时将示例中的身份改为 `--as bot`。解析和删除必须使用相同身份
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`
- 只知名称:`lark-cli wiki spaces list --format json`,边翻页边收集 items 并按 `name` 精确匹配;**一旦任一页累计到至少 1 条精确匹配就停止翻页**。只有当翻完所有页(`has_more=false`)仍无精确匹配时,才对已收集的全量 items 做宽松匹配(`name` trim 空格、大小写不敏感、子串包含)。
- **关键安全约束**:无论精确还是模糊,**无论命中 1 条还是多条,发起删除前都必须把候选(`name` + `space_id` + `description` + `space_type`)列给用户,由用户明确选定一个 `space_id` 再执行**。不要因为"只命中一条"就自动执行删除。
- 命中 0 条:停下来问用户是名称拼错了还是调用方无权限;**不要**自行改名字重试。
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki +node-get` 解析出 `data.space_id` 再传。
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token如果用户给的是 docx/sheet/base URL先用 `wiki +node-get --node-token <url>` 解析出 `node_token`
- `wiki +node-list` 命中 `invalid_parameters``not_found``permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
@@ -48,8 +48,6 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
获取或解析 Wiki 节点统一优先使用 `wiki +node-get`,包括只为获取 `space_id``node_token``obj_token``obj_type` 的中间步骤。只有当前 CLI 不提供该 shortcut或任务明确需要 shortcut 未输出的原始响应字段时,才回退到 `wiki spaces get_node`;回退前先运行 `lark-cli schema wiki.spaces.get_node`
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |

View File

@@ -117,16 +117,13 @@ dry-run 会展示两步调用链:
### 2. 只有知识库 URL`.../wiki/<token>`
先确定后续 `wiki +delete-space` 使用的身份:默认使用 `user`;用户明确要求应用 / bot 视角时使用 `bot`。下面展示默认 user 身份;下游使用 bot 时将两步都改为 `--as bot`。节点解析和删除必须使用相同身份。
```bash
lark-cli wiki +node-get \
--node-token '<wiki_url>' \
--as user \
lark-cli wiki spaces get_node \
--params '{"token":"<wiki_token>"}' \
--format json
```
读取 `data.space_id`。只有当前 CLI 不提供 `+node-get`,或必须读取 shortcut 未输出的原始字段时,才在查看 `lark-cli schema wiki.spaces.get_node` 后回退到原生命令
读取 `data.node.space_id`
### 3. 只有知识库名称

View File

@@ -55,3 +55,26 @@ func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}
func TestBaseFormListDryRun_UsesBaseAndTableIdentifiers(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-list",
"--base-token", "basXXXX",
"--table-id", "tblXXXX",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/basXXXX/tables/tblXXXX/forms")
assert.Contains(t, output, `"method": "GET"`)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseFormQuestionsCreateDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", `[{"type":"text","title":"Risk","required":true}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_x/questions", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.questions.0.type").String(), out)
require.Equal(t, "Risk", clie2e.DryRunGet(out, "api.0.body.questions.0.title").String(), out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.questions.0.required").Bool(), out)
}
func TestBaseFormQuestionsCreateDryRunRejectsInvalidInput(t *testing.T) {
setBaseDryRunConfigEnv(t)
tests := []struct {
name string
input string
message string
}{
{name: "malformed JSON", input: "{", message: "must be a valid JSON array"},
{name: "non-array JSON", input: "{}", message: "must be a valid JSON array"},
{name: "null", input: "null", message: "must be a non-null JSON array"},
{name: "non-object item", input: "[1]", message: "item 1 must be an object"},
{name: "missing title", input: `[{"type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "blank title", input: `[{"title":" ","type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "missing type", input: `[{"title":"Risk"}]`, message: `item 1 must include a non-empty string "type"`},
{name: "non-string type", input: `[{"title":"Risk","type":1}]`, message: `item 1 must include a non-empty string "type"`},
{name: "more than ten items", input: `[{},{},{},{},{},{},{},{},{},{},{}]`, message: "must contain at most 10 items"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", tt.input,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--questions", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.message)
require.Empty(t, result.Stdout)
})
}
}
func TestBaseFormQuestionsCreateHelpShowsExistingQuestionGuard(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+form-questions-create", "--help"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, strings.ToLower(result.Stdout), "form may already contain questions")
require.Contains(t, result.Stdout, "+form-questions-list")
require.Contains(t, result.Stdout, "+form-questions-update")
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"path/filepath"
"runtime"
"testing"
"github.com/larksuite/cli/internal/vfs"
"github.com/stretchr/testify/require"
)
func TestBaseSkillRoutesFileImportExportToDrive(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok)
skillPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "skills", "lark-base", "SKILL.md")
content, err := vfs.ReadFile(skillPath)
require.NoError(t, err)
skill := string(content)
require.Contains(t, skill, "文件导入/导出转 lark-drive")
require.Contains(t, skill, "本地文件与 Base 之间的导入/导出转 `lark-drive`")
require.Contains(t, skill, "在线复制走 `+base-copy`")
require.NotContains(t, skill, "--only-schema")
require.NotContains(t, skill, "--output-dir")
require.NotContains(t, skill, "/tmp/")
}

View File

@@ -1,17 +1,21 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 78 leaf commands
- Covered: 22
- Coverage: 28.2%
- Denominator: 87 leaf commands
- Covered: 28
- Coverage: 32.2%
## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help.
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
- TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
@@ -34,6 +38,7 @@
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
| ✓ | base +dashboard-block-get-data | shortcut | base_dashboard_block_get_data_dryrun_test.go | `--base-token`; `--dashboard-id`; `--block-id`; dry-run only | request shape and identifier handling |
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
@@ -50,12 +55,14 @@
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
| ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape |
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
| | base +form-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
@@ -64,6 +71,7 @@
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered |
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
@@ -78,6 +86,8 @@
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
| ✕ | base +title-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +url-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
| ✕ | base +view-get | shortcut | | none | view workflows not covered |

View File

@@ -93,6 +93,55 @@ func TestDrivePreviewDryRun_Download(t *testing.T) {
}
}
// TestDrivePreviewDryRun_SourceFile verifies source_file mode maps to a direct
// source artifact download request.
func TestDrivePreviewDryRun_SourceFile(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", "fileDryRunPreview",
"--type", "source_file",
"--version", "12",
"--output", "./artifacts/source",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
t.Fatalf("url=%q, want preview download endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.preview_type").String(); got != "16" {
t.Fatalf("preview_type=%q, want 16\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.version").String(); got != "12" {
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "requested_type").String(); got != "source_file" {
t.Fatalf("requested_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "selected_type_code").String(); got != "16" {
t.Fatalf("selected_type_code=%q, want 16\nstdout:\n%s", got, out)
}
}
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
// download mode.
func TestDriveCoverDryRun_Download(t *testing.T) {

View File

@@ -35,6 +35,41 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
t.Run("source file download", func(t *testing.T) {
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--type", "source_file",
"--output", "./artifacts/report-source",
},
WorkDir: downloadDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
stdout := downloadResult.Stdout
if gjson.Get(stdout, "data.requested_type").Exists() {
t.Fatalf("requested_type should be omitted from execute output\nstdout:\n%s", stdout)
}
if got := gjson.Get(stdout, "data.selected_type").String(); got != "source_file" {
t.Fatalf("selected_type=%q, want source_file\nstdout:\n%s", got, stdout)
}
if gjson.Get(stdout, "data.selected_type_code").Exists() {
t.Fatalf("selected_type_code should be omitted from execute output\nstdout:\n%s", stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "source file preview should return output_path")
data, readErr := os.ReadFile(outputPath)
require.NoError(t, readErr)
if string(data) != sourceContent {
t.Fatalf("source file preview content=%q want %q", string(data), sourceContent)
}
})
t.Run("preview list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIMChatSearchTypesGroupDryRunMatchesChatModesGroup(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
typesResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", "group", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
typesResult.AssertExitCode(t, 0)
canonicalResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--chat-modes", "group", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
canonicalResult.AssertExitCode(t, 0)
require.JSONEq(t, canonicalResult.Stdout, typesResult.Stdout)
require.Equal(t, "default", clie2e.DryRunGet(typesResult.Stdout, "api.0.body.filter.chat_modes.0").String())
require.Equal(t, 1, strings.Count(typesResult.Stderr, "note: --types on +chat-search maps to --chat-modes"))
require.NotContains(t, typesResult.Stdout, "maps to --chat-modes")
}
func TestIMChatSearchCanonicalChatModesWinsOverTypes(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", "p2p", "--chat-modes", "topic", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "thread", clie2e.DryRunGet(result.Stdout, "api.0.body.filter.chat_modes.0").String())
require.NotContains(t, result.Stderr, "--types on +chat-search maps")
}
func TestIMChatSearchTypesValidationErrors(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
for _, typesValue := range []string{"p2p", "group,p2p"} {
t.Run(typesValue, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", typesValue, "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
message := gjson.Get(result.Stderr, "error.message").String()
require.Contains(t, message, "service does not support p2p")
require.Contains(t, message, "im +chat-list --types p2p")
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String())
require.Equal(t, "--types", gjson.Get(result.Stderr, "error.param").String())
})
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-search", "--query", "team", "--types", "xxx", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
message := gjson.Get(result.Stderr, "error.message").String()
require.Contains(t, message, "--chat-modes (group|topic)")
require.Contains(t, message, "--search-types (private|external|public_joined|public_not_joined)")
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String())
require.Equal(t, "--types", gjson.Get(result.Stderr, "error.param").String())
}
func TestIMChatSearchTypesHiddenFromHelp(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: []string{"im", "+chat-search", "--help"}})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.False(t, regexp.MustCompile(`(?m)^\s+--types(?:\s|$)`).MatchString(result.Stdout), "--types leaked into help:\n%s", result.Stdout)
}

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIMFlagAliasesDryRun(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
aliasArgs []string
canonicalArgs []string
defaultAs string
notes []string
}{
{
name: "chat messages",
aliasArgs: []string{
"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--start-time", "2026-07-27 00:00:00 +08:00",
"--end-time", "1785254400",
"--sort-order", "asc", "--limit", "25", "--no-reactions", "--dry-run",
},
canonicalArgs: []string{
"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--start", "2026-07-27 00:00:00 +08:00",
"--end", "1785254400",
"--order", "asc", "--page-size", "25", "--no-reactions", "--dry-run",
},
defaultAs: "bot",
notes: []string{
"note: --start-time is an alias for --start",
"note: --end-time is an alias for --end",
"note: --sort-order is an alias for --order",
"note: --limit is an alias for --page-size",
},
},
{
name: "thread id",
aliasArgs: []string{"im", "+threads-messages-list", "--thread-id", "omt_dryrun", "--no-reactions", "--dry-run"},
canonicalArgs: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun", "--no-reactions", "--dry-run"},
defaultAs: "bot",
notes: []string{"note: --thread-id is an alias for --thread"},
},
{
name: "message id",
aliasArgs: []string{"im", "+messages-mget", "--message-id", "om_dryrun", "--no-reactions", "--dry-run"},
canonicalArgs: []string{"im", "+messages-mget", "--message-ids", "om_dryrun", "--no-reactions", "--dry-run"},
defaultAs: "bot",
notes: []string{"note: --message-id is an alias for --message-ids"},
},
{
name: "message search",
aliasArgs: []string{"im", "+messages-search", "--keyword", "project", "--limit", "30", "--no-reactions", "--dry-run"},
canonicalArgs: []string{"im", "+messages-search", "--query", "project", "--page-size", "30", "--no-reactions", "--dry-run"},
defaultAs: "user",
notes: []string{
"note: --keyword is an alias for --query",
"note: --limit is an alias for --page-size",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aliasResult, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.aliasArgs, DefaultAs: tt.defaultAs})
require.NoError(t, err)
aliasResult.AssertExitCode(t, 0)
canonicalResult, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.canonicalArgs, DefaultAs: tt.defaultAs})
require.NoError(t, err)
canonicalResult.AssertExitCode(t, 0)
require.JSONEq(t, canonicalResult.Stdout, aliasResult.Stdout)
require.NotContains(t, aliasResult.Stdout, "is an alias for")
for _, note := range tt.notes {
require.Equal(t, 1, strings.Count(aliasResult.Stderr, note), "stderr:\n%s", aliasResult.Stderr)
}
})
}
}
func TestIMFlagAliasesHiddenFromHelp(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
command string
aliases []string
}{
{"+chat-messages-list", []string{"start-time", "end-time", "sort-order", "limit"}},
{"+threads-messages-list", []string{"thread-id"}},
{"+messages-mget", []string{"message-id"}},
{"+messages-search", []string{"keyword", "limit"}},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: []string{"im", tt.command, "--help"}})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, alias := range tt.aliases {
pattern := regexp.MustCompile(`(?m)^\s+--` + regexp.QuoteMeta(alias) + `(?:\s|$)`)
require.False(t, pattern.MatchString(result.Stdout), "--%s leaked into help:\n%s", alias, result.Stdout)
}
})
}
}
func setFlagAliasDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "alias_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "alias_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
}
// A hidden alias with an invalid value must not fail the command when the
// canonical flag is present — the canonical flag wins and the alias is
// ignored entirely, including its value. This exercises the full runner path
// (declared enums are framework-validated before command Validate runs, so
// alias value sets must not be declared as enums).
func TestIMChatMessagesListCanonicalOrderIgnoresInvalidAliasValue(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--order", "asc", "--sort-order", "unexpected", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "ByCreateTimeAsc", clie2e.DryRunGet(result.Stdout, "api.0.params.sort_type").String())
require.NotContains(t, result.Stderr, "alias")
// Alias in effect on its own: the value set is enforced and the error is
// attributed to the alias flag.
rejected, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun",
"--sort-order", "unexpected", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
rejected.AssertExitCode(t, 2)
require.Empty(t, rejected.Stdout)
require.Equal(t, `invalid value "unexpected" for --sort-order, allowed: asc, desc`, gjson.Get(rejected.Stderr, "error.message").String())
require.Equal(t, "--sort-order", gjson.Get(rejected.Stderr, "error.param").String())
}
// Alias-supplied invalid values must be attributed to the flag the caller
// actually typed — never to the canonical flag it maps to.
func TestIMAliasErrorsNameTheTypedFlag(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
cases := []struct {
name string
args []string
wantParam string
wantMsg string
}{
{
name: "start-time",
args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun", "--start-time", "bad-time", "--dry-run"},
wantParam: "--start-time",
wantMsg: "--start-time: cannot parse time",
},
{
name: "thread-id",
args: []string{"im", "+threads-messages-list", "--thread-id", "not-a-thread", "--dry-run"},
wantParam: "--thread-id",
wantMsg: `invalid --thread-id "not-a-thread"`,
},
{
name: "message-id",
args: []string{"im", "+messages-mget", "--message-id", "not-om", "--dry-run"},
wantParam: "--message-id",
wantMsg: `invalid message ID "not-om"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tc.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Empty(t, result.Stdout)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tc.wantMsg)
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.param").String())
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String())
})
}
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"net/http"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestIM_ListPageAllDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
method string
path string
}{
{
name: "chat-messages-list",
args: []string{"im", "+chat-messages-list", "--chat-id", "oc_dryrun"},
method: http.MethodGet,
path: "/open-apis/im/v1/messages",
},
{
name: "threads-messages-list",
args: []string{"im", "+threads-messages-list", "--thread", "omt_dryrun"},
method: http.MethodGet,
path: "/open-apis/im/v1/messages",
},
{
name: "chat-list",
args: []string{"im", "+chat-list"},
method: http.MethodGet,
path: "/open-apis/im/v1/chats",
},
{
name: "chat-search",
args: []string{"im", "+chat-search", "--query", "team"},
method: http.MethodPost,
path: "/open-apis/im/v2/chats/search",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
args := append([]string{}, tc.args...)
args = append(args, "--page-all", "--page-limit", "3", "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, tc.method, clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, tc.path, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "Auto-paginates through all pages (capped by --page-limit when > 0)", clie2e.DryRunGet(out, "description").String(), "stdout:\n%s", out)
})
}
}

View File

@@ -0,0 +1,123 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIMChatMembersListMemberTypesCompatibilityDryRun(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
run := func(t *testing.T, value string) *clie2e.Result {
t.Helper()
args := []string{"im", "+chat-members-list", "--chat-id", "oc_dryrun"}
if value != "" {
args = append(args, "--member-types", value)
}
args = append(args, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result
}
omitted := run(t, "")
for _, value := range []string{"all", "ALL", "all,user"} {
t.Run(value, func(t *testing.T) {
result := run(t, value)
require.JSONEq(t, omitted.Stdout, result.Stdout)
require.Contains(t, result.Stderr, "means no filter (same as omitting the flag)")
require.NotContains(t, result.Stdout, "means no filter")
})
}
for _, tc := range []struct {
compat string
canonical string
wantNote string
}{
{compat: "users", canonical: "user", wantNote: `note: --member-types "users" is accepted as "user"`},
{compat: "bots", canonical: "bot", wantNote: `note: --member-types "bots" is accepted as "bot"`},
{compat: "Users", canonical: "user", wantNote: `note: --member-types "Users" is accepted as "user"`},
} {
t.Run(tc.compat, func(t *testing.T) {
result := run(t, tc.compat)
canonical := run(t, tc.canonical)
require.JSONEq(t, canonical.Stdout, result.Stdout)
require.Equal(t, 1, strings.Count(result.Stderr, tc.wantNote))
require.NotContains(t, result.Stdout, "is accepted as")
})
}
invalid, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-members-list", "--chat-id", "oc_dryrun", "--member-types", "xxx", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
invalid.AssertExitCode(t, 2)
require.Empty(t, invalid.Stdout)
require.Equal(t, `invalid --member-types value "xxx": expected one of user, bot, all`, gjson.Get(invalid.Stderr, "error.message").String())
require.Equal(t, "validation", gjson.Get(invalid.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(invalid.Stderr, "error.subtype").String())
require.Equal(t, "--member-types", gjson.Get(invalid.Stderr, "error.param").String())
}
func TestIMMessagesResourcesDownloadRequiredFlagsDryRun(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
missing, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-resources-download", "--message-id", "om_dryrun", "--dry-run"},
DefaultAs: "bot",
})
require.NoError(t, err)
missing.AssertExitCode(t, 2)
require.Empty(t, missing.Stdout)
require.Equal(t, "--file-key and --type are required", gjson.Get(missing.Stderr, "error.message").String())
hint := gjson.Get(missing.Stderr, "error.hint").String()
require.Contains(t, hint, "+messages-mget")
require.Contains(t, hint, "--download-resources")
require.Equal(t, int64(2), gjson.Get(missing.Stderr, "error.params.#").Int())
require.Equal(t, "validation", gjson.Get(missing.Stderr, "error.type").String())
require.Equal(t, "invalid_argument", gjson.Get(missing.Stderr, "error.subtype").String())
require.Equal(t, "--file-key", gjson.Get(missing.Stderr, "error.params.0.name").String())
require.Equal(t, "--type", gjson.Get(missing.Stderr, "error.params.1.name").String())
complete, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+messages-resources-download", "--message-id", "om_dryrun",
"--file-key", "img_dryrun", "--type", "image", "--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
complete.AssertExitCode(t, 0)
require.Equal(t, "GET", clie2e.DryRunGet(complete.Stdout, "api.0.method").String())
require.Equal(t, "/open-apis/im/v1/messages/om_dryrun/resources/img_dryrun", clie2e.DryRunGet(complete.Stdout, "api.0.url").String())
require.Equal(t, "image", clie2e.DryRunGet(complete.Stdout, "api.0.params.type").String())
}
func TestIMMessagesResourcesDownloadHelpMarksManualRequiredFlags(t *testing.T) {
setFlagAliasDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: []string{"im", "+messages-resources-download", "--help"}})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.True(t, regexp.MustCompile(`(?m)^\s+--file-key\s+string\s+.*required`).MatchString(result.Stdout), "--file-key help does not mark it required:\n%s", result.Stdout)
require.True(t, regexp.MustCompile(`(?m)^\s+--type\s+string\s+.*required`).MatchString(result.Stdout), "--type help does not mark it required:\n%s", result.Stdout)
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_PageAllLiveWorkflow exercises the real multi-page pagination added to
// the im list shortcuts: pages are fetched until exhaustion or --page-limit,
// merged in order, and the merged result carries has_more plus the resume
// page_token from the last fetched page.
//
// Self-contained: creates its own chats and messages. Chat cleanup follows the
// repo-wide convention in createChat — lark-cli has no chat-delete command, so
// created chats are intentionally left in the test account.
//
// +chat-search pagination is intentionally not covered live: newly created
// chats are not immediately searchable (server-side indexing lag), which would
// make the assertion flaky. Its pagination loop is covered by unit and dry-run
// tests.
func TestIM_PageAllLiveWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatID := createChat(t, parentT, ctx, "lark-cli-e2e-page-all-"+suffix)
// A second chat guarantees the bot is a member of at least two chats, so
// +chat-list with --page-size 1 is guaranteed to have a second page.
createChat(t, parentT, ctx, "lark-cli-e2e-page-all-b-"+suffix)
texts := make([]string, 0, 3)
var parentMessageID string
for i := 1; i <= 3; i++ {
text := fmt.Sprintf("lark-cli-e2e-page-all-msg-%d-%s", i, suffix)
texts = append(texts, text)
id := sendMessage(t, ctx, chatID, text)
if i == 1 {
parentMessageID = id
}
}
t.Run("chat-messages-list stops at page limit with resume token", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", chatID,
"--page-size", "1", "--page-all", "--page-limit", "1"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.messages.#").Int())
require.True(t, gjson.Get(result.Stdout, "data.has_more").Bool(),
"3 messages at page-size 1 must not fit in one page")
require.NotEmpty(t, gjson.Get(result.Stdout, "data.page_token").String(),
"an incomplete merged result must carry the resume token")
require.Contains(t, result.Stderr, "result is incomplete")
})
t.Run("chat-messages-list walks every page", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-messages-list", "--chat-id", chatID,
"--page-size", "1", "--page-all"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "data.messages.#").Int(), int64(3))
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool())
require.Contains(t, result.Stderr, "page 2:", "expected a real second page fetch")
for _, text := range texts {
require.Contains(t, result.Stdout, text, "merged result must contain every sent message")
}
})
t.Run("threads-messages-list walks a real thread", func(t *testing.T) {
for i := 1; i <= 2; i++ {
reply, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", parentMessageID,
"--text", fmt.Sprintf("lark-cli-e2e-page-all-reply-%d-%s", i, suffix),
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
reply.AssertExitCode(t, 0)
reply.AssertStdoutStatus(t, true)
}
// Thread replies replicate asynchronously; retry until both are visible.
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"im", "+threads-messages-list", "--thread", parentMessageID,
"--page-size", "1", "--page-all"},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
return strings.Count(result.Stdout, "lark-cli-e2e-page-all-reply-") < 2
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.GreaterOrEqual(t, gjson.Get(result.Stdout, "data.messages.#").Int(), int64(2))
require.False(t, gjson.Get(result.Stdout, "data.has_more").Bool())
require.Contains(t, result.Stderr, "page 2:", "expected a real second page fetch")
})
t.Run("chat-list paginates across chats", func(t *testing.T) {
partial, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-list", "--page-size", "1", "--page-all", "--page-limit", "1"},
DefaultAs: "bot",
})
require.NoError(t, err)
partial.AssertExitCode(t, 0)
partial.AssertStdoutStatus(t, true)
require.Equal(t, int64(1), gjson.Get(partial.Stdout, "data.chats.#").Int())
require.True(t, gjson.Get(partial.Stdout, "data.has_more").Bool(),
"the bot is in at least two chats, so page 1 of size 1 must not be the end")
require.NotEmpty(t, gjson.Get(partial.Stdout, "data.page_token").String())
require.Contains(t, partial.Stderr, "result is incomplete")
// The bot may be a member of many accumulated e2e chats, so a full walk
// can legitimately end at the default --page-limit with has_more=true.
// Assert the merge itself plus the resume contract instead of exhaustion.
full, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-list", "--page-size", "1", "--page-all"},
DefaultAs: "bot",
})
require.NoError(t, err)
full.AssertExitCode(t, 0)
full.AssertStdoutStatus(t, true)
require.GreaterOrEqual(t, gjson.Get(full.Stdout, "data.chats.#").Int(), int64(2))
require.Contains(t, full.Stderr, "page 2:", "expected a real second page fetch")
if gjson.Get(full.Stdout, "data.has_more").Bool() {
require.NotEmpty(t, gjson.Get(full.Stdout, "data.page_token").String(),
"a truncated merged result must carry the resume token")
}
})
}

View File

@@ -149,11 +149,14 @@ func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
require.Equal(t, "remote_vs_remote", clie2e.DryRunGet(output, "mode").String(), output)
require.Equal(t, int64(2), clie2e.DryRunGet(output, "api.#").Int(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.0.params.preview_type").String(), output)
require.Equal(t, "7633658129540910621", clie2e.DryRunGet(output, "api.0.params.version").String(), output)
require.Equal(t, "16", clie2e.DryRunGet(output, "api.1.params.preview_type").String(), output)
require.Equal(t, "7633658129540910628", clie2e.DryRunGet(output, "api.1.params.version").String(), output)
require.Equal(t, int64(1), clie2e.DryRunGet(output, "context_lines").Int(), output)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
@@ -179,8 +182,9 @@ func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
@@ -224,7 +228,8 @@ func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, `"output": "./copy.md"`)
}
@@ -305,7 +310,8 @@ func TestMarkdownPatchDryRun_Content(t *testing.T) {
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/medias/boxcnMarkdownDryRun/preview_download")
assert.Contains(t, output, `"preview_type": "16"`)
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")