diff --git a/shortcuts/slides/slides_create.go b/shortcuts/slides/slides_create.go index f0e6a79e1..b54deb02d 100644 --- a/shortcuts/slides/slides_create.go +++ b/shortcuts/slides/slides_create.go @@ -33,6 +33,9 @@ var SlidesCreate = common.Shortcut{ // like wiki_move) so the pre-flight check fails fast and lark-cli's // auth login --scope hint guides the user, instead of leaving an orphaned // empty presentation when the in-flight upload 403s. + // NB: no drive scope here on purpose — slides creation never touches drive; + // the presentation URL is built locally (see Execute), so we don't gate a + // drive-free operation behind a drive scope. Scopes: []string{"slides:presentation:create", "slides:presentation:write_only", "docs:document.media:upload"}, Flags: []common.Flag{ {Name: "title", Desc: "presentation title"}, @@ -205,29 +208,14 @@ var SlidesCreate = common.Shortcut{ } } - // Fetch presentation URL via drive meta (best-effort) - if metaData, err := runtime.CallAPI( - "POST", - "/open-apis/drive/v1/metas/batch_query", - nil, - map[string]interface{}{ - "request_docs": []map[string]interface{}{ - { - "doc_token": presentationID, - "doc_type": "slides", - }, - }, - "with_url": true, - }, - ); err == nil { - metas := common.GetSlice(metaData, "metas") - if len(metas) > 0 { - if meta, ok := metas[0].(map[string]interface{}); ok { - if url := common.GetString(meta, "url"); url != "" { - result["url"] = url - } - } - } + // Build the presentation URL locally from the token. The brand-standard + // host transparently redirects to the tenant domain (same fallback used by + // drive +upload / wiki +node-create). This avoids the prior best-effort + // drive metas/batch_query call, which needed an extra drive scope and 403'd + // for users who only authorized slides scopes — without ever blocking an + // otherwise-successful creation. + if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" { + result["url"] = url } if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil { diff --git a/shortcuts/slides/slides_create_test.go b/shortcuts/slides/slides_create_test.go index f1f29aeb3..2470fdfcb 100644 --- a/shortcuts/slides/slides_create_test.go +++ b/shortcuts/slides/slides_create_test.go @@ -35,7 +35,6 @@ func TestSlidesCreateBasic(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123") err := runSlidesCreateShortcut(t, f, stdout, []string{ "+create", @@ -53,8 +52,10 @@ func TestSlidesCreateBasic(t *testing.T) { if data["title"] != "项目汇报" { t.Fatalf("title = %v, want 项目汇报", data["title"]) } - if data["url"] != "https://example.feishu.cn/slides/pres_abc123" { - t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"]) + // URL is built locally from the token (brand-standard host), not fetched from + // drive metas, so it is deterministic and needs no drive scope. + if data["url"] != "https://www.feishu.cn/slides/pres_abc123" { + t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"]) } if _, ok := data["permission_grant"]; ok { t.Fatalf("did not expect permission_grant in user mode") @@ -78,7 +79,6 @@ func TestSlidesCreateBotAutoGrant(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot") reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/drive/v1/permissions/pres_bot/members", @@ -131,7 +131,6 @@ func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user") err := runSlidesCreateShortcut(t, f, stdout, []string{ "+create", @@ -168,7 +167,6 @@ func TestSlidesCreateBotAutoGrantFailed(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_grant_fail", "https://example.feishu.cn/slides/pres_grant_fail") reg.Register(&httpmock.Stub{ Method: "POST", @@ -238,7 +236,6 @@ func TestSlidesCreateDefaultTitle(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default") err := runSlidesCreateShortcut(t, f, stdout, []string{ "+create", @@ -301,7 +298,6 @@ func TestSlidesCreateWithSlides(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides") reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide", @@ -478,7 +474,6 @@ func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides") err := runSlidesCreateShortcut(t, f, stdout, []string{ "+create", @@ -551,7 +546,6 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) { }, }, }) - registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides") err := runSlidesCreateShortcut(t, f, stdout, []string{ "+create", @@ -580,8 +574,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) { } } -// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails. -func TestSlidesCreateURLFetchBestEffort(t *testing.T) { +// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed +// locally from the token — no drive metas/batch_query call is made, so creation +// works for users who only authorized slides scopes. The httpmock registry has no +// batch_query stub registered; if the shortcut tried to call it, the request would +// fail the test (unregistered stub), proving the URL is built without a drive call. +func TestSlidesCreateURLBuiltLocally(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) @@ -592,24 +590,15 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) { "code": 0, "msg": "ok", "data": map[string]interface{}{ - "xml_presentation_id": "pres_no_url", + "xml_presentation_id": "pres_local_url", "revision_id": 1, }, }, }) - // batch_query returns an error — URL fetch should be silently skipped - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/metas/batch_query", - Body: map[string]interface{}{ - "code": 99999, - "msg": "no permission", - }, - }) err := runSlidesCreateShortcut(t, f, stdout, []string{ "+create", - "--title", "No URL", + "--title", "Local URL", "--as", "user", }) if err != nil { @@ -617,11 +606,11 @@ func TestSlidesCreateURLFetchBestEffort(t *testing.T) { } data := decodeSlidesCreateEnvelope(t, stdout) - if data["xml_presentation_id"] != "pres_no_url" { - t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"]) + if data["xml_presentation_id"] != "pres_local_url" { + t.Fatalf("xml_presentation_id = %v, want pres_local_url", data["xml_presentation_id"]) } - if _, ok := data["url"]; ok { - t.Fatalf("did not expect url when batch_query fails") + if data["url"] != "https://www.feishu.cn/slides/pres_local_url" { + t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_local_url", data["url"]) } } @@ -672,22 +661,6 @@ func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buf return parent.Execute() } -// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL. -func registerBatchQueryStub(reg *httpmock.Registry, token, url string) { - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/drive/v1/metas/batch_query", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "metas": []map[string]interface{}{ - {"doc_token": token, "doc_type": "slides", "title": "", "url": url}, - }, - }, - }, - }) -} - // decodeSlidesCreateEnvelope parses the JSON output and returns the data map. func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { t.Helper() @@ -758,7 +731,6 @@ func TestSlidesCreateWithImagePlaceholders(t *testing.T) { } reg.Register(slideStub1) reg.Register(slideStub2) - registerBatchQueryStub(reg, "pres_img", "https://x.feishu.cn/slides/pres_img") slidesJSON := `[ "",