diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go new file mode 100644 index 00000000..99b81bd1 --- /dev/null +++ b/shortcuts/common/resource_url.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + + "github.com/larksuite/cli/internal/core" +) + +// BuildResourceURL returns a brand-standard, user-facing URL for a freshly +// created Lark resource. It is intended as a fallback when the create API does +// not return a URL field (e.g. drive +upload, wiki +node-create) or when the +// returned URL is empty (e.g. degraded MCP responses for docs +create v1). +// +// The returned URL points at the brand's standard host (www.feishu.cn / +// www.larksuite.com), which transparently redirects to the tenant-specific +// domain. It is NOT a guess at the tenant's vanity domain. +// +// Returns "" when token is empty or kind is unrecognized — callers should +// only set the field when the result is non-empty so that "" never overrides +// a real URL the backend already returned. +func BuildResourceURL(brand core.LarkBrand, kind, token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + + host := "https://www.feishu.cn" + if brand == core.BrandLark { + host = "https://www.larksuite.com" + } + + switch strings.ToLower(strings.TrimSpace(kind)) { + case "docx": + return host + "/docx/" + token + case "doc": + return host + "/doc/" + token + case "sheet": + return host + "/sheets/" + token + case "bitable": + return host + "/base/" + token + case "wiki": + return host + "/wiki/" + token + case "file": + return host + "/file/" + token + case "folder": + return host + "/drive/folder/" + token + case "mindnote": + return host + "/mindnote/" + token + case "slides": + return host + "/slides/" + token + default: + return "" + } +} diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go new file mode 100644 index 00000000..9ef0d9db --- /dev/null +++ b/shortcuts/common/resource_url_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestBuildResourceURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + brand core.LarkBrand + kind string + token string + want string + }{ + {"feishu docx", core.BrandFeishu, "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, + {"feishu doc legacy", core.BrandFeishu, "doc", "doccnABC", "https://www.feishu.cn/doc/doccnABC"}, + {"feishu sheet", core.BrandFeishu, "sheet", "shtcnABC", "https://www.feishu.cn/sheets/shtcnABC"}, + {"feishu bitable", core.BrandFeishu, "bitable", "bascnABC", "https://www.feishu.cn/base/bascnABC"}, + {"feishu wiki", core.BrandFeishu, "wiki", "wikcnABC", "https://www.feishu.cn/wiki/wikcnABC"}, + {"feishu file", core.BrandFeishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"}, + {"feishu folder", core.BrandFeishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"}, + {"feishu mindnote", core.BrandFeishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"}, + {"feishu slides", core.BrandFeishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"}, + {"lark docx", core.BrandLark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"}, + {"lark wiki", core.BrandLark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"}, + {"empty brand defaults to feishu", core.LarkBrand(""), "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, + {"kind case-insensitive", core.BrandFeishu, "DOCX", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"}, + {"token whitespace trimmed", core.BrandFeishu, "docx", " doxcnABC ", "https://www.feishu.cn/docx/doxcnABC"}, + {"empty token", core.BrandFeishu, "docx", "", ""}, + {"whitespace-only token", core.BrandFeishu, "docx", " ", ""}, + {"unknown kind", core.BrandFeishu, "calendar", "calABC", ""}, + {"empty kind", core.BrandFeishu, "", "doxcnABC", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildResourceURL(tt.brand, tt.kind, tt.token) + if got != tt.want { + t.Errorf("BuildResourceURL(%q, %q, %q) = %q, want %q", tt.brand, tt.kind, tt.token, got, tt.want) + } + }) + } +} diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 7ce75026..b0067dee 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -150,6 +150,24 @@ func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]int if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil { result["permission_grant"] = grant } + fallbackDocURLV1(runtime, result) +} + +// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP +// response did not include one but did include a doc_id. This protects against +// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult +// drops structured fields. +func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) { + if strings.TrimSpace(common.GetString(result, "doc_url")) != "" { + return + } + docID := strings.TrimSpace(common.GetString(result, "doc_id")) + if docID == "" { + return + } + if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" { + result["doc_url"] = u + } } func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget { diff --git a/shortcuts/doc/docs_create_test.go b/shortcuts/doc/docs_create_test.go index 404b060b..746347d3 100644 --- a/shortcuts/doc/docs_create_test.go +++ b/shortcuts/doc/docs_create_test.go @@ -182,6 +182,67 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) { } } +func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateAPIStub(reg, map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + // "url" deliberately omitted to exercise the fallback. + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", "内容

正文

", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + doc, _ := data["document"].(map[string]interface{}) + if doc == nil { + t.Fatalf("missing document in envelope: %#v", data) + } + if got, want := doc["url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want { + t.Fatalf("document.url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestDocsCreateV2PreservesBackendURL(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateAPIStub(reg, map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + "url": "https://tenant.larkoffice.com/docx/doxcn_new_doc", + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", "内容

正文

", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + doc, _ := data["document"].(map[string]interface{}) + if got, want := doc["url"], "https://tenant.larkoffice.com/docx/doxcn_new_doc"; got != want { + t.Fatalf("document.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) + } +} + // ── V1 (MCP) tests ── func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) { @@ -273,6 +334,60 @@ func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) { } } +func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateMCPStub(reg, map[string]interface{}{ + "doc_id": "doxcn_new_doc", + "message": "文档创建成功", + // "doc_url" deliberately omitted to exercise the fallback. + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v1", + "--title", "项目计划", + "--markdown", "## 目标", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want { + t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + registerDocsCreateMCPStub(reg, map[string]interface{}{ + "doc_id": "doxcn_new_doc", + "doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc", + "message": "文档创建成功", + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v1", + "--title", "项目计划", + "--markdown", "## 目标", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsCreateEnvelope(t, stdout) + if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want { + t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) + } +} + // ── Helpers ── func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 7aeb3655..413c4cbf 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -51,6 +51,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error { } augmentDocsCreatePermission(runtime, data) + fallbackDocsCreateURLV2(runtime, data) runtime.OutRaw(data, nil) return nil } @@ -84,3 +85,23 @@ func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string data["permission_grant"] = grant } } + +// fallbackDocsCreateURLV2 fills data.document.url with a brand-standard URL +// when the OpenAPI response did not include one. Backfills only when missing, +// so any tenant-specific URL the backend returned is preserved. +func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) { + doc, _ := data["document"].(map[string]interface{}) + if doc == nil { + return + } + if strings.TrimSpace(common.GetString(doc, "url")) != "" { + return + } + docID := strings.TrimSpace(common.GetString(doc, "document_id")) + if docID == "" { + return + } + if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" { + doc["url"] = u + } +} diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go index 116f147b..75c50aee 100644 --- a/shortcuts/drive/drive_create_folder.go +++ b/shortcuts/drive/drive_create_folder.go @@ -92,8 +92,10 @@ var DriveCreateFolder = common.Shortcut{ "folder_token": folderToken, "parent_folder_token": spec.FolderToken, } - if url := common.GetString(data, "url"); url != "" { + if url := strings.TrimSpace(common.GetString(data, "url")); url != "" { out["url"] = url + } else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" { + out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil { out["permission_grant"] = grant diff --git a/shortcuts/drive/drive_create_folder_test.go b/shortcuts/drive/drive_create_folder_test.go index 2a531bb1..5d0e8980 100644 --- a/shortcuts/drive/drive_create_folder_test.go +++ b/shortcuts/drive/drive_create_folder_test.go @@ -232,6 +232,72 @@ func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) { } } +func TestDriveCreateFolderFallbackURLWhenBackendOmitsIt(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "")) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "token": "fld_created", + // "url" deliberately omitted to exercise the fallback. + }, + }, + }) + + err := mountAndRunDrive(t, DriveCreateFolder, []string{ + "+create-folder", + "--name", "Weekly Reports", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want { + t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestDriveCreateFolderFallbackURLWhenBackendURLIsWhitespace(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "")) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "token": "fld_created", + "url": " ", // whitespace-only must trigger fallback, not pass through. + }, + }, + }) + + err := mountAndRunDrive(t, DriveCreateFolder, []string{ + "+create-folder", + "--name", "Weekly Reports", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want { + t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want) + } +} + func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) { t.Parallel() diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 7dbe7fd8..1b04fe7c 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -119,8 +119,12 @@ var DriveImport = common.Shortcut{ if status.Token != "" { out["token"] = status.Token } - if status.URL != "" { - out["url"] = status.URL + if statusURL := strings.TrimSpace(status.URL); statusURL != "" { + out["url"] = statusURL + } else if status.Token != "" { + if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" { + out["url"] = u + } } if status.JobErrorMsg != "" { out["job_error_msg"] = status.JobErrorMsg @@ -205,6 +209,20 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec, }) } +// normalizeDriveImportKindForURL maps the server's import "type" field to a +// canonical kind BuildResourceURL recognizes. status.DocType comes straight +// from the API and isn't normalized; if it ever returns aliases like "sheets" +// or "sheet_v2" the URL construction would silently fall through. Fall back +// to the user-supplied --type, which is already validated to docx/sheet/ +// bitable, so out.url stays populated whenever status.Token is set. +func normalizeDriveImportKindForURL(serverType, fallback string) string { + switch strings.ToLower(strings.TrimSpace(serverType)) { + case "docx", "sheet", "bitable": + return strings.ToLower(strings.TrimSpace(serverType)) + } + return fallback +} + // importTargetFileName returns the explicit import name when present, otherwise // derives one from the local file name. func importTargetFileName(filePath, explicitName string) string { diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go index ca72c04a..1f8d1f70 100644 --- a/shortcuts/drive/drive_import_test.go +++ b/shortcuts/drive/drive_import_test.go @@ -12,6 +12,9 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" _ "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" ) @@ -362,3 +365,206 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) { t.Fatalf("mount_key = %q, want %q", got, "fld_test") } } + +// driveImportMockEnv mounts the three stubs needed for a full +import run: +// media upload_all -> import_tasks (create) -> import_tasks/ (poll). +// Returns nothing; caller asserts on stdout via decodeDriveEnvelope. +func driveImportMockEnv(t *testing.T, reg *httpmock.Registry, ticket string, pollData map[string]interface{}) { + t.Helper() + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"file_token": "file_import_media"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/import_tasks", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"ticket": ticket}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/import_tasks/" + ticket, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"result": pollData}, + }, + }) +} + +// driveImportTestConfig builds a CliConfig for the import fallback tests. +// The brand defaults to BrandFeishu when omitted; pass core.BrandLark to +// exercise the larksuite.com branch of BuildResourceURL. +func driveImportTestConfig(suffix string, brands ...core.LarkBrand) *core.CliConfig { + brand := core.BrandFeishu + if len(brands) > 0 { + brand = brands[0] + } + return &core.CliConfig{ + AppID: "drive-import-fallback-" + suffix, + AppSecret: "test-secret", + Brand: brand, + } +} + +func TestDriveImportFallbackURLWhenBackendOmitsIt(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("missing-url")) + driveImportMockEnv(t, reg, "ticket_fallback", map[string]interface{}{ + "token": "doxcn_imported", + "type": "docx", + "job_status": float64(0), + // "url" deliberately omitted: import API frequently returns the doc + // without an absolute URL, leaving the CLI to backfill from token. + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + defer os.Chdir(origDir) + if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := mountAndRunDrive(t, DriveImport, []string{ + "+import", "--file", "notes.md", "--type", "docx", "--as", "user", + }, f, stdout); err != nil { + t.Fatalf("import should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want { + t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestDriveImportPreservesBackendURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("preserve-url")) + driveImportMockEnv(t, reg, "ticket_preserve", map[string]interface{}{ + "token": "doxcn_imported", + "type": "docx", + "job_status": float64(0), + "url": "https://tenant.larkoffice.com/docx/doxcn_imported", + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + defer os.Chdir(origDir) + if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := mountAndRunDrive(t, DriveImport, []string{ + "+import", "--file", "notes.md", "--type", "docx", "--as", "user", + }, f, stdout); err != nil { + t.Fatalf("import should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://tenant.larkoffice.com/docx/doxcn_imported"; got != want { + t.Fatalf("data.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) + } +} + +func TestDriveImportFallbackURLWhenServerURLIsWhitespace(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("whitespace-url")) + driveImportMockEnv(t, reg, "ticket_whitespace", map[string]interface{}{ + "token": "doxcn_imported", + "type": "docx", + "job_status": float64(0), + "url": " ", // whitespace-only must trigger fallback, not pass through. + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + defer os.Chdir(origDir) + if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := mountAndRunDrive(t, DriveImport, []string{ + "+import", "--file", "notes.md", "--type", "docx", "--as", "user", + }, f, stdout); err != nil { + t.Fatalf("import should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want { + t.Fatalf("data.url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want) + } +} + +func TestDriveImportFallbackURLForLarkBrand(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("lark-brand", core.BrandLark)) + driveImportMockEnv(t, reg, "ticket_lark", map[string]interface{}{ + "token": "doxcn_imported", + "type": "docx", + "job_status": float64(0), + // "url" omitted to force the fallback through the lark host branch. + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + defer os.Chdir(origDir) + if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := mountAndRunDrive(t, DriveImport, []string{ + "+import", "--file", "notes.md", "--type", "docx", "--as", "user", + }, f, stdout); err != nil { + t.Fatalf("import should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://www.larksuite.com/docx/doxcn_imported"; got != want { + t.Fatalf("data.url = %#v, want %q (lark brand fallback)", got, want) + } +} + +func TestDriveImportFallbackURLWhenServerTypeIsAlias(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("alias-type")) + driveImportMockEnv(t, reg, "ticket_alias", map[string]interface{}{ + "token": "shtcn_imported", + "type": "sheets", // non-canonical alias the server may return + "job_status": float64(0), + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir: %v", err) + } + defer os.Chdir(origDir) + if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if err := mountAndRunDrive(t, DriveImport, []string{ + "+import", "--file", "data.csv", "--type", "sheet", "--as", "user", + }, f, stdout); err != nil { + t.Fatalf("import should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + // Server returned "sheets" (alias) — normalize falls back to the user + // --type "sheet", so BuildResourceURL picks the canonical /sheets/ path. + if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_imported"; got != want { + t.Fatalf("data.url = %#v, want %q (alias normalized via spec.DocType fallback)", got, want) + } +} diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go index 081679ac..eeee2576 100644 --- a/shortcuts/drive/drive_io_test.go +++ b/shortcuts/drive/drive_io_test.go @@ -317,6 +317,84 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) { } } +func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + // upload_all only ever returns file_token; url is never present — + // this exercises the fallback path unconditionally for explorer + // parents. + "data": map[string]interface{}{"file_token": "file_explorer_small"}, + }, + }) + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Chdir() error: %v", err) + } + defer os.Chdir(origDir) + + if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", "--file", "hello.bin", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("upload should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want { + t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestDriveUploadOmitsURLForWikiParent(t *testing.T) { + uploadTestConfig := &core.CliConfig{ + AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"file_token": "file_wiki_small"}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveUpload, []string{ + "+upload", "--file", "hello.bin", + "--wiki-token", "wikcn_parent", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("upload should succeed, got: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if _, ok := data["url"]; ok { + t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"]) + } +} + func TestDriveUploadSmallFileAPIError(t *testing.T) { uploadTestConfig := &core.CliConfig{ AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index a938f3c6..43747ca2 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -146,6 +146,14 @@ var DriveUpload = common.Shortcut{ "file_name": fileName, "size": fileSize, } + // wiki-hosted files have no standalone /file/ URL — only the + // wiki node URL, which the upload response doesn't carry. Skip the + // fallback for parent_type=wiki rather than emit a link that 404s. + if target.ParentType == driveUploadParentTypeExplorer { + if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" { + out["url"] = u + } + } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil { out["permission_grant"] = grant } diff --git a/shortcuts/sheets/sheet_create.go b/shortcuts/sheets/sheet_create.go index 69266a57..fba3534e 100644 --- a/shortcuts/sheets/sheet_create.go +++ b/shortcuts/sheets/sheet_create.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -108,7 +109,12 @@ var SheetCreate = common.Shortcut{ out := map[string]interface{}{ "spreadsheet_token": token, "title": title, - "url": spreadsheet["url"], + } + url, _ := spreadsheet["url"].(string) + if url = strings.TrimSpace(url); url != "" { + out["url"] = url + } else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" { + out["url"] = u } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil { out["permission_grant"] = grant diff --git a/shortcuts/sheets/sheet_create_test.go b/shortcuts/sheets/sheet_create_test.go index 11e43f37..67791be4 100644 --- a/shortcuts/sheets/sheet_create_test.go +++ b/shortcuts/sheets/sheet_create_test.go @@ -110,6 +110,142 @@ func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { } } +func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + // "url" deliberately omitted to exercise the fallback. + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want) + } +} + +func TestSheetCreatePreservesBackendURL(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want) + } +} + +func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": " ", // whitespace-only must trigger fallback, not pass through. + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want) + } +} + +func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "spreadsheet": map[string]interface{}{ + "spreadsheet_token": "shtcn_new_sheet", + "url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ", + }, + }, + }, + }) + + err := runSheetCreateShortcut(t, f, stdout, []string{ + "+create", + "--title", "项目排期", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeSheetCreateEnvelope(t, stdout) + if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want { + t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want) + } +} + func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig { t.Helper() diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index 2c5c67ad..639fdddf 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -479,5 +479,8 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil { out["permission_grant"] = grant } + if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" { + out["url"] = u + } return out } diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index e20f07f9..a057c25d 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -502,6 +502,9 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) { if envelope.Data["node_token"] != "wik_created" { t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created") } + if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want { + t.Fatalf("url = %#v, want %q", got, want) + } var captured map[string]interface{} if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil { diff --git a/span.log b/span.log deleted file mode 100644 index e69de29b..00000000