mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
11 Commits
docs/wiki-
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427ead1aa8 | ||
|
|
15895b74e1 | ||
|
|
02ed6f02a3 | ||
|
|
7a2f6443cc | ||
|
|
3561753a7d | ||
|
|
09b38a7292 | ||
|
|
a8ad44ba13 | ||
|
|
eb0bd8a9ab | ||
|
|
003d0f42f8 | ||
|
|
7946e5c81d | ||
|
|
5cf09ecfda |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
[](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).
|
||||
|
||||
@@ -311,10 +311,6 @@ lark-cli config risk-control default
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal file
24
shortcuts/base/base_form_questions_create_tips_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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: ..."
|
||||
|
||||
@@ -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="////"`},
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
377
shortcuts/im/im_flag_aliases_test.go
Normal file
377
shortcuts/im/im_flag_aliases_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
495
shortcuts/im/im_list_page_all_test.go
Normal file
495
shortcuts/im/im_list_page_all_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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{
|
||||
|
||||
65
shortcuts/im/im_page_size_limits.go
Normal file
65
shortcuts/im/im_page_size_limits.go
Normal 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),
|
||||
)
|
||||
}
|
||||
116
shortcuts/im/im_page_size_limits_test.go
Normal file
116
shortcuts/im/im_page_size_limits_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,只提交要修改的字段;不要为局部更新补造未变更配置。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 拉取文件到本地目录,支持重复远端路径处理和增量模式。 |
|
||||
|
||||
@@ -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` 调本命令。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -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) -- 云空间(云盘/云存储)全部命令
|
||||
|
||||
@@ -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`
|
||||
- 本地文件名在未显式带扩展名时,会结合响应头自动补扩展名
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.*` 时才需要自己做这一步。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. 只有知识库名称
|
||||
|
||||
|
||||
@@ -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"`)
|
||||
}
|
||||
|
||||
108
tests/cli_e2e/base/base_form_questions_create_dryrun_test.go
Normal file
108
tests/cli_e2e/base/base_form_questions_create_dryrun_test.go
Normal 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")
|
||||
}
|
||||
30
tests/cli_e2e/base/base_skill_contract_test.go
Normal file
30
tests/cli_e2e/base/base_skill_contract_test.go
Normal 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/")
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
105
tests/cli_e2e/im/im_chat_search_types_dryrun_test.go
Normal file
105
tests/cli_e2e/im/im_chat_search_types_dryrun_test.go
Normal 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)
|
||||
}
|
||||
216
tests/cli_e2e/im/im_flag_aliases_dryrun_test.go
Normal file
216
tests/cli_e2e/im/im_flag_aliases_dryrun_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
71
tests/cli_e2e/im/im_list_page_all_dryrun_test.go
Normal file
71
tests/cli_e2e/im/im_list_page_all_dryrun_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
155
tests/cli_e2e/im/im_page_all_live_test.go
Normal file
155
tests/cli_e2e/im/im_page_all_live_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user