mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
docs/block
...
v1.0.55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714da970d0 | ||
|
|
ed7fdd1a27 | ||
|
|
4464ba7660 | ||
|
|
bb03c8ac4d | ||
|
|
3feb70b32a | ||
|
|
64b1b3f3ed | ||
|
|
a0e83c7e59 |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,20 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
|
||||
- **vc**: Support agent meeting event workflows (#1483)
|
||||
- **drive**: Support exporting Base structure snapshots (#1481)
|
||||
- **doc**: Add docx cover resource commands (#1468)
|
||||
- **doc**: Support `lang` for docx fetch v2 (#1459)
|
||||
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Validate drive import folder target (#1485)
|
||||
|
||||
## [v1.0.54] - 2026-06-15
|
||||
|
||||
### Features
|
||||
@@ -1175,6 +1189,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
|
||||
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
|
||||
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
|
||||
|
||||
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.
|
||||
|
||||
@@ -4,21 +4,117 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
|
||||
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
|
||||
host, appID, strings.Join(scopes, ","))
|
||||
// Landing-page contract for the scan-to-enable deep link, verified against the
|
||||
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
|
||||
// Note the param is camelCase "clientID" (not snake_case), and the value is the
|
||||
// consuming app's own ID. Centralized so it can be corrected in one place.
|
||||
const (
|
||||
addonsLandingPath = "/page/launcher"
|
||||
addonsClientIDParam = "clientID"
|
||||
)
|
||||
|
||||
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
|
||||
// Encoded form: JSON -> gzip -> base64url(no padding).
|
||||
type ManifestAddons struct {
|
||||
Scopes *AddonsScopes `json:"scopes,omitempty"`
|
||||
Events *AddonsEvents `json:"events,omitempty"`
|
||||
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
|
||||
}
|
||||
|
||||
// consoleEventSubscriptionURL points at the app's event subscription console page.
|
||||
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s/app/%s/event", host, appID)
|
||||
type AddonsScopes struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsEvents struct {
|
||||
Items AddonsEventItems `json:"items"`
|
||||
}
|
||||
|
||||
type AddonsEventItems struct {
|
||||
Tenant []string `json:"tenant"`
|
||||
User []string `json:"user"`
|
||||
}
|
||||
|
||||
type AddonsCallbacks struct {
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
|
||||
func encodeAddons(a ManifestAddons) (string, error) {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
if _, err := gw.Write(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
|
||||
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
|
||||
encoded, err := encodeAddons(a)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
|
||||
}
|
||||
|
||||
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
|
||||
func consoleLandingURL(brand core.LarkBrand, appID string) string {
|
||||
host := core.ResolveEndpoints(brand).Open
|
||||
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
|
||||
}
|
||||
|
||||
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
|
||||
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
|
||||
url, err := consoleAddonsURL(brand, appID, a)
|
||||
if err != nil {
|
||||
return consoleLandingURL(brand, appID)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// missingScopeAddons routes missing scopes into the identity-appropriate section.
|
||||
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
|
||||
// the addons spec treats a missing tenant/user as an empty array.
|
||||
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
|
||||
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
|
||||
if identity.IsBot() {
|
||||
s.Tenant = missing
|
||||
} else {
|
||||
s.User = missing
|
||||
}
|
||||
return ManifestAddons{Scopes: s}
|
||||
}
|
||||
|
||||
// missingSubscriptionAddons routes missing events/callbacks into the right section.
|
||||
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
|
||||
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
|
||||
if subType == eventlib.SubTypeCallback {
|
||||
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
|
||||
}
|
||||
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
|
||||
if identity.IsBot() {
|
||||
ev.Items.Tenant = missing
|
||||
} else {
|
||||
ev.Items.User = missing
|
||||
}
|
||||
return ManifestAddons{Events: ev}
|
||||
}
|
||||
|
||||
@@ -4,33 +4,109 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
|
||||
"im:message:readonly",
|
||||
"im:message.group_at_msg",
|
||||
})
|
||||
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
|
||||
t.Helper()
|
||||
gz, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("base64url decode: %v", err)
|
||||
}
|
||||
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip reader: %v", err)
|
||||
}
|
||||
raw, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip: %v", err)
|
||||
}
|
||||
var a ManifestAddons
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestEncodeAddons_RoundTrip(t *testing.T) {
|
||||
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
|
||||
encoded, err := encodeAddons(in)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
for _, r := range encoded {
|
||||
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
|
||||
}
|
||||
}
|
||||
out := decodeAddons(t, encoded)
|
||||
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
|
||||
t.Errorf("roundtrip mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
|
||||
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
|
||||
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
|
||||
if got != want {
|
||||
t.Errorf("url\n got: %s\nwant: %s", got, want)
|
||||
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
|
||||
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("url: %v", err)
|
||||
}
|
||||
host := core.ResolveEndpoints(core.BrandFeishu).Open
|
||||
prefix := host + "/page/launcher?clientID=cli_x&addons="
|
||||
if !strings.HasPrefix(url, prefix) {
|
||||
t.Errorf("url = %q, want prefix %q", url, prefix)
|
||||
}
|
||||
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
|
||||
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
|
||||
t.Errorf("decoded callbacks mismatch: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
|
||||
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
|
||||
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
|
||||
t.Errorf("unexpected url: %s", got)
|
||||
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
|
||||
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
|
||||
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
|
||||
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
|
||||
}
|
||||
user := missingScopeAddons(core.AsUser, []string{"im:message"})
|
||||
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
|
||||
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
|
||||
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
|
||||
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
|
||||
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
|
||||
}
|
||||
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
|
||||
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
|
||||
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
|
||||
// Unused identity sides must encode as [] (not null) so the launcher page's
|
||||
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
|
||||
cases := []ManifestAddons{
|
||||
missingScopeAddons(core.AsBot, []string{"im:message"}),
|
||||
missingScopeAddons(core.AsUser, []string{"im:message"}),
|
||||
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
|
||||
}
|
||||
for i, a := range cases {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatalf("case %d marshal: %v", i, err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("null")) {
|
||||
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
|
||||
}
|
||||
|
||||
// Callback subscriptions live in application/get, not app_versions; fetch the
|
||||
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
|
||||
// leave subscribedCallbacks nil so the callback precheck skips.
|
||||
var subscribedCallbacks []string
|
||||
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
|
||||
if cbErr != nil {
|
||||
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
|
||||
} else {
|
||||
subscribedCallbacks = cbs
|
||||
}
|
||||
}
|
||||
|
||||
pf := &preflightCtx{
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
factory: f,
|
||||
appID: cfg.AppID,
|
||||
brand: cfg.Brand,
|
||||
eventKey: eventKey,
|
||||
identity: identity,
|
||||
keyDef: keyDef,
|
||||
appVer: appVer,
|
||||
subscribedCallbacks: subscribedCallbacks,
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
return err
|
||||
@@ -229,6 +243,9 @@ type preflightCtx struct {
|
||||
identity core.Identity
|
||||
keyDef *eventlib.KeyDefinition
|
||||
appVer *appmeta.AppVersion
|
||||
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
|
||||
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
|
||||
subscribedCallbacks []string
|
||||
}
|
||||
|
||||
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
|
||||
@@ -266,46 +283,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
|
||||
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
|
||||
// the tenant token carries them. User: the scan link only updates the app
|
||||
// manifest — the user's own token still lacks the scopes until it is
|
||||
// re-authorized — so direct the user to re-login instead.
|
||||
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
|
||||
if identity.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"grant these scopes and publish a new app version at: %s",
|
||||
consoleScopeGrantURL(brand, appID, missing),
|
||||
)
|
||||
return fmt.Sprintf("grant these scopes by scanning: %s",
|
||||
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "),
|
||||
)
|
||||
strings.Join(missing, " "))
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
// in the app's console 底账 — published app_versions for event subscriptions,
|
||||
// application/get subscribed_callbacks for callback subscriptions.
|
||||
func preflightEventTypes(pf *preflightCtx) error {
|
||||
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
|
||||
return nil
|
||||
}
|
||||
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
|
||||
for _, t := range pf.appVer.EventTypes {
|
||||
subscribed[t] = true
|
||||
|
||||
var subscribed []string
|
||||
noun := "event types"
|
||||
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
|
||||
if pf.subscribedCallbacks == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.subscribedCallbacks
|
||||
noun = "callbacks"
|
||||
} else {
|
||||
if pf.appVer == nil {
|
||||
return nil
|
||||
}
|
||||
subscribed = pf.appVer.EventTypes
|
||||
}
|
||||
|
||||
have := make(map[string]bool, len(subscribed))
|
||||
for _, t := range subscribed {
|
||||
have[t] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, t := range pf.keyDef.RequiredConsoleEvents {
|
||||
if !subscribed[t] {
|
||||
if !have[t] {
|
||||
missing = append(missing, t)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
"EventKey %s requires %s not subscribed in console: %s",
|
||||
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these %s by scanning: %s", noun, url)
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
|
||||
@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +157,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
}
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
"token_type=tenant",
|
||||
"grant these scopes by scanning: ",
|
||||
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(hint, want) {
|
||||
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
|
||||
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "callbacks not subscribed") {
|
||||
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("expected skip (nil), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
|
||||
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
|
||||
// console state: a required callback IS missing and must be reported,
|
||||
// not skipped as a weak dependency.
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{}, // fetched, none subscribed
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
err := preflightEventTypes(pf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing callback when none are subscribed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "card.action.trigger") {
|
||||
t.Errorf("error should name the missing callback, got: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
pf := &preflightCtx{
|
||||
appID: "cli_x",
|
||||
brand: core.BrandFeishu,
|
||||
eventKey: "test.cb",
|
||||
identity: core.AsBot,
|
||||
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
|
||||
keyDef: &eventlib.KeyDefinition{
|
||||
Key: "test.cb",
|
||||
SubscriptionType: eventlib.SubTypeCallback,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
if err := preflightEventTypes(pf); err != nil {
|
||||
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
|
||||
// bot: scan-to-enable link (adds scopes to app manifest)
|
||||
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
|
||||
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
|
||||
t.Errorf("bot hint should give the scan link, got: %s", bot)
|
||||
}
|
||||
// user: re-login (scan link cannot grant scopes to the user's own token)
|
||||
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
|
||||
if !strings.Contains(user, "auth login --scope") {
|
||||
t.Errorf("user hint should direct to auth login, got: %s", user)
|
||||
}
|
||||
if strings.Contains(user, "/page/launcher") {
|
||||
t.Errorf("user hint must NOT use the scan link, got: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
47
internal/appmeta/app_callbacks.go
Normal file
47
internal/appmeta/app_callbacks.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FetchSubscribedCallbacks returns the app's currently subscribed callback names
|
||||
// from application/get. On a successful fetch it always returns a non-nil slice
|
||||
// (empty when callback_info is absent or lists no callbacks) so callers can
|
||||
// distinguish "fetched, zero callbacks subscribed" — a definitive console state
|
||||
// that must fail the precheck — from a fetch error (nil), which is a
|
||||
// weak-dependency skip. Identity must be bot: the endpoint is app-level.
|
||||
func FetchSubscribedCallbacks(ctx context.Context, client APIClient, appID string) ([]string, error) {
|
||||
path := fmt.Sprintf("/open-apis/application/v6/applications/%s?lang=zh_cn", appID)
|
||||
raw, err := client.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
App struct {
|
||||
CallbackInfo *struct {
|
||||
SubscribedCallbacks []string `json:"subscribed_callbacks"`
|
||||
} `json:"callback_info"`
|
||||
} `json:"app"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("decode application response: %w", err)
|
||||
}
|
||||
// callback_info also carries callback_type (e.g. "websocket"); it is
|
||||
// intentionally not parsed or validated. Feishu open-platform callbacks are
|
||||
// delivered over WebSocket only (confirmed), matching the CLI's WebSocket
|
||||
// event source, so subscribed_callbacks alone is sufficient for the precheck.
|
||||
// Revisit and validate callback_type if non-WebSocket delivery ever appears.
|
||||
callbacks := []string{}
|
||||
if ci := envelope.Data.App.CallbackInfo; ci != nil {
|
||||
callbacks = append(callbacks, ci.SubscribedCallbacks...)
|
||||
}
|
||||
return callbacks, nil
|
||||
}
|
||||
101
internal/appmeta/app_callbacks_test.go
Normal file
101
internal/appmeta/app_callbacks_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package appmeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var errFakeFetch = errors.New("fake fetch error")
|
||||
|
||||
type fakeCallbackClient struct {
|
||||
raw string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeCallbackClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(f.raw), nil
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_ParsesList(t *testing.T) {
|
||||
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket","subscribed_callbacks":["card.action.trigger","profile.view.get"]}}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
want := []string{"card.action.trigger", "profile.view.get"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_NoCallbackInfo(t *testing.T) {
|
||||
// A successful fetch with no callback_info means "zero callbacks subscribed",
|
||||
// which must be a non-nil empty slice (distinct from a fetch error's nil) so
|
||||
// the precheck reports a required callback as missing instead of skipping.
|
||||
raw := `{"code":0,"data":{"app":{"app_id":"cli_x"}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want non-nil empty slice")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_FetchError(t *testing.T) {
|
||||
// A fetch error must return nil so the caller treats it as a weak-dependency skip.
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{err: errFakeFetch}, "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("got %v, want nil on fetch error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_CallbackInfoPresentButNull(t *testing.T) {
|
||||
// callback_info present but subscribed_callbacks explicitly null → must be
|
||||
// a non-nil empty slice so the precheck reports missing callbacks.
|
||||
raw := `{"code":0,"data":{"app":{"callback_info":{"subscribed_callbacks":null}}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is null")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSubscribedCallbacks_CallbackInfoPresentButOmitted(t *testing.T) {
|
||||
// callback_info present but subscribed_callbacks omitted → same as null: non-nil empty.
|
||||
raw := `{"code":0,"data":{"app":{"callback_info":{"callback_type":"websocket"}}},"msg":"success"}`
|
||||
got, err := FetchSubscribedCallbacks(context.Background(), fakeCallbackClient{raw: raw}, "cli_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("got nil, want non-nil empty slice when subscribed_callbacks is omitted")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -265,8 +265,8 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
DefaultAs: app.DefaultAs,
|
||||
Lang: app.Lang,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
|
||||
@@ -132,6 +132,27 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: PlainSecret("my-secret"),
|
||||
Brand: BrandFeishu,
|
||||
Lang: "en",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Lang != "en" {
|
||||
t.Errorf("Lang = %q, want %q", cfg.Lang, "en")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
|
||||
// Keychain ref matches appId, so validation passes.
|
||||
// The subsequent ResolveSecretInput will fail (no real keychain),
|
||||
|
||||
@@ -269,8 +269,26 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
// SingleConsumer EventKeys allow only one consumer per SubscriptionID: reject extras at handshake.
|
||||
exclusive := false
|
||||
if def, ok := event.Lookup(hello.EventKey); ok {
|
||||
exclusive = def.SingleConsumer
|
||||
}
|
||||
var firstForKey bool
|
||||
if exclusive {
|
||||
ok, reason := b.hub.TryRegisterExclusive(bc)
|
||||
if !ok {
|
||||
if err := bc.writeFrame(protocol.NewHelloAckRejected("v1", reason)); err != nil {
|
||||
b.logger.Printf("WARN: reject hello_ack write to pid=%d key=%q failed: %v", hello.PID, hello.EventKey, err)
|
||||
}
|
||||
bc.Close()
|
||||
return
|
||||
}
|
||||
firstForKey = true
|
||||
} else {
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey = b.hub.RegisterAndIsFirst(bc)
|
||||
}
|
||||
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
|
||||
@@ -5,12 +5,15 @@ package bus
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
@@ -194,3 +197,60 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_SingleConsumerRejectsSecond: a SingleConsumer EventKey accepts
|
||||
// the first consumer and rejects the second for the same SubscriptionID.
|
||||
func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) {
|
||||
const key = "test.handlehello.exclusive"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
SingleConsumer: true,
|
||||
Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
readAck := func(t *testing.T, pid int) *protocol.HelloAck {
|
||||
t.Helper()
|
||||
server, client := net.Pipe()
|
||||
t.Cleanup(func() { server.Close(); client.Close() })
|
||||
hello := &protocol.Hello{PID: pid, EventKey: key, EventTypes: []string{key}}
|
||||
go b.handleHello(server, bufio.NewReader(server), hello)
|
||||
line, err := protocol.ReadFrame(bufio.NewReader(client))
|
||||
if err != nil {
|
||||
t.Fatalf("read ack (pid %d): %v", pid, err)
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ack (pid %d): %v", pid, err)
|
||||
}
|
||||
ack, ok := msg.(*protocol.HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want *HelloAck", msg)
|
||||
}
|
||||
return ack
|
||||
}
|
||||
|
||||
ack1 := readAck(t, 100)
|
||||
if ack1.Rejected {
|
||||
t.Fatalf("first consumer should be accepted, got rejected: %q", ack1.RejectReason)
|
||||
}
|
||||
|
||||
ack2 := readAck(t, 200)
|
||||
if !ack2.Rejected {
|
||||
t.Fatal("second consumer should be rejected")
|
||||
}
|
||||
if !strings.Contains(ack2.RejectReason, "already running") {
|
||||
t.Errorf("reject reason = %q, want mention of 'already running'", ack2.RejectReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,34 @@ package bus
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
// exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an
|
||||
// in-progress cleanup of the same subscription before rejecting, so a stuck
|
||||
// cleanup can never wedge new consumers forever. Kept below the consumer's
|
||||
// hello_ack deadline (consume.helloAckTimeout = 5s) so the reject still reaches
|
||||
// the consumer as a clean failed_precondition instead of a handshake timeout.
|
||||
// Override with LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT (a Go duration such as
|
||||
// "2s"); values at or above the 5s handshake deadline are not recommended.
|
||||
var exclusiveCleanupWaitTimeout = resolveExclusiveCleanupWaitTimeout()
|
||||
|
||||
func resolveExclusiveCleanupWaitTimeout() time.Duration {
|
||||
const def = 3 * time.Second
|
||||
if v := os.Getenv("LARKSUITE_CLI_EVENT_EXCLUSIVE_WAIT_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
@@ -124,6 +145,63 @@ func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// TryRegisterExclusive registers s only when no subscriber holds s.SubscriptionID()
|
||||
// and any in-progress cleanup for that subscription finishes within
|
||||
// exclusiveCleanupWaitTimeout. On failure it returns (false, reason): either a
|
||||
// duplicate consumer already holds the subscription, or the cleanup did not
|
||||
// finish in time — the timeout guarantees a stuck cleanup can never wedge new
|
||||
// consumers forever. reason is "" on success. Mirrors RegisterAndIsFirst's wait
|
||||
// on in-progress cleanup, but bounded.
|
||||
func (h *Hub) TryRegisterExclusive(s Subscriber) (bool, string) {
|
||||
sid := s.SubscriptionID()
|
||||
deadline := time.Now().Add(exclusiveCleanupWaitTimeout)
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-ch:
|
||||
// Stop+drain so a timer that fired concurrently with Stop isn't left on .C.
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
continue
|
||||
case <-timer.C:
|
||||
return false, "timed out waiting for the previous consumer's cleanup to finish; retry shortly"
|
||||
}
|
||||
}
|
||||
if h.subCounts[sid] != 0 {
|
||||
pid := h.existingPIDForSubscriptionLocked(sid)
|
||||
h.mu.Unlock()
|
||||
return false, fmt.Sprintf("another consumer (pid %d) is already running for this subscription", pid)
|
||||
}
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
|
||||
// existingPIDForSubscriptionLocked returns the PID of one subscriber for sid.
|
||||
// Caller must hold h.mu.
|
||||
func (h *Hub) existingPIDForSubscriptionLocked(sid string) int {
|
||||
for sub := range h.subscribers {
|
||||
if sub.SubscriptionID() == sid {
|
||||
return sub.PID()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Publish fans out a RawEvent to all matching subscribers (non-blocking).
|
||||
//
|
||||
// A fresh *protocol.Event is allocated per subscriber so each consumer sees
|
||||
|
||||
@@ -6,6 +6,7 @@ package bus
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -355,3 +356,69 @@ func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive(t *testing.T) {
|
||||
h := NewHub()
|
||||
first := newTestConn("k.exclusive", []string{"k.exclusive"})
|
||||
first.pid = 100
|
||||
ok, _ := h.TryRegisterExclusive(first)
|
||||
if !ok {
|
||||
t.Fatal("first exclusive register should succeed")
|
||||
}
|
||||
|
||||
second := newTestConn("k.exclusive", []string{"k.exclusive"})
|
||||
second.pid = 200
|
||||
ok, reason := h.TryRegisterExclusive(second)
|
||||
if ok {
|
||||
t.Error("second exclusive register should be rejected")
|
||||
}
|
||||
if !strings.Contains(reason, "pid 100") {
|
||||
t.Errorf("reject reason = %q, want it to name existing pid 100", reason)
|
||||
}
|
||||
if got := h.SubCount("k.exclusive"); got != 1 {
|
||||
t.Errorf("SubCount = %d, want 1 (second not registered)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive_CleanupWaitTimeout(t *testing.T) {
|
||||
// A cleanup lock that never releases must not wedge a new exclusive consumer
|
||||
// forever — TryRegisterExclusive bounds the wait and rejects with a timeout reason.
|
||||
saved := exclusiveCleanupWaitTimeout
|
||||
exclusiveCleanupWaitTimeout = 20 * time.Millisecond
|
||||
defer func() { exclusiveCleanupWaitTimeout = saved }()
|
||||
|
||||
h := NewHub()
|
||||
first := newTestConn("k.timeout", []string{"k.timeout"})
|
||||
if ok, _ := h.TryRegisterExclusive(first); !ok {
|
||||
t.Fatal("first exclusive register should succeed")
|
||||
}
|
||||
// Hold the cleanup lock and never release it.
|
||||
if !h.AcquireCleanupLock("k.timeout") {
|
||||
t.Fatal("AcquireCleanupLock should succeed for the sole subscriber")
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
second := newTestConn("k.timeout", []string{"k.timeout"})
|
||||
ok, reason := h.TryRegisterExclusive(second)
|
||||
if ok {
|
||||
t.Error("second exclusive register should be rejected on cleanup-wait timeout")
|
||||
}
|
||||
if !strings.Contains(reason, "timed out") {
|
||||
t.Errorf("reject reason = %q, want a timeout reason", reason)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("wait took %v, want bounded by the ~20ms timeout (no deadlock)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_TryRegisterExclusive_DistinctSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
a := newTestConn("k.a", []string{"k.a"})
|
||||
b := newTestConn("k.b", []string{"k.b"})
|
||||
if ok, _ := h.TryRegisterExclusive(a); !ok {
|
||||
t.Fatal("register a failed")
|
||||
}
|
||||
if ok, _ := h.TryRegisterExclusive(b); !ok {
|
||||
t.Error("distinct subscription b should register")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
@@ -102,6 +103,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil {
|
||||
return rejErr
|
||||
}
|
||||
|
||||
var cleanup func() error
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
@@ -171,6 +175,17 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
// rejectionError converts a rejected hello_ack into a structured precondition
|
||||
// error; returns nil when the ack is absent or not a rejection.
|
||||
func rejectionError(ack *protocol.HelloAck, eventKey string) error {
|
||||
if ack == nil || !ack.Rejected {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"cannot start consumer: %s", ack.RejectReason).
|
||||
WithHint("EventKey %s allows only one consumer; run `lark-cli event status` to find the running one, then stop it before retrying", eventKey)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
return d.Truncate(time.Second)
|
||||
}
|
||||
|
||||
36
internal/event/consume/reject_test.go
Normal file
36
internal/event/consume/reject_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
)
|
||||
|
||||
func TestRejectionError_Rejected(t *testing.T) {
|
||||
ack := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, Rejected: true, RejectReason: "another consumer (pid 9) is already running"}
|
||||
err := rejectionError(ack, "im.message.receive_v1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rejected ack")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Category != errs.CategoryValidation || prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %v, want validation/failed_precondition; err=%q", prob, err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already running") {
|
||||
t.Errorf("error = %q, want reject reason", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectionError_NotRejected(t *testing.T) {
|
||||
if err := rejectionError(&protocol.HelloAck{Type: protocol.MsgTypeHelloAck}, "k"); err != nil {
|
||||
t.Errorf("expected nil for non-rejected ack, got %v", err)
|
||||
}
|
||||
if err := rejectionError(nil, "k"); err != nil {
|
||||
t.Errorf("expected nil for nil ack, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,11 @@ type Hello struct {
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
Type string `json:"type"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
FirstForKey bool `json:"first_for_key"`
|
||||
Type string `json:"type"`
|
||||
BusVersion string `json:"bus_version"`
|
||||
FirstForKey bool `json:"first_for_key"`
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
|
||||
@@ -117,6 +119,17 @@ func NewHelloAck(busVersion string, firstForKey bool) *HelloAck {
|
||||
}
|
||||
}
|
||||
|
||||
// NewHelloAckRejected builds a hello_ack that tells the consumer the bus refused
|
||||
// registration (e.g. a SingleConsumer EventKey already has a running consumer).
|
||||
func NewHelloAckRejected(busVersion, reason string) *HelloAck {
|
||||
return &HelloAck{
|
||||
Type: MsgTypeHelloAck,
|
||||
BusVersion: busVersion,
|
||||
Rejected: true,
|
||||
RejectReason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event {
|
||||
return &Event{
|
||||
Type: MsgTypeEvent,
|
||||
|
||||
@@ -105,3 +105,25 @@ func TestReadFrame_PropagatesEOF(t *testing.T) {
|
||||
t.Errorf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelloAckRejected_RoundTrip(t *testing.T) {
|
||||
ack := NewHelloAckRejected("v1", "another consumer (pid 42) is already running for this subscription")
|
||||
if !ack.Rejected || ack.RejectReason == "" {
|
||||
t.Fatalf("NewHelloAckRejected fields: %+v", ack)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Encode(&buf, ack); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
msg, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
got, ok := msg.(*HelloAck)
|
||||
if !ok {
|
||||
t.Fatalf("decoded type = %T, want *HelloAck", msg)
|
||||
}
|
||||
if !got.Rejected || got.RejectReason != ack.RejectReason {
|
||||
t.Errorf("roundtrip = %+v, want Rejected with reason", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ func RegisterKey(def KeyDefinition) {
|
||||
panic(fmt.Sprintf("EventKey %s: EventType must not be empty", def.Key))
|
||||
}
|
||||
|
||||
if def.SubscriptionType == "" {
|
||||
def.SubscriptionType = SubTypeEvent
|
||||
}
|
||||
if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback {
|
||||
panic(fmt.Sprintf("EventKey %s: SubscriptionType must be %q or %q; got %q",
|
||||
def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType))
|
||||
}
|
||||
|
||||
validateSchema(def)
|
||||
validateParams(def)
|
||||
validateAuth(def)
|
||||
|
||||
@@ -244,3 +244,58 @@ func TestBufferSize_Clamped(t *testing.T) {
|
||||
t.Errorf("BufferSize = %d, want %d", def.BufferSize, MaxBufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_SubscriptionTypeDefaultsToEvent(t *testing.T) {
|
||||
const key = "test.subtype.default"
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer UnregisterKeyForTest(key)
|
||||
|
||||
def, ok := Lookup(key)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", key)
|
||||
}
|
||||
if def.SubscriptionType != SubTypeEvent {
|
||||
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeEvent)
|
||||
}
|
||||
if def.SingleConsumer {
|
||||
t.Errorf("SingleConsumer = true, want false (default)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_SubscriptionTypeCallbackPreserved(t *testing.T) {
|
||||
const key = "test.subtype.callback"
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
SubscriptionType: SubTypeCallback,
|
||||
SingleConsumer: true,
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
defer UnregisterKeyForTest(key)
|
||||
|
||||
def, _ := Lookup(key)
|
||||
if def.SubscriptionType != SubTypeCallback {
|
||||
t.Errorf("SubscriptionType = %q, want %q", def.SubscriptionType, SubTypeCallback)
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Errorf("SingleConsumer = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterKey_InvalidSubscriptionTypePanics(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Errorf("expected panic for invalid SubscriptionType")
|
||||
}
|
||||
}()
|
||||
RegisterKey(KeyDefinition{
|
||||
Key: "test.subtype.bogus",
|
||||
EventType: "test.subtype.bogus",
|
||||
SubscriptionType: "bogus",
|
||||
Schema: SchemaDef{Native: &SchemaSpec{Raw: []byte(`{"type":"object"}`)}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,6 +42,18 @@ const (
|
||||
ParamInt ParamType = "int"
|
||||
)
|
||||
|
||||
// SubscriptionType marks whether an EventKey is delivered via Lark event
|
||||
// subscription or interactive callback subscription. It is a sibling of
|
||||
// EventType (which holds the concrete Lark event_type string).
|
||||
type SubscriptionType string
|
||||
|
||||
const (
|
||||
// SubTypeEvent: checked against the published app_versions event_infos.
|
||||
SubTypeEvent SubscriptionType = "event"
|
||||
// SubTypeCallback: checked against application/get subscribed_callbacks.
|
||||
SubTypeCallback SubscriptionType = "callback"
|
||||
)
|
||||
|
||||
// ParamValue.Desc is mandatory so AI consumers can decide which value to pick.
|
||||
type ParamValue struct {
|
||||
Value string `json:"value"`
|
||||
@@ -96,6 +108,10 @@ type KeyDefinition struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
EventType string `json:"event_type"`
|
||||
|
||||
// SubscriptionType selects which console "底账" the precheck reads.
|
||||
// Empty is normalized to SubTypeEvent at RegisterKey.
|
||||
SubscriptionType SubscriptionType `json:"subscription_type,omitempty"`
|
||||
|
||||
Params []ParamDef `json:"params,omitempty"`
|
||||
|
||||
Schema SchemaDef `json:"schema"`
|
||||
@@ -148,4 +164,8 @@ type KeyDefinition struct {
|
||||
|
||||
BufferSize int `json:"buffer_size,omitempty"`
|
||||
Workers int `json:"workers,omitempty"`
|
||||
|
||||
// SingleConsumer rejects a second consumer for the same SubscriptionID at
|
||||
// the bus handshake. Default false = unlimited consumers (fan-out).
|
||||
SingleConsumer bool `json:"single_consumer,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.54",
|
||||
"version": "1.0.55",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
795
shortcuts/doc/doc_resource_cover.go
Normal file
795
shortcuts/doc/doc_resource_cover.go
Normal file
@@ -0,0 +1,795 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
docCoverResourceType = "cover"
|
||||
docCoverUploadParent = "docx_image"
|
||||
docCoverURLMaxBytes = int64(20 * 1024 * 1024)
|
||||
docCoverDownloadName = "cover"
|
||||
docCoverURLDownloadName = "cover"
|
||||
)
|
||||
|
||||
type docCoverHTTPStatusCause int
|
||||
|
||||
func (c docCoverHTTPStatusCause) Error() string {
|
||||
return http.StatusText(int(c))
|
||||
}
|
||||
|
||||
type docCoverURLGuardError string
|
||||
|
||||
func (e docCoverURLGuardError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var docCoverAllowedContentTypes = map[string]string{
|
||||
"image/gif": ".gif",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
var DocResourceDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-download",
|
||||
Description: "Download a document resource (type=cover downloads the cover image content)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
||||
{Name: "output", Desc: "local save path", Required: true},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: validateDocCoverType,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
documentID := docRef.Token
|
||||
d := common.NewDryRunAPI()
|
||||
if docRef.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to docx document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
d.GET("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Read document cover metadata").
|
||||
Set("document_id", documentID)
|
||||
d.GET("/open-apis/drive/v1/medias/:cover_token/download").
|
||||
Desc("Download cover image content").
|
||||
Set("cover_token", "<cover.token>").
|
||||
Set("output", runtime.Str("output"))
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
cover, err := getDocCover(runtime, documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cover.Token == "" {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "document has no cover (cover is empty): %s", common.MaskToken(documentID)).WithParam("--type")
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover: %s\n", common.MaskToken(cover.Token))
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(cover.Token)),
|
||||
})
|
||||
if err != nil {
|
||||
return wrapDocNetworkErr(err, "download cover failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
|
||||
if finalPath != outputPath {
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if !runtime.Bool("overwrite") {
|
||||
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
|
||||
}
|
||||
}
|
||||
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
||||
if savedPath == "" {
|
||||
savedPath = finalPath
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
"cover": cover.toOutput(),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocResourceUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-update",
|
||||
Description: "Upload and update a document resource (type=cover)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
||||
{Name: "file", Desc: "local image file path (files > 20MB use multipart upload automatically)"},
|
||||
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file"},
|
||||
{Name: "url", Desc: "HTTPS image URL to download and upload"},
|
||||
{Name: "offset-ratio-x", Type: "float64", Desc: "cover horizontal offset ratio"},
|
||||
{Name: "offset-ratio-y", Type: "float64", Desc: "cover vertical offset ratio"},
|
||||
},
|
||||
Validate: validateDocCoverUpdate,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
documentID := docRef.Token
|
||||
d := common.NewDryRunAPI()
|
||||
if docRef.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to docx document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
source := docCoverDryRunSource(runtime)
|
||||
d.Desc("upload cover image and update document cover").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc("Upload cover image").
|
||||
Body(map[string]interface{}{
|
||||
"file": source,
|
||||
"file_name": "<cover_file_name>",
|
||||
"parent_type": docCoverUploadParent,
|
||||
"parent_node": documentID,
|
||||
"extra": fmt.Sprintf(`{"drive_route_token":"%s"}`, documentID),
|
||||
})
|
||||
d.PATCH("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Update document cover").
|
||||
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": buildDocCoverUpdateBody("<file_token>", runtime)}})
|
||||
d.Set("document_id", documentID)
|
||||
if runtime.Str("url") != "" {
|
||||
d.Set("url_safety", "HTTPS only; private/loopback/link-local IPs rejected; max 3 redirects; image content-types only; max 20MiB")
|
||||
}
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source, err := readDocCoverUpdateSource(ctx, runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading cover image: %s (%d bytes)\n", source.FileName, source.FileSize)
|
||||
if source.FileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
uploadCfg := UploadDocMediaFileConfig{
|
||||
FilePath: source.FilePath,
|
||||
Reader: source.Reader,
|
||||
FileName: source.FileName,
|
||||
FileSize: source.FileSize,
|
||||
ParentType: docCoverUploadParent,
|
||||
ParentNode: documentID,
|
||||
DocID: documentID,
|
||||
}
|
||||
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", common.MaskToken(fileToken))
|
||||
|
||||
coverBody := buildDocCoverUpdateBody(fileToken, runtime)
|
||||
if _, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
||||
nil,
|
||||
map[string]interface{}{"update_cover": map[string]interface{}{"cover": coverBody}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"updated": true,
|
||||
"source": source.Kind,
|
||||
"file_token": fileToken,
|
||||
"cover": coverBody,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocResourceDelete = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-delete",
|
||||
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
||||
},
|
||||
Validate: validateDocCoverType,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
documentID := docRef.Token
|
||||
d := common.NewDryRunAPI()
|
||||
if docRef.Kind == "wiki" {
|
||||
documentID = "<resolved_docx_token>"
|
||||
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to docx document").
|
||||
Params(map[string]interface{}{"token": docRef.Token})
|
||||
}
|
||||
d.GET("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Read document cover metadata for idempotency").
|
||||
Set("document_id", documentID)
|
||||
d.PATCH("/open-apis/docx/v1/documents/:document_id").
|
||||
Desc("Clear document cover when one exists").
|
||||
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}})
|
||||
return d
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cover, err := getDocCover(runtime, documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cover.Token == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"deleted": false,
|
||||
"already_empty": true,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
||||
nil,
|
||||
map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"document_id": documentID,
|
||||
"type": docCoverResourceType,
|
||||
"deleted": true,
|
||||
"already_empty": false,
|
||||
"previous_cover": cover.toOutput(),
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type docCoverMetadata struct {
|
||||
Token string
|
||||
OffsetRatioX *float64
|
||||
OffsetRatioY *float64
|
||||
}
|
||||
|
||||
func (c docCoverMetadata) toOutput() map[string]interface{} {
|
||||
out := map[string]interface{}{"token": c.Token}
|
||||
if c.OffsetRatioX != nil {
|
||||
out["offset_ratio_x"] = *c.OffsetRatioX
|
||||
}
|
||||
if c.OffsetRatioY != nil {
|
||||
out["offset_ratio_y"] = *c.OffsetRatioY
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type docCoverUpdateSource struct {
|
||||
Kind string
|
||||
FilePath string
|
||||
Reader io.Reader
|
||||
FileName string
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
func validateDocCoverType(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("type") != docCoverResourceType {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --type %q, expected cover", runtime.Str("type")).WithParam("--type")
|
||||
}
|
||||
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if docRef.Kind == "doc" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocCoverUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocCoverType(ctx, runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceCount := 0
|
||||
var params []errs.InvalidParam
|
||||
if runtime.Str("file") != "" {
|
||||
sourceCount++
|
||||
params = append(params, errs.InvalidParam{Name: "--file", Reason: "source flag"})
|
||||
}
|
||||
if runtime.Bool("from-clipboard") {
|
||||
sourceCount++
|
||||
params = append(params, errs.InvalidParam{Name: "--from-clipboard", Reason: "source flag"})
|
||||
}
|
||||
if runtime.Str("url") != "" {
|
||||
sourceCount++
|
||||
params = append(params, errs.InvalidParam{Name: "--url", Reason: "source flag"})
|
||||
}
|
||||
if sourceCount == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file, --from-clipboard or --url is required").WithParams(
|
||||
errs.InvalidParam{Name: "--file", Reason: "provide one source"},
|
||||
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide one source"},
|
||||
errs.InvalidParam{Name: "--url", Reason: "provide one source"},
|
||||
)
|
||||
}
|
||||
if sourceCount > 1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file, --from-clipboard and --url are mutually exclusive").WithParams(params...)
|
||||
}
|
||||
if err := validateCoverOffset(runtime, "offset-ratio-x"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCoverOffset(runtime, "offset-ratio-y"); err != nil {
|
||||
return err
|
||||
}
|
||||
if rawURL := runtime.Str("url"); rawURL != "" {
|
||||
if _, err := parseDocCoverURLSyntax(rawURL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCoverOffset(runtime *common.RuntimeContext, name string) error {
|
||||
if !runtime.Changed(name) {
|
||||
return nil
|
||||
}
|
||||
value := runtime.Float64(name)
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a finite number", name).WithParam("--" + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDocxDocumentIDForResource(runtime *common.RuntimeContext, input string) (string, error) {
|
||||
docRef, err := parseDocumentRef(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch docRef.Kind {
|
||||
case "docx":
|
||||
return docRef.Token, nil
|
||||
case "wiki":
|
||||
return resolveDocxDocumentID(runtime, input)
|
||||
case "doc":
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents").WithParam("--doc")
|
||||
}
|
||||
}
|
||||
|
||||
func getDocCover(runtime *common.RuntimeContext, documentID string) (docCoverMetadata, error) {
|
||||
data, err := runtime.CallAPITyped("GET",
|
||||
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return docCoverMetadata{}, err
|
||||
}
|
||||
coverData := common.GetMap(data, "document", "cover")
|
||||
if len(coverData) == 0 {
|
||||
coverData = common.GetMap(data, "cover")
|
||||
}
|
||||
cover := docCoverMetadata{Token: common.GetString(coverData, "token")}
|
||||
if value, ok := getOptionalFloat(coverData, "offset_ratio_x"); ok {
|
||||
cover.OffsetRatioX = &value
|
||||
}
|
||||
if value, ok := getOptionalFloat(coverData, "offset_ratio_y"); ok {
|
||||
cover.OffsetRatioY = &value
|
||||
}
|
||||
return cover, nil
|
||||
}
|
||||
|
||||
func getOptionalFloat(m map[string]interface{}, key string) (float64, bool) {
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func readDocCoverUpdateSource(ctx context.Context, runtime *common.RuntimeContext) (docCoverUpdateSource, error) {
|
||||
if runtime.Bool("from-clipboard") {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n")
|
||||
content, err := readClipboardImage()
|
||||
if err != nil {
|
||||
return docCoverUpdateSource{}, err
|
||||
}
|
||||
return docCoverUpdateSource{
|
||||
Kind: "clipboard",
|
||||
Reader: bytes.NewReader(content),
|
||||
FileName: "clipboard.png",
|
||||
FileSize: int64(len(content)),
|
||||
}, nil
|
||||
}
|
||||
if rawURL := runtime.Str("url"); rawURL != "" {
|
||||
content, fileName, err := downloadDocCoverURL(ctx, runtime, rawURL)
|
||||
if err != nil {
|
||||
return docCoverUpdateSource{}, err
|
||||
}
|
||||
return docCoverUpdateSource{
|
||||
Kind: "url",
|
||||
Reader: bytes.NewReader(content),
|
||||
FileName: fileName,
|
||||
FileSize: int64(len(content)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
filePath := runtime.Str("file")
|
||||
stat, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil {
|
||||
return docCoverUpdateSource{}, wrapDocInputFileErr(err, "file not found")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return docCoverUpdateSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
return docCoverUpdateSource{
|
||||
Kind: "file",
|
||||
FilePath: filePath,
|
||||
FileName: filepath.Base(filePath),
|
||||
FileSize: stat.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildDocCoverUpdateBody(fileToken string, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cover := map[string]interface{}{"token": fileToken}
|
||||
if runtime.Changed("offset-ratio-x") {
|
||||
cover["offset_ratio_x"] = runtime.Float64("offset-ratio-x")
|
||||
}
|
||||
if runtime.Changed("offset-ratio-y") {
|
||||
cover["offset_ratio_y"] = runtime.Float64("offset-ratio-y")
|
||||
}
|
||||
return cover
|
||||
}
|
||||
|
||||
func docCoverDryRunSource(runtime *common.RuntimeContext) string {
|
||||
if runtime.Bool("from-clipboard") {
|
||||
return "<clipboard image>"
|
||||
}
|
||||
if rawURL := runtime.Str("url"); rawURL != "" {
|
||||
return rawURL
|
||||
}
|
||||
if filePath := runtime.Str("file"); filePath != "" {
|
||||
return "@" + filePath
|
||||
}
|
||||
return "<cover image>"
|
||||
}
|
||||
|
||||
func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, raw string) ([]byte, string, error) {
|
||||
u, err := parseAndValidateDocCoverURL(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
baseClient, err := runtime.Factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
||||
}
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) //nolint:forbidigo // cover --url fetches external user content; RuntimeContext API helpers are Lark-API only.
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
resp, err := client.Do(req) //nolint:forbidigo // cover --url uses a guarded external downloader, not Lark API transport.
|
||||
if err != nil {
|
||||
return nil, "", wrapDocNetworkErr(err, "download cover URL failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if resp.StatusCode >= 500 {
|
||||
subtype = errs.SubtypeNetworkServer
|
||||
}
|
||||
cause := docCoverHTTPStatusCause(resp.StatusCode)
|
||||
return nil, "", errs.NewNetworkError(subtype, "download cover URL failed: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode).WithCause(cause)
|
||||
}
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType == "" {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response must include an image Content-Type").WithParam("--url")
|
||||
}
|
||||
mediaType = strings.ToLower(mediaType)
|
||||
ext, ok := docCoverAllowedContentTypes[mediaType]
|
||||
if !ok {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/png, image/jpeg, image/gif or image/webp", mediaType).WithParam("--url")
|
||||
}
|
||||
|
||||
limited := io.LimitReader(resp.Body, docCoverURLMaxBytes+1)
|
||||
content, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, "", wrapDocNetworkErr(err, "read cover URL response failed: %v", err)
|
||||
}
|
||||
if int64(len(content)) > docCoverURLMaxBytes {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response exceeds 20MiB limit").WithParam("--url")
|
||||
}
|
||||
|
||||
fileName := docCoverURLFileName(resp.Request.URL, ext)
|
||||
return content, fileName, nil
|
||||
}
|
||||
|
||||
func parseAndValidateDocCoverURL(ctx context.Context, raw string) (*url.URL, error) {
|
||||
u, err := parseDocCoverURLSyntax(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverURLHost(ctx, u.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func parseDocCoverURLSyntax(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must use https").WithParam("--url")
|
||||
}
|
||||
if u.User != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not include userinfo").WithParam("--url")
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func docCoverURLFileName(u *url.URL, ext string) string {
|
||||
base := path.Base(u.EscapedPath())
|
||||
if base == "." || base == "/" || base == "" {
|
||||
return docCoverURLDownloadName + ext
|
||||
}
|
||||
unescaped, err := url.PathUnescape(base)
|
||||
if err == nil {
|
||||
base = unescaped
|
||||
}
|
||||
base = filepath.Base(base)
|
||||
if strings.TrimSpace(base) == "" || base == "." || base == string(filepath.Separator) {
|
||||
return docCoverURLDownloadName + ext
|
||||
}
|
||||
if filepath.Ext(base) == "" {
|
||||
base += ext
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func validateDocCoverURLHost(ctx context.Context, host string) error {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if host == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: %v", err).WithParam("--url").WithCause(err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: no addresses").WithParam("--url")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnsafeDocCoverIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 169 && v4[1] == 254 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 192 && v4[1] == 168 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
||||
return true
|
||||
}
|
||||
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) {
|
||||
return true
|
||||
}
|
||||
if v4[0] >= 240 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return ip.IsPrivate()
|
||||
}
|
||||
|
||||
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
|
||||
if base == nil {
|
||||
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
|
||||
}
|
||||
cloned := *base
|
||||
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
|
||||
}
|
||||
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
|
||||
if len(via) >= 3 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
|
||||
}
|
||||
if len(via) > 0 {
|
||||
prev := via[len(via)-1]
|
||||
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirect from https to http is not allowed").WithParam("--url")
|
||||
}
|
||||
}
|
||||
_, err := parseAndValidateDocCoverURL(req.Context(), req.URL.String())
|
||||
return err
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
cloned.Proxy = nil
|
||||
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
if cloned.DialTLSContext != nil {
|
||||
origDialTLS := cloned.DialTLSContext
|
||||
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
||||
if dialFn != nil {
|
||||
return dialFn(ctx, network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func validateDocCoverConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return docCoverURLGuardError("nil connection")
|
||||
}
|
||||
addr := conn.RemoteAddr()
|
||||
if addr == nil {
|
||||
return docCoverURLGuardError("missing remote address")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr.String())
|
||||
if err != nil {
|
||||
host = addr.String()
|
||||
}
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
return docCoverURLGuardError("invalid remote IP")
|
||||
}
|
||||
if isUnsafeDocCoverIP(ip) {
|
||||
return docCoverURLGuardError("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
712
shortcuts/doc/doc_resource_cover_test.go
Normal file
712
shortcuts/doc/doc_resource_cover_test.go
Normal file
@@ -0,0 +1,712 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-download-app"))
|
||||
documentID := "doxcnCoverDownload1"
|
||||
coverToken := "cover_token_download_123"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{
|
||||
"token": coverToken,
|
||||
"offset_ratio_x": 0.25,
|
||||
"offset_ratio_y": 0.75,
|
||||
}))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/medias/" + coverToken + "/download",
|
||||
Status: 200,
|
||||
Body: []byte("png-data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/png"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeDocResourceOutput(t, stdout)
|
||||
data := out.Data
|
||||
if data["type"] != "cover" {
|
||||
t.Fatalf("type = %v, want cover", data["type"])
|
||||
}
|
||||
if data["content_type"] != "image/png" {
|
||||
t.Fatalf("content_type = %v, want image/png", data["content_type"])
|
||||
}
|
||||
if int(data["size_bytes"].(float64)) != len("png-data") {
|
||||
t.Fatalf("size_bytes = %v", data["size_bytes"])
|
||||
}
|
||||
savedPath, _ := data["saved_path"].(string)
|
||||
if !strings.HasSuffix(savedPath, "cover.png") {
|
||||
t.Fatalf("saved_path = %q, want cover.png suffix", savedPath)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(tmpDir, "cover.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(cover.png) error: %v", err)
|
||||
}
|
||||
if string(content) != "png-data" {
|
||||
t.Fatalf("downloaded content = %q", string(content))
|
||||
}
|
||||
cover := data["cover"].(map[string]interface{})
|
||||
if cover["token"] != coverToken {
|
||||
t.Fatalf("cover.token = %v, want %s", cover["token"], coverToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-download-app"))
|
||||
documentID := "doxcnCoverEmptyDownload1"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover.png",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty cover error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeFailedPrecondition, "--type")
|
||||
if _, statErr := os.Stat(filepath.Join(tmpDir, "cover.png")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("cover.png should not be created, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-empty-delete-app"))
|
||||
documentID := "doxcnCoverEmptyDelete1"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocResourceOutput(t, stdout).Data
|
||||
if data["deleted"] != false {
|
||||
t.Fatalf("deleted = %v, want false", data["deleted"])
|
||||
}
|
||||
if data["already_empty"] != true {
|
||||
t.Fatalf("already_empty = %v, want true", data["already_empty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-delete-app"))
|
||||
documentID := "doxcnCoverDelete1"
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{"token": "cover_token_delete_123"}))
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/docx/v1/documents/" + documentID,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := string(patchStub.CapturedBody)
|
||||
if !strings.Contains(body, `"update_cover"`) || !strings.Contains(body, `"cover":null`) {
|
||||
t.Fatalf("PATCH body = %s, want update_cover.cover=null", body)
|
||||
}
|
||||
data := decodeDocResourceOutput(t, stdout).Data
|
||||
if data["deleted"] != true {
|
||||
t.Fatalf("deleted = %v, want true", data["deleted"])
|
||||
}
|
||||
if data["already_empty"] != false {
|
||||
t.Fatalf("already_empty = %v, want false", data["already_empty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-update-app"))
|
||||
documentID := "doxcnCoverUpdate1"
|
||||
fileToken := "file_cover_uploaded_token_12345"
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("cover.png", []byte("png-data"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": fileToken},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
patchStub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/docx/v1/documents/" + documentID,
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
}
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
"--offset-ratio-x", "0.2",
|
||||
"--offset-ratio-y", "0.8",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v — stderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
if !bytes.Contains(uploadStub.CapturedBody, []byte("png-data")) {
|
||||
t.Fatalf("upload body does not contain file bytes")
|
||||
}
|
||||
uploadBody := string(uploadStub.CapturedBody)
|
||||
if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "docx_image") {
|
||||
t.Fatalf("upload body missing docx_image parent type: %s", uploadBody)
|
||||
}
|
||||
if !strings.Contains(uploadBody, "drive_route_token") || !strings.Contains(uploadBody, documentID) {
|
||||
t.Fatalf("upload body missing drive_route_token extra: %s", uploadBody)
|
||||
}
|
||||
patchBody := string(patchStub.CapturedBody)
|
||||
for _, want := range []string{`"update_cover"`, `"token":"` + fileToken + `"`, `"offset_ratio_x":0.2`, `"offset_ratio_y":0.8`} {
|
||||
if !strings.Contains(patchBody, want) {
|
||||
t.Fatalf("PATCH body = %s, missing %s", patchBody, want)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(stderr.String(), fileToken) {
|
||||
t.Fatalf("stderr leaked full file_token: %s", stderr.String())
|
||||
}
|
||||
data := decodeDocResourceOutput(t, stdout).Data
|
||||
if data["file_token"] != fileToken {
|
||||
t.Fatalf("stdout file_token = %v, want %s", data["file_token"], fileToken)
|
||||
}
|
||||
cover := data["cover"].(map[string]interface{})
|
||||
if cover["token"] != fileToken {
|
||||
t.Fatalf("stdout cover.token = %v, want %s", cover["token"], fileToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidate1",
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
"--from-clipboard",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected mutual exclusion error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard")
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidateRequired1",
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing source error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--file", "--from-clipboard", "--url")
|
||||
}
|
||||
|
||||
func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverURLValidate1",
|
||||
"--type", "cover",
|
||||
"--url", "https://127.0.0.1/cover.png",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe URL error, got nil")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestDocCoverURLSyntaxValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
ok bool
|
||||
}{
|
||||
{name: "https", raw: " https://example.com/cover.png ", ok: true},
|
||||
{name: "http", raw: "http://example.com/cover.png"},
|
||||
{name: "userinfo", raw: "https://user:pass@example.com/cover.png"},
|
||||
{name: "empty host", raw: "https:///cover.png"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u, err := parseDocCoverURLSyntax(tc.raw)
|
||||
if tc.ok {
|
||||
if err != nil {
|
||||
t.Fatalf("parseDocCoverURLSyntax() error: %v", err)
|
||||
}
|
||||
if u.String() != "https://example.com/cover.png" {
|
||||
t.Fatalf("URL = %q, want normalized https URL", u.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceCoverDryRunsPlanAPIs(t *testing.T) {
|
||||
downloadRT := docValidateRuntime(t, map[string]string{
|
||||
"doc": "doxcnCoverDryRunDownload",
|
||||
"output": "cover",
|
||||
}, nil, nil)
|
||||
download := decodeDocDryRun(t, DocResourceDownload.DryRun(context.Background(), downloadRT))
|
||||
if len(download.API) != 2 {
|
||||
t.Fatalf("download dry-run API count = %d, want 2", len(download.API))
|
||||
}
|
||||
if got := download.API[0].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDownload" {
|
||||
t.Fatalf("download metadata URL = %q", got)
|
||||
}
|
||||
if got := download.API[1].URL; got != "/open-apis/drive/v1/medias/%3Ccover.token%3E/download" {
|
||||
t.Fatalf("download media URL = %q", got)
|
||||
}
|
||||
|
||||
updateRT := docValidateRuntime(t, map[string]string{
|
||||
"doc": "https://example.larksuite.com/wiki/wikcnCoverDryRunUpdate",
|
||||
"url": "https://example.com/cover.png",
|
||||
}, nil, nil)
|
||||
update := decodeDocDryRun(t, DocResourceUpdate.DryRun(context.Background(), updateRT))
|
||||
if len(update.API) != 3 {
|
||||
t.Fatalf("update dry-run API count = %d, want 3", len(update.API))
|
||||
}
|
||||
if got := update.API[0].URL; got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("wiki resolve URL = %q", got)
|
||||
}
|
||||
if got := update.API[1].URL; got != "/open-apis/drive/v1/medias/upload_all" {
|
||||
t.Fatalf("upload URL = %q", got)
|
||||
}
|
||||
if got := update.API[2].URL; got != "/open-apis/docx/v1/documents/%3Cresolved_docx_token%3E" {
|
||||
t.Fatalf("patch URL = %q", got)
|
||||
}
|
||||
if got := update.API[1].Body["file"]; got != "https://example.com/cover.png" {
|
||||
t.Fatalf("upload source = %#v", got)
|
||||
}
|
||||
|
||||
deleteRT := docValidateRuntime(t, map[string]string{"doc": "doxcnCoverDryRunDelete"}, nil, nil)
|
||||
deleteDry := decodeDocDryRun(t, DocResourceDelete.DryRun(context.Background(), deleteRT))
|
||||
if len(deleteDry.API) != 2 {
|
||||
t.Fatalf("delete dry-run API count = %d, want 2", len(deleteDry.API))
|
||||
}
|
||||
if got := deleteDry.API[1].URL; got != "/open-apis/docx/v1/documents/doxcnCoverDryRunDelete" {
|
||||
t.Fatalf("delete patch URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocResourceCoverDryRunReportsInvalidDoc(t *testing.T) {
|
||||
rt := docValidateRuntime(t, map[string]string{"doc": "https://example.com/sheets/shtxxx"}, nil, nil)
|
||||
dry := DocResourceDownload.DryRun(context.Background(), rt)
|
||||
if got := dry.Format(); !strings.Contains(got, "error:") {
|
||||
t.Fatalf("dry-run error output = %q, want error field", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateDocCoverURLRejectsUnsafeIP(t *testing.T) {
|
||||
_, err := parseAndValidateDocCoverURL(context.Background(), "https://127.0.0.1/cover.png")
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestValidateDocCoverURLHost(t *testing.T) {
|
||||
for _, host := range []string{"", "localhost", "service.localhost", "127.0.0.1"} {
|
||||
t.Run(host, func(t *testing.T) {
|
||||
assertValidationContract(t, validateDocCoverURLHost(context.Background(), host), errs.SubtypeInvalidArgument, "--url")
|
||||
})
|
||||
}
|
||||
if err := validateDocCoverURLHost(context.Background(), "1.1.1.1"); err != nil {
|
||||
t.Fatalf("validateDocCoverURLHost(public IP) error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
|
||||
for _, rawIP := range []string{
|
||||
"10.0.0.1",
|
||||
"127.0.0.1",
|
||||
"169.254.1.1",
|
||||
"172.16.0.1",
|
||||
"192.168.0.1",
|
||||
"100.64.0.1",
|
||||
"198.18.0.1",
|
||||
"240.0.0.1",
|
||||
} {
|
||||
t.Run(rawIP, func(t *testing.T) {
|
||||
if !isUnsafeDocCoverIP(net.ParseIP(rawIP)) {
|
||||
t.Fatalf("%s was classified as safe", rawIP)
|
||||
}
|
||||
})
|
||||
}
|
||||
if isUnsafeDocCoverIP(net.ParseIP("1.1.1.1")) {
|
||||
t.Fatal("public IPv4 address was classified as unsafe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
|
||||
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
baseClient := &http.Client{Transport: baseTransport}
|
||||
|
||||
client := newDocCoverHTTPClient(baseClient)
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("cover URL downloader must not inherit proxy settings")
|
||||
}
|
||||
if baseTransport.Proxy == nil {
|
||||
t.Fatal("base transport proxy was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
|
||||
client := newDocCoverHTTPClient(&http.Client{})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest() error: %v", err)
|
||||
}
|
||||
if err := client.CheckRedirect(req, []*http.Request{{}, {}, {}}); err == nil {
|
||||
t.Fatal("expected too many redirects error")
|
||||
}
|
||||
|
||||
prev, err := http.NewRequest(http.MethodGet, "https://1.1.1.1/start", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(prev) error: %v", err)
|
||||
}
|
||||
downgrade, err := http.NewRequest(http.MethodGet, "http://1.1.1.1/cover.png", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(downgrade) error: %v", err)
|
||||
}
|
||||
if err := client.CheckRedirect(downgrade, []*http.Request{prev}); err == nil {
|
||||
t.Fatal("expected https-to-http redirect error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
|
||||
if err := validateDocCoverConnRemoteIP(nil); err == nil {
|
||||
t.Fatal("expected nil connection error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
|
||||
t.Fatal("expected missing remote address error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
|
||||
t.Fatal("expected invalid remote IP error")
|
||||
}
|
||||
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
|
||||
t.Fatal("expected local remote IP error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverURLFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{raw: "https://example.com/images/cover", ext: ".png", want: "cover.png"},
|
||||
{raw: "https://example.com/images/cover.jpeg", ext: ".png", want: "cover.jpeg"},
|
||||
{raw: "https://example.com/", ext: ".webp", want: "cover.webp"},
|
||||
{raw: "https://example.com/images/%2Fescaped", ext: ".gif", want: "escaped.gif"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
u, err := url.Parse(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q): %v", tc.raw, err)
|
||||
}
|
||||
if got := docCoverURLFileName(u, tc.ext); got != tc.want {
|
||||
t.Fatalf("docCoverURLFileName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLSuccess(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("png-data"))
|
||||
}))
|
||||
|
||||
content, fileName, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadDocCoverURL() error: %v", err)
|
||||
}
|
||||
if string(content) != "png-data" {
|
||||
t.Fatalf("content = %q, want png-data", string(content))
|
||||
}
|
||||
if fileName != "cover.png" {
|
||||
t.Fatalf("fileName = %q, want cover.png", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLRejectsHTTPStatus(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("unavailable"))
|
||||
}))
|
||||
|
||||
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP status error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed problem", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("problem category = %v, want %v", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkServer {
|
||||
t.Fatalf("problem subtype = %v, want %v", p.Subtype, errs.SubtypeNetworkServer)
|
||||
}
|
||||
if p.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("problem code = %v, want %v", p.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.NetworkError", err, err)
|
||||
}
|
||||
if networkErr.Cause == nil {
|
||||
t.Fatal("expected preserved underlying cause, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLRejectsUnsupportedContentType(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("not-image"))
|
||||
}))
|
||||
|
||||
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestDownloadDocCoverURLRejectsOversizeResponse(t *testing.T) {
|
||||
runtime, rawURL := newDocCoverURLTestRuntime(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = io.CopyN(w, repeatByteReader('x'), docCoverURLMaxBytes+1)
|
||||
}))
|
||||
|
||||
_, _, err := downloadDocCoverURL(context.Background(), runtime, rawURL)
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--url")
|
||||
}
|
||||
|
||||
func TestDocCoverMetadataOutputAndOptionalFloats(t *testing.T) {
|
||||
x := 0.25
|
||||
y := 1.0
|
||||
out := docCoverMetadata{
|
||||
Token: "cover_token",
|
||||
OffsetRatioX: &x,
|
||||
OffsetRatioY: &y,
|
||||
}.toOutput()
|
||||
if out["token"] != "cover_token" || out["offset_ratio_x"] != x || out["offset_ratio_y"] != y {
|
||||
t.Fatalf("cover output = %#v", out)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"float": float64(1.5),
|
||||
"int": 2,
|
||||
"int64": int64(3),
|
||||
"text": "4",
|
||||
}
|
||||
if got, ok := getOptionalFloat(data, "float"); !ok || got != 1.5 {
|
||||
t.Fatalf("float optional = %v/%v, want 1.5/true", got, ok)
|
||||
}
|
||||
if got, ok := getOptionalFloat(data, "int"); !ok || got != 2 {
|
||||
t.Fatalf("int optional = %v/%v, want 2/true", got, ok)
|
||||
}
|
||||
if got, ok := getOptionalFloat(data, "int64"); !ok || got != 3 {
|
||||
t.Fatalf("int64 optional = %v/%v, want 3/true", got, ok)
|
||||
}
|
||||
if _, ok := getOptionalFloat(data, "text"); ok {
|
||||
t.Fatal("string optional unexpectedly parsed as float")
|
||||
}
|
||||
if _, ok := getOptionalFloat(nil, "missing"); ok {
|
||||
t.Fatal("nil map optional unexpectedly returned a value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCoverDryRunSource(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
str map[string]string
|
||||
bools map[string]bool
|
||||
want string
|
||||
}{
|
||||
{name: "clipboard", bools: map[string]bool{"from-clipboard": true}, want: "<clipboard image>"},
|
||||
{name: "url", str: map[string]string{"url": "https://example.com/cover.png"}, want: "https://example.com/cover.png"},
|
||||
{name: "file", str: map[string]string{"file": "cover.png"}, want: "@cover.png"},
|
||||
{name: "empty", want: "<cover image>"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := docValidateRuntime(t, tc.str, tc.bools, nil)
|
||||
if got := docCoverDryRunSource(rt); got != tc.want {
|
||||
t.Fatalf("docCoverDryRunSource() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
|
||||
got := map[string]bool{}
|
||||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Command] = true
|
||||
}
|
||||
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("Shortcuts() missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDocCoverURLTestRuntime(t *testing.T, handler http.Handler) (*common.RuntimeContext, string) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-download-app"))
|
||||
targetAddr := server.Listener.Addr().String()
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, network, targetAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return docCoverRemoteAddrConn{
|
||||
Conn: conn,
|
||||
addr: &net.TCPAddr{IP: net.ParseIP("1.1.1.1"), Port: 443},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return &common.RuntimeContext{Factory: f}, "https://1.1.1.1/assets/cover"
|
||||
}
|
||||
|
||||
type docCoverRemoteAddrConn struct {
|
||||
net.Conn
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
|
||||
return c.addr
|
||||
}
|
||||
|
||||
type testAddr string
|
||||
|
||||
func (a testAddr) Network() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
func (a testAddr) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
type repeatByteReader byte
|
||||
|
||||
func (r repeatByteReader) Read(p []byte) (int, error) {
|
||||
for i := range p {
|
||||
p[i] = byte(r)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func docCoverMetadataStub(documentID string, cover map[string]interface{}) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docx/v1/documents/" + documentID,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"cover": cover,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type docResourceOutput struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutput {
|
||||
t.Helper()
|
||||
var out docResourceOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode resource output: %v; output=%s", err, stdout.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
|
||||
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
|
||||
@@ -89,6 +90,9 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
}
|
||||
if lang := resolveFetchLang(runtime); lang != "" {
|
||||
body["lang"] = lang
|
||||
}
|
||||
|
||||
detail := effectiveFetchDetail(runtime)
|
||||
switch detail {
|
||||
@@ -118,6 +122,16 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
if runtime.Changed("lang") {
|
||||
return strings.TrimSpace(runtime.Str("lang"))
|
||||
}
|
||||
if runtime.Config == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(runtime.Config.Lang))
|
||||
}
|
||||
|
||||
// buildReadOption 拼装 read_option JSON;full/空模式返回 nil,让服务端走默认全文路径。
|
||||
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
mode := strings.TrimSpace(runtime.Str("scope"))
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -62,6 +63,47 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesExplicitLang(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
if err := runtime.Cmd.Flags().Set("lang", "en-US"); err != nil {
|
||||
t.Fatalf("set lang: %v", err)
|
||||
}
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["lang"]; got != "en-US" {
|
||||
t.Fatalf("lang = %#v, want %q", got, "en-US")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyUsesRuntimeConfigLang(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["lang"]; got != "zh_cn" {
|
||||
t.Fatalf("lang = %#v, want %q", got, "zh_cn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
runtime.Config = &core.CliConfig{Lang: "zh_cn"}
|
||||
if err := runtime.Cmd.Flags().Set("lang", ""); err != nil {
|
||||
t.Fatalf("set lang: %v", err)
|
||||
}
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if _, ok := body["lang"]; ok {
|
||||
t.Fatalf("did not expect blank explicit lang in fetch body: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -262,6 +304,7 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+fetch"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
@@ -281,6 +324,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
|
||||
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("detail", "simple", "")
|
||||
cmd.Flags().String("lang", "", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("scope", "full", "")
|
||||
cmd.Flags().String("start-block-id", "", "")
|
||||
|
||||
@@ -60,6 +60,9 @@ func Shortcuts() []common.Shortcut {
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
DocMediaDownload,
|
||||
DocResourceDownload,
|
||||
DocResourceUpdate,
|
||||
DocResourceDelete,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "doc-type", Desc: "source document type: doc | docx | sheet | bitable | slides", Required: true, Enum: []string{"doc", "docx", "sheet", "bitable", "slides"}},
|
||||
{Name: "file-extension", Desc: "export format: docx | pdf | xlsx | csv | markdown | base (bitable only) | pptx (slides only)", Required: true, Enum: []string{"docx", "pdf", "xlsx", "csv", "markdown", "base", "pptx"}},
|
||||
{Name: "sub-id", Desc: "sub-table/sheet ID, required when exporting sheet/bitable as csv"},
|
||||
{Name: "only-schema", Type: "bool", Desc: "export only bitable schema when --doc-type bitable --file-extension base"},
|
||||
{Name: "file-name", Desc: "preferred output filename (optional)"},
|
||||
{Name: "output-dir", Default: ".", Desc: "local output directory (default: current directory)"},
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
@@ -44,6 +45,7 @@ var DriveExport = common.Shortcut{
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
})
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -52,6 +54,7 @@ var DriveExport = common.Shortcut{
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
@@ -78,6 +81,9 @@ var DriveExport = common.Shortcut{
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
@@ -95,6 +101,7 @@ var DriveExport = common.Shortcut{
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
|
||||
@@ -34,6 +34,7 @@ type driveExportSpec struct {
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OnlySchema bool
|
||||
}
|
||||
|
||||
// driveExportTaskResultCommand prints the resume command shown when bounded
|
||||
@@ -150,6 +151,10 @@ func validateDriveExportSpec(spec driveExportSpec) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension base only supports --doc-type bitable")
|
||||
}
|
||||
|
||||
if spec.OnlySchema && (spec.DocType != "bitable" || spec.FileExtension != "base") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--only-schema is only used when exporting bitable as base").WithParam("--only-schema")
|
||||
}
|
||||
|
||||
if spec.FileExtension == "pptx" && spec.DocType != "slides" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-extension pptx only supports --doc-type slides")
|
||||
}
|
||||
@@ -185,6 +190,9 @@ func createDriveExportTask(runtime *common.RuntimeContext, spec driveExportSpec)
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/export_tasks", nil, body)
|
||||
if err != nil {
|
||||
|
||||
@@ -51,6 +51,15 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
name: "base bitable ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base"},
|
||||
},
|
||||
{
|
||||
name: "base bitable only schema ok",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "base", OnlySchema: true},
|
||||
},
|
||||
{
|
||||
name: "only schema non base rejected",
|
||||
spec: driveExportSpec{Token: "base123", DocType: "bitable", FileExtension: "xlsx", OnlySchema: true},
|
||||
wantErr: "--only-schema is only used",
|
||||
},
|
||||
{
|
||||
name: "slides pptx ok",
|
||||
spec: driveExportSpec{Token: "slides123", DocType: "slides", FileExtension: "pptx"},
|
||||
@@ -612,6 +621,7 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
"--token", "bitable123",
|
||||
"--doc-type", "bitable",
|
||||
"--file-extension", "base",
|
||||
"--only-schema",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
@@ -628,6 +638,9 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
|
||||
if createBody["type"] != "bitable" {
|
||||
t.Fatalf("export_tasks body type = %v, want %q", createBody["type"], "bitable")
|
||||
}
|
||||
if createBody["only_schema"] != true {
|
||||
t.Fatalf("export_tasks body only_schema = %v, want true", createBody["only_schema"])
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "crm.base"))
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,8 @@ var DriveImport = common.Shortcut{
|
||||
"docs:document.media:upload",
|
||||
"docs:document:import",
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
|
||||
@@ -61,6 +62,7 @@ var DriveImport = common.Shortcut{
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
@@ -87,6 +89,9 @@ var DriveImport = common.Shortcut{
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
|
||||
@@ -257,6 +257,46 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendDriveImportFolderTokenWikiCheckDryRun(dry *common.DryRunAPI, spec driveImportSpec) {
|
||||
folderToken := strings.TrimSpace(spec.FolderToken)
|
||||
if folderToken == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[0] Validate whether --folder-token is a wiki node").
|
||||
Params(map[string]interface{}{"token": folderToken})
|
||||
}
|
||||
|
||||
func rejectDriveImportWikiFolderToken(runtime *common.RuntimeContext, folderToken string) error {
|
||||
folderToken = strings.TrimSpace(folderToken)
|
||||
if folderToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": folderToken},
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
node := common.GetMap(data, "node")
|
||||
if len(node) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node",
|
||||
).
|
||||
WithParam("--folder-token").
|
||||
WithHint("Pass a Drive folder token, or omit --folder-token to import into the Drive root folder. Wiki node tokens are not accepted as import mount folders.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// driveImportStatus captures the backend fields needed to decide whether the
|
||||
// import can be surfaced immediately or requires a follow-up poll.
|
||||
type driveImportStatus struct {
|
||||
|
||||
@@ -5,10 +5,12 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
@@ -276,6 +278,134 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsWikiFolderToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"node_token": "wikcnImportTarget",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxImportTarget",
|
||||
"title": "Wiki Import Target",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "notes.md",
|
||||
"--type", "docx",
|
||||
"--folder-token", "wikcnImportTarget",
|
||||
"--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki folder-token validation error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if validationErr.Param != "--folder-token" {
|
||||
t.Fatalf("param = %q, want --folder-token", validationErr.Param)
|
||||
}
|
||||
wantMessage := "--folder-token only supports Drive folder tokens, but the provided token resolves to a wiki node"
|
||||
if validationErr.Message != wantMessage {
|
||||
t.Fatalf("message = %q, want %q", validationErr.Message, wantMessage)
|
||||
}
|
||||
for _, disallowed := range []string{"node_token=", "obj_type=", "Wiki Import Target"} {
|
||||
if strings.Contains(validationErr.Message, disallowed) {
|
||||
t.Fatalf("message = %q, must not contain %q", validationErr.Message, disallowed)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "Drive folder token") {
|
||||
t.Fatalf("hint = %q, want Drive folder token guidance", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportContinuesWhenFolderTokenDoesNotResolveAsWiki(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1310001,
|
||||
"msg": "node not found",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import_folder"},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "docx",
|
||||
"job_status": 0,
|
||||
"token": "docx_imported",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "notes.md",
|
||||
"--type", "docx",
|
||||
"--folder-token", "fldcnImportTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got := data["token"]; got != "docx_imported" {
|
||||
t.Fatalf("token = %#v, want docx_imported", got)
|
||||
}
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
point, _ := body["point"].(map[string]interface{})
|
||||
if got := point["mount_key"]; got != "fldcnImportTarget" {
|
||||
t.Fatalf("import mount_key = %#v, want fldcnImportTarget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -114,16 +114,20 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(got.API))
|
||||
if len(got.API) != 4 {
|
||||
t.Fatalf("expected 4 API calls, got %d", len(got.API))
|
||||
}
|
||||
|
||||
uploadName, _ := got.API[0].Body["file_name"].(string)
|
||||
if got.API[0].Body != nil {
|
||||
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)
|
||||
}
|
||||
|
||||
uploadName, _ := got.API[1].Body["file_name"].(string)
|
||||
if uploadName != "base-import.xlsx" {
|
||||
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
|
||||
}
|
||||
|
||||
importName, _ := got.API[1].Body["file_name"].(string)
|
||||
importName, _ := got.API[2].Body["file_name"].(string)
|
||||
if importName != "base-import" {
|
||||
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ func Shortcuts() []common.Shortcut {
|
||||
VCRecording,
|
||||
VCMeetingJoin,
|
||||
VCMeetingLeave,
|
||||
VCMeetingListActive,
|
||||
VCMeetingEvents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ var VCMeetingEvents = common.Shortcut{
|
||||
Description: "List bot meeting events by meeting ID",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
|
||||
@@ -156,6 +156,9 @@ func validateMeetingEventsMeetingID(meetingID string) error {
|
||||
if meetingID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id is required").WithParam("--meeting-id")
|
||||
}
|
||||
if validMeetingNumber(meetingID) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a long meeting_id, not a 9-digit meeting number; use +meeting-join or +meeting-list-active to get meeting_id").WithParam("--meeting-id")
|
||||
}
|
||||
value, err := strconv.ParseInt(meetingID, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a positive integer, got %q", meetingID).WithParam("--meeting-id")
|
||||
|
||||
@@ -262,6 +262,26 @@ func TestMeetingEvents_Validation_InvalidMeetingID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_RejectsMeetingNumber(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "732067044")
|
||||
|
||||
err := VCMeetingEvents.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for 9-digit meeting number")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a 9-digit meeting number") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--meeting-id" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingEvents_Validation_InvalidTimeRange(t *testing.T) {
|
||||
runtime := newMeetingEventsRuntime()
|
||||
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
|
||||
@@ -818,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) {
|
||||
for _, shortcut := range got {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-events"}
|
||||
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"}
|
||||
if !reflect.DeepEqual(commands, want) {
|
||||
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ var VCMeetingJoin = common.Shortcut{
|
||||
Description: "Join a meeting by meeting number (bot join)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-number", Required: true, Desc: "meeting number to join"},
|
||||
|
||||
@@ -20,7 +20,7 @@ var VCMeetingLeave = common.Shortcut{
|
||||
Description: "Leave a meeting by meeting ID",
|
||||
Risk: "write",
|
||||
Scopes: []string{"vc:meeting.bot.join:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-id", Required: true, Desc: "meeting ID to leave"},
|
||||
|
||||
121
shortcuts/vc/vc_meeting_list_active.go
Normal file
121
shortcuts/vc/vc_meeting_list_active.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const vcMeetingListActiveAPIPath = "/open-apis/vc/v1/bots/user_active_meeting"
|
||||
|
||||
// VCMeetingListActive lists meetings the current or target user is actively in.
|
||||
var VCMeetingListActive = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+meeting-list-active",
|
||||
Description: "List active meetings for the current identity or target user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "target user ID when using bot identity"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateMeetingListActiveUserID(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params, err := buildMeetingListActiveParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
dryRun := common.NewDryRunAPI().GET(vcMeetingListActiveAPIPath)
|
||||
if len(params) > 0 {
|
||||
dryRun.Params(params)
|
||||
}
|
||||
return dryRun
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params, err := buildMeetingListActiveParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingListActiveAPIPath, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
meetings := common.GetSlice(data, "meetings")
|
||||
runtime.OutFormat(data, &output.Meta{Count: len(meetings)}, func(w io.Writer) {
|
||||
if len(meetings) == 0 {
|
||||
fmt.Fprintln(w, "No active meetings.")
|
||||
return
|
||||
}
|
||||
displayedMeetings := 0
|
||||
for _, raw := range meetings {
|
||||
meeting, _ := raw.(map[string]interface{})
|
||||
if meeting == nil {
|
||||
continue
|
||||
}
|
||||
if displayedMeetings > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
displayedMeetings++
|
||||
title := common.GetString(meeting, "meeting_title")
|
||||
if title == "" {
|
||||
title = "Untitled meeting"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\n", title)
|
||||
if id := common.GetString(meeting, "meeting_id"); id != "" {
|
||||
fmt.Fprintf(w, " Meeting ID: %s\n", id)
|
||||
}
|
||||
if no := common.GetString(meeting, "meeting_no"); no != "" {
|
||||
fmt.Fprintf(w, " Meeting No: %s\n", no)
|
||||
}
|
||||
}
|
||||
if displayedMeetings > 1 {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateMeetingListActiveUserID validates the target user only for bot identity.
|
||||
func validateMeetingListActiveUserID(runtime *common.RuntimeContext) error {
|
||||
if !runtime.IsBot() {
|
||||
return nil
|
||||
}
|
||||
userID := strings.TrimSpace(runtime.Str("user-id"))
|
||||
if userID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id is required when --as bot").WithParam("--user-id")
|
||||
}
|
||||
if _, err := common.ValidateUserIDTyped("--user-id", userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMeetingListActiveParams builds the query params for active meeting lookup.
|
||||
func buildMeetingListActiveParams(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
if err := validateMeetingListActiveUserID(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if runtime.IsBot() {
|
||||
userID := strings.TrimSpace(runtime.Str("user-id"))
|
||||
params["user_id"] = userID
|
||||
}
|
||||
return params, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -589,6 +591,335 @@ func TestMeetingLeave_Execute_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/open-apis/vc/v1/bots/user_active_meeting") {
|
||||
t.Errorf("dry-run should include API path, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "user_id") {
|
||||
t.Errorf("user identity should not send user_id by default, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ScopeMatchesEventReadPermission(t *testing.T) {
|
||||
if len(VCMeetingListActive.Scopes) != 1 || VCMeetingListActive.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Fatalf("scopes = %#v, want [vc:meeting.meetingevent:read]", VCMeetingListActive.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_UserIdentityIgnoresUserID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--dry-run", "--as", "user", "--user-id", "not-open-id",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "user_id") {
|
||||
t.Errorf("user identity should not send user_id, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_UserIdentityIgnoresInvalidUserID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
var gotUserID string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotUserID = req.URL.Query().Get("user_id")
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"meetings": []interface{}{}},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--as", "user", "--user-id", "not-open-id", "--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotUserID != "" {
|
||||
t.Fatalf("user identity should not send user_id, got %q", gotUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Validate_BotRequiresUserID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{"+meeting-list-active", "--as", "bot"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --as bot omits --user-id")
|
||||
}
|
||||
assertMeetingListActiveUserIDValidationError(t, err)
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Validate_UserIDOpenIDFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--as", "bot", "--user-id", "300",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-open_id user-id")
|
||||
}
|
||||
assertMeetingListActiveUserIDValidationError(t, err)
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_BotPassesUserID(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
var gotUserID string
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotUserID = req.URL.Query().Get("user_id")
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"meetings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9001",
|
||||
"meeting_no": "123456789",
|
||||
"meeting_title": "Standup",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--user-id", "ou_300",
|
||||
"--format", "json", "--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotUserID != "ou_300" {
|
||||
t.Fatalf("user_id query = %q, want ou_300", gotUserID)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse stdout: %v", err)
|
||||
}
|
||||
data, _ := resp["data"].(map[string]any)
|
||||
meetings, _ := data["meetings"].([]any)
|
||||
if len(meetings) != 1 {
|
||||
t.Fatalf("meetings = %d, want 1 (envelope: %s)", len(meetings), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_BotValidationErrorEnvelope(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+meeting-list-active"}
|
||||
cmd.Flags().String("user-id", "", "")
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
|
||||
|
||||
dry := VCMeetingListActive.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal dry-run output: %v", err)
|
||||
}
|
||||
got := string(raw)
|
||||
if !strings.Contains(got, "--user-id") {
|
||||
t.Fatalf("dry-run error = %q, want user-id validation", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_DryRun_BotSendsUserID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--dry-run", "--as", "bot", "--user-id", "ou_300",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "user_id") || !strings.Contains(stdout.String(), "ou_300") {
|
||||
t.Fatalf("dry-run should include user_id=ou_300, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_ValidationError(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "+meeting-list-active"}
|
||||
cmd.Flags().String("user-id", "", "")
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, defaultConfig(), core.AsBot)
|
||||
|
||||
err := VCMeetingListActive.Execute(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
assertMeetingListActiveUserIDValidationError(t, err)
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ExecutePretty_Empty(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No active meetings.") {
|
||||
t.Fatalf("pretty output = %q, want empty-state message", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ExecutePretty_SingleMeetingNoSelectionPrompt(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"meetings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9001",
|
||||
"meeting_no": "123456789",
|
||||
"meeting_title": "Standup",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Standup", "Meeting ID: 9001", "Meeting No: 123456789"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "Multiple active meetings found") {
|
||||
t.Fatalf("single meeting should not show selection prompt: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_ExecutePretty_MultipleMeetings(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"meetings": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9001",
|
||||
"meeting_no": "123456789",
|
||||
"meeting_title": "Standup",
|
||||
},
|
||||
"ignored",
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9002",
|
||||
"meeting_no": "987654321",
|
||||
"meeting_title": "Planning",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"meeting_id": "9003",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "pretty", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Standup",
|
||||
"Meeting ID: 9001",
|
||||
"Meeting No: 123456789",
|
||||
"Planning",
|
||||
"Meeting ID: 9002",
|
||||
"Meeting No: 987654321",
|
||||
"Untitled meeting",
|
||||
"Meeting ID: 9003",
|
||||
"Multiple active meetings found. Ask the user to choose one meeting_id before calling +meeting-events.",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingListActive_Execute_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/bots/user_active_meeting",
|
||||
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, VCMeetingListActive, []string{
|
||||
"+meeting-list-active", "--format", "json", "--as", "user",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryAuthorization {
|
||||
t.Fatalf("error problem = (%+v, %t), want authorization problem", p, ok)
|
||||
} else if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("error subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||
} else if p.Code != 121005 {
|
||||
t.Fatalf("error code = %d, want 121005", p.Code)
|
||||
}
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMeetingListActiveUserIDValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--user-id" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--user-id")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed error lock assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help"
|
||||
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs resource-download --help; lark-cli docs resource-update --help; lark-cli docs resource-delete --help"
|
||||
---
|
||||
|
||||
# docs (v2)
|
||||
@@ -45,6 +45,8 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs resource-download/resource-update/resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
|
||||
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
|
||||
@@ -71,6 +73,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
| [`resource-download` / `resource-update` / `resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
|
||||
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
|
||||
|
||||
## 不在本 Skill 范围
|
||||
|
||||
@@ -124,6 +124,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
- `<img>` / `<source>` 带 `url` 时,直接用该 URL 下载即可(普通 HTTP GET),无需走 shortcut。
|
||||
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
|
||||
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut) → `docs +media-download --token <token> --output ./downloaded_media`
|
||||
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs resource-download/resource-update/resource-delete --type cover`
|
||||
|
||||
## 嵌入电子表格 / 多维表格
|
||||
|
||||
@@ -134,4 +135,5 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
- [lark-doc-create](lark-doc-create.md) — 创建文档
|
||||
- [lark-doc-update](lark-doc-update.md) — 更新文档
|
||||
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
|
||||
- [lark-doc-resource-cover](lark-doc-resource-cover.md) — 读取、更新、删除文档封面图
|
||||
|
||||
70
skills/lark-doc/references/lark-doc-resource-cover.md
Normal file
70
skills/lark-doc/references/lark-doc-resource-cover.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# docs resource-*(Docx 封面图资源)
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
Docx 封面图不是正文里的 `<img token="...">` 素材块。读取、更新、删除文档封面图时,使用 `docs resource-download/resource-update/resource-delete --type cover`,不要使用 `+media-insert` 或 `+media-download --token <cover.token>` 让用户手动拼步骤。
|
||||
|
||||
## 选择规则
|
||||
|
||||
- 用户要下载文档封面图:`docs resource-download --type cover`
|
||||
- 用户要设置/替换文档封面图:`docs resource-update --type cover`
|
||||
- 用户要删除文档封面图:`docs resource-delete --type cover`
|
||||
- 用户要下载正文图片、附件、画板缩略图:继续使用 [`docs +media-download`](lark-doc-media-download.md)
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 下载封面图。CLI 会先读取 document.cover.token,再下载图片内容并保存到本地。
|
||||
lark-cli docs resource-download --doc doxcnXXX --type cover --output ./cover
|
||||
|
||||
# 使用本地文件更新封面图。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png
|
||||
|
||||
# 使用剪切板图片更新封面图。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --from-clipboard
|
||||
|
||||
# 使用 HTTPS URL 更新封面图。CLI 会先下载 URL 内容,再上传并写入 cover.token。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --url "https://example.com/cover.png"
|
||||
|
||||
# 可选:设置封面图裁切偏移。
|
||||
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png --offset-ratio-x 0.2 --offset-ratio-y 0.8
|
||||
|
||||
# 删除封面图;当文档本来没有封面图时也成功返回。
|
||||
lark-cli docs resource-delete --doc doxcnXXX --type cover
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| all | `--doc <id>` | 是 | 文档 ID、docx URL,或可解析为 docx 的 wiki URL |
|
||||
| all | `--type cover` | 否 | 当前只支持 `cover`;默认值也是 `cover` |
|
||||
| download | `--output <path>` | 是 | 本地保存路径;不带扩展名会根据响应类型自动补全 |
|
||||
| download | `--overwrite` | 否 | 覆盖已存在的输出文件 |
|
||||
| update | `--file <path>` | 三选一 | 磁盘上的真实图片文件;大于 20MiB 自动使用分片上传 |
|
||||
| update | `--from-clipboard` | 三选一 | 从系统剪切板读取图片 |
|
||||
| update | `--url <https-url>` | 三选一 | 从 HTTPS URL 下载图片后上传 |
|
||||
| update | `--offset-ratio-x <number>` | 否 | 视图相对原图中心的横向偏移比例:水平偏移 px / 原图宽度 px;0 为居中,正数向右,负数向左 |
|
||||
| update | `--offset-ratio-y <number>` | 否 | 视图相对原图中心的纵向偏移比例:垂直偏移 px / 原图高度 px;0 为居中,正数向上,负数向下 |
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `resource-download` 成功时 stdout JSON 的 `data` 包含 `document_id`、`type`、`saved_path`、`size_bytes`、`content_type`、`cover.token`。如果文档没有封面图,命令失败退出,错误包含 `document has no cover` 和脱敏 `document_id`,不会创建输出文件。
|
||||
- `resource-update` 成功时 stdout JSON 的 `data` 包含完整 `file_token` 和 `cover.token`;stderr 只打印脱敏 token。
|
||||
- `resource-delete` 成功时 stdout JSON 的 `data.deleted` 表示本次是否真的发起删除,`data.already_empty` 表示删除前是否没有封面图。空封面图是幂等成功,不报错。
|
||||
|
||||
## URL 来源安全边界
|
||||
|
||||
`resource-update --url` 只用于下载公开 HTTPS 图片:
|
||||
|
||||
- 只允许 `https://`,拒绝 HTTP、空 host 和 URL userinfo。
|
||||
- 拒绝解析到 private、loopback、link-local、multicast、unspecified 地址的 host。
|
||||
- 最多跟随 3 次跳转,每次跳转都重新校验 URL。
|
||||
- 响应 `Content-Type` 只允许 `image/png`、`image/jpeg`、`image/gif`、`image/webp`。
|
||||
- 响应体最大 20MiB。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-doc-media-download](lark-doc-media-download.md) — 下载正文素材或画板缩略图
|
||||
- [lark-doc-media-insert](lark-doc-media-insert.md) — 在正文插入图片/文件
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
@@ -76,6 +76,14 @@ lark-cli drive +export \
|
||||
--file-extension base \
|
||||
--output-dir ./exports
|
||||
|
||||
# 导出多维表格结构为 .base 快照(仅导出表结构,不导出记录数据)
|
||||
lark-cli drive +export \
|
||||
--token "<BITABLE_TOKEN>" \
|
||||
--doc-type bitable \
|
||||
--file-extension base \
|
||||
--only-schema \
|
||||
--output-dir ./exports
|
||||
|
||||
# 允许覆盖已存在文件
|
||||
lark-cli drive +export \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
@@ -92,6 +100,7 @@ lark-cli drive +export \
|
||||
| `--doc-type` | 是 | 源文档类型:`doc` / `docx` / `sheet` / `bitable` / `slides` |
|
||||
| `--file-extension` | 是 | 导出格式:`docx` / `pdf` / `xlsx` / `csv` / `markdown` / `base` / `pptx` |
|
||||
| `--sub-id` | 条件必填 | 当 `sheet` / `bitable` 导出为 `csv` 时必填 |
|
||||
| `--only-schema` | 否 | 仅当 `--doc-type bitable --file-extension base` 时可用;只导出多维表格结构,不导出记录数据 |
|
||||
| `--file-name` | 否 | 覆盖默认本地文件名;如未带扩展名,会按 `--file-extension` 自动补齐 |
|
||||
| `--output-dir` | 否 | 本地输出目录,默认当前目录 |
|
||||
| `--overwrite` | 否 | 覆盖已存在文件 |
|
||||
@@ -100,6 +109,7 @@ lark-cli drive +export \
|
||||
|
||||
- `markdown` 只支持 `docx`
|
||||
- `base` 只支持 `bitable`
|
||||
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
|
||||
- `pptx` 只支持 `slides`
|
||||
- `slides` 支持导出为 `pptx` / `pdf`
|
||||
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-vc-agent
|
||||
version: 1.0.0
|
||||
description: "飞书视频会议:让机器人代当前用户加入/离开正在进行的会议,并读取会议期间的实时事件(参会人加入与离开、发言、聊天、屏幕共享等)。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提:机器人只能读到它自己参会过且仍在进行中的会议的事件;查询已结束会议的参会名单、纪要或逐字稿请使用 lark-vc 技能。"
|
||||
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件,如参会人加入/离开、发言、聊天、屏幕共享。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询,这些使用 lark-vc 技能。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -10,7 +10,7 @@ metadata:
|
||||
|
||||
# vc-agent (v1)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取以下两份 skill 文档:**
|
||||
相关技能:
|
||||
|
||||
- [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、身份切换、权限处理
|
||||
- [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) — 视频会议的核心概念(Meeting / Note / Minutes 等),本 skill 直接复用,不再重复定义
|
||||
@@ -18,7 +18,7 @@ metadata:
|
||||
## 内测提示
|
||||
|
||||
- 当前功能正在内测中,仅少数用户可用。忽略 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 中的权限申请流程,以下方的指引为准。
|
||||
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要申请对应权限,而是提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。
|
||||
- 如果 `lark-cli` 提示 `missing required scope(s)` / `permission_violations`,不要走普通权限申请流程;先提示用户加入早鸟群确认内测权限已开通,再按“应用身份权限配置检查”处理应用权限、安装和数据范围。
|
||||
- 如果 `lark-cli` 返回 `error.code=20017` / `ErrNotInGray`,提示用户加入早鸟群:`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`。
|
||||
|
||||
## 定位
|
||||
@@ -33,61 +33,99 @@ metadata:
|
||||
| 用户意图示例 | 应路由到 |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
|
||||
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**,且**机器人已入会**) | **本 skill** `+meeting-events` |
|
||||
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"(**进行中会议**) | **本 skill** `+meeting-events` |
|
||||
| "我/某个用户现在在哪个会里"、"给我找当前可拉事件的 meeting_id" | **本 skill** `+meeting-list-active` |
|
||||
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
|
||||
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
|
||||
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill(入会 → 读事件)→ 会议结束后用 [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md) 拉纪要 → [`lark-im`](../lark-im/SKILL.md) 发群 |
|
||||
|
||||
## 身份路由
|
||||
|
||||
不要向用户暴露内部身份缩写;对用户只说“用户身份”或“应用身份”。
|
||||
|
||||
| 场景 | 使用身份 | 关键规则 |
|
||||
| ---- | -------- | -------- |
|
||||
| 查询当前登录用户正在参加的会议 | `--as user` | 不传 `--user-id`;拿到的 `meeting_id` 后续继续用 `--as user` 读事件 |
|
||||
| 查询目标用户且应用机器人也在会中的会议 | `--as bot --user-id <user_open_id>` | `--user-id` 必须是 `ou_...`;拿到的 `meeting_id` 后续继续用 `--as bot` 读事件 |
|
||||
| 用户明确要求应用机器人入会/旁听/代参会 | `--as bot` | 这是写操作,会真实产生入会记录;返回的 `meeting.id` 后续继续用 `--as bot` |
|
||||
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
|
||||
|
||||
## 核心场景
|
||||
|
||||
### 1. 加入正在进行的会议(写操作)
|
||||
|
||||
1. 只有用户明确表达"让 Agent **真实入会**"(参会机器人、会中助手、代为旁听、代参会)时才用 `+meeting-join`。只是查数据不要入会。
|
||||
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`。
|
||||
2. `+meeting-join --meeting-number` 只接受 **9 位纯数字**会议号,不是会议链接整串、也不是 `meeting_id`。如果用户只是给了 9 位会议号并询问会中内容,先按 `+meeting-list-active` 的会议号匹配流程找 `meeting_id`,不要直接入会。
|
||||
3. 返回体中的 `meeting.id` **必须立刻记录**——后续 `+meeting-events` / `+meeting-leave` 都靠它,**不能用 9 位会议号替代**。
|
||||
4. 入会对所有参会人可见,执行前核实 9 位会议号来源,避免误入错会。
|
||||
5. 仅支持 `user` 身份,需提前 `lark-cli auth login`。
|
||||
5. 使用应用身份 `--as bot` 执行真实入会;不要用当前登录用户身份尝试让应用机器人入会。
|
||||
6. 若入会失败,优先查看 `+meeting-join` reference 的错误排查段落,重点确认会议号、密码、会议状态、等候室 / 审批以及会议是否禁止当前身份加入。
|
||||
|
||||
### 2. 感知会中事件(读操作)
|
||||
|
||||
1. 用户要看"会议里正在发生什么"(参会人加入/离开、聊天、转写、屏幕共享)时,用 `+meeting-events`。
|
||||
2. 输入是 **`meeting_id`**(长数字 ID),不是 9 位会议号。
|
||||
3. Bot 必须**真实参会过**(先 `+meeting-join`),否则事件流通常不可见。具体的状态边界、结束后宽限窗口与错误码(如 `10005 / 20001 / 20002`)请查看 `+meeting-events` reference。
|
||||
3. 不依赖默认身份。`meeting_id` 来自用户身份发现时,继续用 `--as user`;来自应用身份发现或 `+meeting-join` 时,继续用 `--as bot`。身份不一致会导致空结果或权限错误。
|
||||
4. **不能做会后复盘**,**不能替代参会人快照查询**。如果会议已结束:
|
||||
- 想拿纪要文档或逐字稿文档 token:用 `lark-cli vc +notes --meeting-ids <meeting.id>`
|
||||
- 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
- 先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。
|
||||
- 再根据 `note_display_type`、`note_id`、`minute_token` 和用户意图,按 [`lark-vc`](../lark-vc/SKILL.md) 的产物决策读取正文、逐字稿或妙记。
|
||||
- 想看参会人快照:用 `vc meeting get --with-participants`(见 [`lark-vc`](../lark-vc/SKILL.md))
|
||||
5. **默认必须使用** **`--page-all`**,除非用户明确要求“只查一页”,或确实需要控制返回体大小。
|
||||
6. 输出格式默认优先 `--format pretty`(时间线更易读);只有在需要完整保留原始消息流与结构化字段时,才使用 `--format json`。
|
||||
7. **必须识别分页信号**:只要响应里出现 `has_more=true`、pretty 里的 `more available`,或返回了非空 `page_token`,就不能把当前结果当作完整事件流;默认应继续分页,或明确告诉用户当前只是部分结果。
|
||||
8. 保留响应里的 `page_token`,下次增量拉取直接续,不要从头再拉。
|
||||
9. **只要你是基于** **`+meeting-events`** **来回答一场正在进行中的会议内容,就不能直接复用旧结果。** 无论用户是在问“现在/刚刚/最新”的状态,还是让你“总结一下这个会议讲什么”,都必须先重新拉一次当前事件流,确认拿到的是最新信息,再基于最新结果回答。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
|
||||
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings,并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`。
|
||||
|
||||
### 3. 离开会议(写操作)
|
||||
|
||||
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`;不要把任务完成当作离会指令。
|
||||
2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`,**不接受 9 位会议号**。
|
||||
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,才用应用身份执行 `+meeting-leave --as bot --meeting-id <长数字 meeting_id>`;不应因任务完成而执行离会。
|
||||
2. `--meeting-id` **必须**是长数字会议 ID,通常来自 `+meeting-join` 返回的 `meeting.id`,也可以来自应用身份 `+meeting-list-active` 返回的 `meeting_id`。如果来自 list-active,必须确认应用机器人当前就在该会中。**不接受 9 位会议号**。
|
||||
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
|
||||
4. 仅支持 `user` 身份。
|
||||
4. 使用与入会或 active meeting 发现相同的应用身份离会。
|
||||
|
||||
### 4. Agent 参会示范
|
||||
### 4. 获取当前可用的进行中会议 ID(读操作)
|
||||
|
||||
1. `+meeting-list-active` 用来发现当前进行中的会议,并拿到后续 `+meeting-events` 需要的长数字 `meeting_id`。
|
||||
2. 用户身份:`lark-cli vc +meeting-list-active --as user --format json`,用于发现当前登录用户正在参加的会议;后续 `+meeting-events` 继续 `--as user`。
|
||||
3. 应用身份:`lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json`,`--user-id` 必须是目标用户 open_id,即 `ou_...`;返回该用户当前正在参加且应用机器人也在会中的会议。它不是全量会议搜索接口。后续 `+meeting-events` 继续 `--as bot`。
|
||||
4. 如果返回空,先按当前身份解释:用户身份下表示当前用户没有可见的进行中会议;应用身份下表示没有找到“目标用户在会中且应用机器人也在会中”的当前会。
|
||||
5. 如果返回多个会议,不要自动任选一个;按 `meeting_title` / `meeting_no` / `meeting_id` 展示候选,等待用户明确选择后再调用 `+meeting-events`。
|
||||
6. 如果用户给了 9 位会议号,先在 active meeting 结果中按 `meeting_no` 匹配。匹配失败时,不要自动入会;只有用户明确要求应用机器人真实入会时,才询问或执行 `+meeting-join`。
|
||||
|
||||
### 5. Agent 参会示范
|
||||
|
||||
```bash
|
||||
# 1. 入会,捕获 meeting.id
|
||||
JOIN=$(lark-cli vc +meeting-join --meeting-number 123456789 --format json)
|
||||
JOIN=$(lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json)
|
||||
MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
|
||||
|
||||
# 2. 会中轮询事件
|
||||
# 默认用 --page-all 拉全当前可见事件;下次增量优先复用 page_token
|
||||
# 典型间隔 10-30 秒
|
||||
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id "$MID" --page-all --format pretty
|
||||
|
||||
# 3. 会后可选:取纪要 / 逐字稿(跨到 lark-vc)
|
||||
# 3. 会后可选:进入 lark-vc 获取会议产物信息,再按 note_display_type / minute_token 决策读取
|
||||
lark-cli vc +notes --meeting-ids "$MID"
|
||||
```
|
||||
|
||||
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --meeting-id "$MID"`。
|
||||
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --as bot --meeting-id "$MID"`。
|
||||
|
||||
如果已经知道目标用户 `open_id`,且 bot 已在会中,也可以先发现当前会:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
如果只是回答当前登录用户所在会议发生了什么,使用用户身份一路查:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -96,20 +134,31 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
|
||||
| Shortcut | 类型 | 说明 |
|
||||
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
|
||||
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
|
||||
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List bot meeting events (participant joined/left, transcript, chat, share) |
|
||||
| [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md) | 读 | List active meetings and discover meeting_id for event reads |
|
||||
| [`+meeting-events`](references/lark-vc-agent-meeting-events.md) | 读 | List meeting events visible to the app agent (participant joined/left, transcript, chat, share) |
|
||||
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by meeting\_id |
|
||||
|
||||
- 使用 `+meeting-join` 前**必须**阅读 [references/lark-vc-agent-meeting-join.md](references/lark-vc-agent-meeting-join.md),了解入参格式与写操作可见性风险。
|
||||
- 使用 `+meeting-events` 前**必须**阅读 [references/lark-vc-agent-meeting-events.md](references/lark-vc-agent-meeting-events.md),了解 `meeting_id` 来源、分页、错误码(10005 / 20001 / 20002)与 "bot 仍在会中" 硬约束。
|
||||
- 使用 `+meeting-leave` 前**必须**阅读 [references/lark-vc-agent-meeting-leave.md](references/lark-vc-agent-meeting-leave.md),了解 `meeting_id` 的来源与写操作可见性。
|
||||
- [`+meeting-join`](references/lark-vc-agent-meeting-join.md):入参格式、写操作可见性风险、入会失败排查。
|
||||
- [`+meeting-list-active`](references/lark-vc-agent-meeting-list-active.md):用户身份和应用身份的不同返回范围。
|
||||
- [`+meeting-events`](references/lark-vc-agent-meeting-events.md):`meeting_id` 来源、身份延续、分页和错误码(10005 / 20001 / 20002)。
|
||||
- [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md):`meeting_id` 的来源与写操作可见性。
|
||||
|
||||
## 权限表
|
||||
## 应用身份权限配置检查
|
||||
|
||||
| Shortcut | 所需 scope |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `+meeting-join` | `vc:meeting.bot.join:write` |
|
||||
| `+meeting-events` | `vc:meeting.meetingevent:read` |
|
||||
| `+meeting-leave` | `vc:meeting.bot.join:write` |
|
||||
应用身份 `--as bot` 报 `no permission`、`missing required scope(s)`、`permission_violations`、`ErrNotInGray` 或 `20017` 时,不要引导用户执行 `auth login`。按顺序检查:
|
||||
|
||||
1. 以 CLI 返回的 metadata / error envelope 为准,确认提示的 VC Agent 相关权限已开通。常见读取 active meeting / events 需要会中事件读取权限;应用机器人入会 / 离会需要 bot 入会写权限。
|
||||
2. 应用已发布并安装到当前租户。
|
||||
3. 开放平台“权限可访问的数据范围”已开通并保存。
|
||||
4. 数据范围选择“按条件筛选”,条件配置为:**会议的归属者 包含 与应用的可用范围一致**。
|
||||
5. 如果 scope、安装和数据范围都正确,仍返回 `ErrNotInGray` / `20017`,再按 VC Agent 内测 privilege / 灰度白名单处理,提示加入早鸟群或联系平台同学开通。
|
||||
|
||||
## 用户身份被拒绝时
|
||||
|
||||
用户身份 `--as user` 报权限或身份不支持类错误时,不要反复引导用户执行 `auth login`。先以 CLI 返回的 metadata / error envelope 为准判断:如果错误表明当前接口不支持用户身份访问,再按用户意图切换处理:
|
||||
|
||||
1. 如果用户只是查询当前登录用户所在的进行中会议,说明当前接口链路不支持用户身份访问,改用应用身份流程;需要目标用户 open_id,并要求应用机器人已在会中或先按用户确认执行入会。
|
||||
2. 如果用户明确要求应用机器人入会、旁听、代参会或读取应用机器人可见事件,直接切到 `--as bot`,并按上面的应用身份权限配置检查处理。
|
||||
|
||||
## 延伸
|
||||
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
|
||||
# vc +meeting-events
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
查询当前 bot 在一场正在进行的视频会议中收到的会中事件列表。该命令是**读操作**。对进行中会议,要求 bot 当前仍在会中;对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**,只要 bot 曾经在这场会里出现过,仍可继续拉取事件。
|
||||
查询一场正在进行的视频会议中的会中事件列表。该命令是**读操作**,必须沿用 `meeting_id` 的来源身份:用户身份发现的会议继续用用户身份读,应用身份发现或应用机器人入会得到的会议继续用应用身份读。对已结束会议,存在一个**结束后 5 分钟内的宽限窗口**;应用身份读取时,要求应用机器人曾经在这场会里出现过。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-events`(调用 `GET /open-apis/vc/v1/bots/events`)。
|
||||
|
||||
可见性边界:
|
||||
|
||||
- `meeting_id` 来自 `+meeting-list-active --as user`:后续读取事件继续 `--as user`。
|
||||
- `meeting_id` 来自 `+meeting-list-active --as bot --user-id <user_open_id>` 或 `+meeting-join --as bot`:后续读取事件继续 `--as bot`。
|
||||
- 应用身份下,应用机器人必须在该会中或参会过;应用身份 active meeting 返回的是“目标用户在会中且应用机器人也在会中”的会议,不表示可以读取任意 `meeting_id`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 默认用法:全量拉取当前可见事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-all --format pretty
|
||||
|
||||
# 指定时间范围,并拉全该时间窗内当前可见事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --start 2026-04-17T15:00:00+08:00 --end 2026-04-17T16:00:00+08:00 --page-all --format pretty
|
||||
|
||||
# 基于上一次保存的 page_token 继续查新增事件
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-token <last_page_token> --page-all --format pretty
|
||||
|
||||
# 调试或控制返回体大小时,显式只查一页
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
|
||||
|
||||
# 预览 API 调用(不实际请求)
|
||||
lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
lark-cli vc +meeting-events --as <same_identity> --meeting-id 69xxxxxxxxxxxxx28 --page-size 20 --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -36,8 +37,6 @@ lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
| `--page-token <token>` | 否 | 从指定分页游标继续拉取下一页 |
|
||||
| `--page-size <n>` | 否 | 单页模式每页大小。CLI 会自动夹紧到 `20-100`;传 `--page-all` 时固定使用 `100` |
|
||||
| `--page-all` | 否 | 自动分页,直到没有更多页面为止(内部有安全上限) |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (CLI 默认) / pretty(本 skill 推荐默认) / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
@@ -45,37 +44,55 @@ lark-cli vc +meeting-events --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
`--meeting-id` 必须是会议的长数字 ID。它通常来自:
|
||||
- `+meeting-join` 返回体中的 `meeting.id`
|
||||
- `+meeting-list-active` 返回体中的 `meeting_id`
|
||||
- `+search` 结果中的 `id`
|
||||
|
||||
**不要**把 9 位会议号(`--meeting-number`)传给这个命令。
|
||||
如果 `meeting_id` 来自 `+meeting-list-active`,后续 `+meeting-events` 必须沿用同一身份;如果返回多个会议,先让用户选择具体 `meeting_id`。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
如果用户提供的是 9 位会议号且没有明确要求应用机器人入会,先按当前场景身份查 active meetings 并按 `meeting_no` 匹配。匹配到唯一项后,取该项的长数字 `meeting_id`,再用同一身份调用本命令;匹配失败时不要自动入会,除非用户明确说“入会 / 让应用机器人旁听 / 代我参会”。
|
||||
|
||||
该命令仅支持 `user` 身份。
|
||||
### 2. 身份来源是读取事件的权限锚点
|
||||
|
||||
### 3. bot 必须在会中,或在会议结束后的 5 分钟宽限窗口内曾经在会中
|
||||
- 用户身份路径:先用 `+meeting-list-active --as user` 发现当前登录用户的会议,再用 `+meeting-events --as user` 读取该 `meeting_id`。
|
||||
- 应用身份路径:应用机器人必须在会中或参会过;不要拿任意 `meeting_id` 直接用 `--as bot` 查。
|
||||
- 不要混用身份。身份不一致时,常见结果是空列表、`no permission` 或 `bot is not in meeting`。
|
||||
|
||||
这是查询“bot 在会中观察到的事件”的接口。若 bot 已离会、未入会、或会议已经无法再判断 bot 身份,后端通常会报:
|
||||
- `bot is not in meeting, no permission`
|
||||
### 3. 读取事件前必须先拿到可见的 meeting_id
|
||||
|
||||
因此,最稳妥的调用顺序通常是:
|
||||
最稳妥的调用顺序通常是:
|
||||
|
||||
```bash
|
||||
# 先入会
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
|
||||
# 记录返回的 meeting.id
|
||||
# 方式 1:先入会,直接记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 再查询事件
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
如果应用机器人已经在会中,也可以先通过 active meeting 找会:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
如果只是查询当前登录用户所在会议:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
若应用机器人已离会、未入会、或会议已经无法再判断身份,后端通常会报:
|
||||
- `bot is not in meeting, no permission`
|
||||
|
||||
更精确地说,后端当前的判断规则是:
|
||||
|
||||
- **会议进行中**:要求 bot **当前仍在会中**
|
||||
- **会议已结束后的 5 分钟内**:只要 bot **曾经在这场会中出现过**,仍可拉取事件
|
||||
- **会议进行中**:要求应用机器人**当前仍在会中**
|
||||
- **会议已结束后的 5 分钟内**:只要应用机器人**曾经在这场会中出现过**,仍可拉取事件
|
||||
- **会议结束超过 5 分钟**:按会议结束处理,通常不再返回事件流
|
||||
- **bot 从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
|
||||
- **应用机器人从未真实入会过**:即使会议仍在进行或刚结束,也会返回 `10005 bot is not in meeting`
|
||||
|
||||
### 4. 自动分页规则
|
||||
|
||||
@@ -87,9 +104,9 @@ lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
|
||||
执行准则:
|
||||
|
||||
- **默认命令模板**:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty`
|
||||
- **默认命令模板**:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format pretty`
|
||||
- 如果你发现自己执行成了不带 `--page-all` 的单页查询,而响应里又出现 `has_more=true` / `more available` / 非空 `page_token`,应立刻意识到这只是部分结果。
|
||||
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
|
||||
- 遇到上述情况,默认补救方式是继续使用返回的 `page_token` 续拉,例如:`lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-token <returned_page_token> --page-all --format pretty`
|
||||
- 只有在用户明确要求“就看第一页”“先不要翻页”时,才不要默认带 `--page-all`
|
||||
- 只要你是基于 `+meeting-events` 来回答一场**正在进行中的会议内容**,就不能直接复用上一次查询结果。无论用户是在问“现在是谁在说话”“刚刚发生了什么”“最新事件有哪些”,还是让你“总结一下这个会议讲什么”,都必须先重新执行一次 `+meeting-events`,确认拿到的是最新事件流,再回答用户。只有在用户明确要求基于某次历史快照继续分析时,才可以复用旧结果。
|
||||
|
||||
@@ -115,7 +132,10 @@ lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
|
||||
执行准则:
|
||||
|
||||
- 这类问题默认先用 `lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
|
||||
- 如果上下文已有明确 `meeting_id` 和来源身份,直接用同一身份执行 `+meeting-events --page-all --format json`。
|
||||
- 如果上下文没有明确 `meeting_id`,先按用户当前意图选择身份:问“我/当前用户所在会议”用 `lark-cli vc +meeting-list-active --as user --format pretty`;问“应用机器人可见的目标用户会议”用 `lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format pretty`。返回多个会议时先让用户选择。
|
||||
- 如果上下文只有 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配;匹配到唯一会议后再查事件。不要为了总结会议而自动调用 `+meeting-join`。
|
||||
- 这类问题拿到 `meeting_id` 后,用 `lark-cli vc +meeting-events --as <same_identity> --meeting-id <meeting.id> --page-all --format json` 拉取最新事件流。
|
||||
- 如果事件中出现共享文档线索,例如:
|
||||
- `magic_share_started`
|
||||
- `share_doc.title`
|
||||
@@ -171,7 +191,7 @@ lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` |
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+meeting-list-active` 返回的 `meeting_id`;或 `+search` 结果中的 `id`。必须同时记录来源身份 |
|
||||
| `start` / `end` | 用户给出的时间范围;如未给出则默认取全量可见事件 |
|
||||
| `page-token` | 上一页或上一次查询结果中保存的 `page_token`;建议持久化保存,便于下次继续拉取新增事件 |
|
||||
|
||||
@@ -181,16 +201,31 @@ lark-cli vc +meeting-events --meeting-id <meeting.id>
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 第 2 步:查询事件流
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 1b:应用机器人已在会中,先发现 meeting_id 再读事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 1c:当前登录用户正在会中,先发现 meeting_id 再读事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 2:过滤某段时间内的事件
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-events \
|
||||
--as <same_identity> \
|
||||
--meeting-id <meeting.id> \
|
||||
--start 2026-04-17T15:00:00+08:00 \
|
||||
--end 2026-04-17T16:00:00+08:00 \
|
||||
@@ -204,6 +239,7 @@ lark-cli vc +meeting-events \
|
||||
# 上一次查询结束后,保留最后返回的 page_token
|
||||
# 这次直接从该游标继续拉新增事件
|
||||
lark-cli vc +meeting-events \
|
||||
--as <same_identity> \
|
||||
--meeting-id <meeting.id> \
|
||||
--page-token <last_page_token> \
|
||||
--page-all \
|
||||
@@ -221,23 +257,27 @@ lark-cli vc +meeting-events \
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入长数字 `meeting.id` |
|
||||
| `10005 bot is not in meeting` | bot 从未真实入会该会议;或会议已结束但 bot 从未在会中出现过 | 先 `+meeting-join --meeting-number <9位号>` 真实入会再查;如果会议已经结束且当时 bot 没进过会,本接口也拉不到数据。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`**(不依赖 bot 身份参会) |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。若要拿纪要文档或逐字稿 token,用 `lark-cli vc +notes --meeting-ids <meeting.id>`;若要拿 AI 产物(summary / todos / chapters)或导出逐字稿文件,先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `not a 9-digit meeting number` | 把 9 位会议号误传给 `--meeting-id` | 如果只是查询会中内容,先用 `+meeting-list-active` 按 `meeting_no` 匹配拿长数字 `meeting_id`;只有用户明确要求入会时才用 `+meeting-join --as bot --meeting-number <9位号>` |
|
||||
| `10005 bot is not in meeting` | 使用应用身份读取,但应用机器人从未真实入会该会议;或会议已结束但应用机器人从未在会中出现过 | 如果本来是用户身份发现的 `meeting_id`,改回 `--as user`;如果确实要应用身份读取,先 `+meeting-join --as bot --meeting-number <9位号>` 真实入会再查。**如果只是想看参会人快照,改用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants`** |
|
||||
| 用户身份不支持 | 当前事件读取接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先通过 `+meeting-list-active --as bot --user-id <user_open_id>` 获取应用身份可读的 `meeting_id`,或在用户明确同意后让应用机器人入会,再用 `+meeting-events --as bot` 读取 |
|
||||
| `20001 meeting_status_MEETING_END` | 会议已结束且已超出后端允许的 5 分钟宽限窗口 | 本接口不再适合继续拉取事件。先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息,再根据 `note_display_type` / `note_id` / `minute_token` 和用户意图选择纪要正文、逐字稿或妙记;参会人请用 `lark-cli vc meeting get --params '{"meeting_id":"<meeting.id>"}' --with-participants` |
|
||||
| `20002 meeting not exist` | `meeting_id` 错误,或会议实例当前已不可获取(常见于把 9 位会议号当 meeting_id 传) | 确认传入的是长数字 `meeting_id`,不是 9 位会议号 |
|
||||
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
|
||||
| `HTTP 404` / `HTTP 500` | 服务端当前无法找到或处理该会议实例 | 换一个正在进行且 bot 可见的 meeting_id,或排查后端问题 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 这是**会中事件流**查询,不适合拿来搜历史会议记录;搜历史会议请用 `+search`。
|
||||
- 如果会议已经结束,不要卡在 `+meeting-events`:
|
||||
- 想拿纪要文档或逐字稿 token:用 `lark-cli vc +notes --meeting-ids <meeting.id>`
|
||||
- 想拿 AI 产物(summary / todos / chapters)或导出逐字稿文件:先用 `lark-cli vc +recording --meeting-ids <meeting.id>` 拿 `minute_token`,再用 `lark-cli vc +notes --minute-tokens <minute_token>`
|
||||
- 事件列表是否完整,取决于 bot 何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且 bot **曾经在会中**时还能继续拉到事件。
|
||||
- 先用 `lark-cli vc +notes --meeting-ids <meeting.id>` 获取会议产物信息。
|
||||
- 再根据 `note_display_type`、`note_id`、`minute_token` 和用户意图,按 `lark-vc` 的产物决策读取纪要正文、逐字稿或妙记。
|
||||
- 事件列表是否完整,取决于应用机器人何时入会、何时离会,以及后端当前可见的会中事件范围。对于已结束会议,通常只在**结束后 5 分钟内**、且应用机器人**曾经在会中**时还能继续拉到事件。
|
||||
- 查询"谁参加过某会议"请用 `vc meeting get --params '{"meeting_id":"<id>","with_participants":true}'`——这是参会人**快照** API,不依赖 bot 是否参会,对已结束会议也可查;**不要** 用 `+meeting-events` 做参会人查询。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 先真实入会
|
||||
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前可读事件的进行中会议 ID
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 用户明确要求时离会
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
|
||||
# vc +meeting-join
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 9 位会议号加入一场正在进行的视频会议(bot join)。这是一次**写操作**,会实际让当前身份加入会议。
|
||||
通过 9 位会议号让应用机器人加入一场正在进行的视频会议。这是一次**写操作**,会实际让应用机器人加入会议。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-join`(调用 `POST /open-apis/vc/v1/bots/join`)。
|
||||
|
||||
> **不要把 9 位会议号等同于入会意图。** 用户给出 9 位会议号并询问“会议讲了什么 / 查会中事件”时,先用 `+meeting-list-active` 查当前 active meetings 并按 `meeting_no` 匹配;只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才调用本命令。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 仅指定会议号(无密码)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 指定会议号 + 密码
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --password 8888
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --password 8888
|
||||
|
||||
# 从邀请事件透传 call_id(参见「如何获取输入参数」)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --call-id a08e06bf-9a41-44e4-a89c-a7871899e783
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --call-id a08e06bf-9a41-44e4-a89c-a7871899e783
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --format json
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
|
||||
|
||||
# 预览 API 调用(不实际加入会议)
|
||||
lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -33,14 +33,13 @@ lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
| `--meeting-number <no>` | 是 | 会议号,必须为 **9 位纯数字** |
|
||||
| `--password <pw>` | 否 | 会议密码,仅在该会议设置了入会密码时传入 |
|
||||
| `--call-id <id>` | 否 | 从 `vc.bot.meeting_invited_v1` 邀请事件透传的 `call_id`,原样回传即可。Agent 主动入会或无邀请事件来源时不传 |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不实际加入会议;会议号或身份不确定时先用它确认请求 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 仅支持 user 身份
|
||||
### 1. 使用应用身份
|
||||
|
||||
该命令仅支持 `user` 身份。
|
||||
这是应用机器人入会能力,使用 `--as bot`。不要用当前登录用户身份尝试让应用机器人入会。
|
||||
|
||||
### 2. 会议号格式严格校验
|
||||
|
||||
@@ -53,8 +52,8 @@ lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
|
||||
### 3. 会议必须已开始且允许入会
|
||||
|
||||
- 会议必须处于**进行中**状态,bot 无法加入尚未开始或已结束的会议。
|
||||
- 若会议设置了**等候室 / 入会审批**,bot 可能需要主持人放行后才真正入会。
|
||||
- 会议必须处于**进行中**状态,应用机器人无法加入尚未开始或已结束的会议。
|
||||
- 若会议设置了**等候室 / 入会审批**,应用机器人可能需要主持人放行后才真正入会。
|
||||
- 若返回 `HTTP 403: no permission`(错误码 `121003`),不要只理解成“账号没权限”。这类报错更常见的原因是:会议参数或会控配置当前不满足入会条件,例如会议号填错、密码未传或错误、会议尚未开始、等候室 / 入会审批未放行、会议禁止外部/特定身份加入等。应先确认这些配置项,再重试。
|
||||
|
||||
### 4. 机器人入会后对其他参会人可见
|
||||
@@ -67,7 +66,7 @@ lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `meeting.id` | 会议 ID(可后续传给 `+meeting-leave --meeting-id`) |
|
||||
| `meeting.id` | 会议 ID(可后续传给 `+meeting-leave --as bot --meeting-id`) |
|
||||
| `meeting.meeting_no` | 会议号(与入参一致) |
|
||||
| `meeting.topic` | 会议主题 |
|
||||
| `meeting.start_time` | 会议开始时间 |
|
||||
@@ -88,25 +87,30 @@ lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录返回的 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 第 2 步:使用返回的 meeting.id 查询会中事件
|
||||
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
|
||||
```
|
||||
|
||||
### 场景 2:加入会议 → 会后拉取纪要 / 录制
|
||||
如果 bot 已经在会中,也可以通过 active meeting 找回 `meeting_id`:
|
||||
|
||||
```bash
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
```
|
||||
|
||||
### 场景 2:加入会议 → 会后进入 lark-vc 获取会议产物信息
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入并参会
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 第 2 步:会议结束后,查询录制(拿到 minute_token)
|
||||
lark-cli vc +recording --meeting-ids <meeting.id>
|
||||
|
||||
# 第 3 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
|
||||
# 第 2 步:会议结束后,先查询会议产物
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
后续按 `lark-vc` 的产物决策处理:根据 `note_display_type`、`note_id`、`minute_token` 和用户意图选择纪要正文、逐字稿或妙记。
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
@@ -114,18 +118,20 @@ lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
| `--meeting-number must be exactly 9 digits` | 会议号不是 9 位纯数字 | 检查是否误传了会议链接或 meeting_id |
|
||||
| 会议密码错误 | `--password` 错误或未提供 | 向主持人确认会议密码 |
|
||||
| 会议不存在 / 已结束 | 会议号错误或会议未进行中 | 确认会议正在进行中 |
|
||||
| `HTTP 403: no permission` / `121003` | 入会前置条件不满足,通常不是单纯 scope 问题 | 依次确认:1)会议允许智能体加入;2)会议号正确;3)如有密码,已正确传入 `--password`;4)会议已开始;5)等候室 / 入会审批已放行;6)会议未禁止当前身份加入(如限制外部、限制 bot、仅特定成员可入会);确认后重试 |
|
||||
| `HTTP 403: no permission` / `121003` | 入会前置条件不满足,通常不是单纯 scope 问题 | 依次确认:1)会议允许智能体加入;2)会议号正确;3)如有密码,已正确传入 `--password`;4)会议已开始;5)等候室 / 入会审批已放行;6)会议未禁止当前身份加入(如限制外部、限制应用机器人、仅特定成员可入会);确认后重试 |
|
||||
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
|
||||
| 入会被拒绝 | 等候室 / 入会审批 / 限制外部入会 | 联系主持人放行或调整会议设置 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 仅在 Agent 需要**真实加入**会议(例如参会机器人、会中助手)时使用;只拉取会议数据不需要入会。
|
||||
- 入会会让机器人立即出现在参会列表;若用户要求退出 / 离开 / 结束参会,直接 `+meeting-leave` 即可。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
|
||||
- 入会会让机器人立即出现在参会列表;若用户要求退出 / 离开 / 结束参会,直接使用 `+meeting-leave --as bot --meeting-id <meeting.id>`。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
|
||||
- 执行成功后,立即记录返回的 `meeting.id`,用于后续 `+meeting-leave` / `+meeting-events`。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 对应的离会命令
|
||||
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前可读事件的进行中会议 ID
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议记录
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
# vc +meeting-leave
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
通过 `meeting_id` 离开当前身份所在的视频会议(bot leave)。这是一次**写操作**,会实际把当前身份从会议中移出。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-leave`(调用 `POST /open-apis/vc/v1/bots/leave`)。
|
||||
@@ -11,13 +9,13 @@
|
||||
|
||||
```bash
|
||||
# 通过 meeting_id 离会
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28
|
||||
lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28
|
||||
|
||||
# 输出格式
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --format json
|
||||
lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --format json
|
||||
|
||||
# 预览 API 调用(不实际离会)
|
||||
lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
lark-cli vc +meeting-leave --as bot --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -25,22 +23,21 @@ lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--meeting-id <id>` | 是 | 会议 ID(**不是 9 位会议号**) |
|
||||
| `--format <fmt>` | 否 | 输出格式:json (默认) / pretty / table / ndjson / csv |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不实际离会;meeting_id 或身份不确定时先用它确认请求 |
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 入参是 meeting_id,不是会议号
|
||||
|
||||
`--meeting-id` 必须是会议的长数字 ID,通常由 `+meeting-join` 返回体中的 `meeting.id` 提供,也可从 `+search` 结果中的 `id` 字段获取。**传 9 位会议号会失败**。
|
||||
`--meeting-id` 必须是会议的长数字 ID,通常由 `+meeting-join --as bot` 返回体中的 `meeting.id` 提供,也可从应用身份 `+meeting-list-active --as bot --user-id <user_open_id>` 返回体中的 `meeting_id` 获取。**传 9 位会议号会失败**。
|
||||
|
||||
### 2. 仅支持 user 身份
|
||||
### 2. 优先使用 bot 身份
|
||||
|
||||
该命令仅支持 `user` 身份。只能让当前身份自己离会,无法强制移出其他参会人。
|
||||
这是应用机器人离会能力,使用与入会或 active meeting 发现相同的 `--as bot`。只能让当前身份自己离会,无法强制移出其他参会人。
|
||||
|
||||
### 3. 当前身份必须在会议中
|
||||
|
||||
必须先通过 `+meeting-join` 或其他方式在该会议中,否则接口会报错。
|
||||
应用机器人必须已经在该会议中,否则接口会报错。如果 `meeting_id` 来自 `+meeting-list-active`,必须确认这是应用身份发现到的会议。
|
||||
|
||||
### 4. 离会立即生效,对其他参会人可见
|
||||
|
||||
@@ -55,7 +52,7 @@ lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
| 输入参数 | 获取方式 |
|
||||
|---------|---------|
|
||||
| `meeting-id` | `+meeting-join` 返回的 `meeting.id`;或 `+search` 结果中的 `id` 字段 |
|
||||
| `meeting-id` | `+meeting-join --as bot` 返回的 `meeting.id`;或应用身份 `+meeting-list-active --as bot --user-id <user_open_id>` 返回的 `meeting_id` |
|
||||
|
||||
## Agent 组合场景
|
||||
|
||||
@@ -63,13 +60,13 @@ lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
|
||||
|
||||
```bash
|
||||
# 第 1 步:加入会议,记录 meeting.id
|
||||
lark-cli vc +meeting-join --meeting-number 123456789
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789
|
||||
|
||||
# 第 2 步:在会中处理用户请求(如监听发言、记录信息等)
|
||||
# ...
|
||||
|
||||
# 第 3 步:仅在用户明确要求退出 / 离开 / 结束参会时,使用上一步记录的 meeting.id 离会
|
||||
lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
lark-cli vc +meeting-leave --as bot --meeting-id <meeting.id>
|
||||
```
|
||||
|
||||
### 场景 2:会后补拉产物(不需要离会)
|
||||
@@ -77,10 +74,7 @@ lark-cli vc +meeting-leave --meeting-id <meeting.id>
|
||||
如果用户只是要求会议结束后拉录制、纪要或逐字稿,不要先调用 `+meeting-leave`;直接跨到 `lark-vc` 查询会后产物。
|
||||
|
||||
```bash
|
||||
# 第 1 步:会议结束后查询录制
|
||||
lark-cli vc +recording --meeting-ids <meeting.id>
|
||||
|
||||
# 第 2 步:查询会议纪要
|
||||
# 第 1 步:会议结束后进入 lark-vc 获取会议产物信息
|
||||
lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
```
|
||||
|
||||
@@ -88,19 +82,20 @@ lark-cli vc +notes --meeting-ids <meeting.id>
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入从 `+meeting-join` 得到的 `meeting.id` |
|
||||
| `--meeting-id is required` | 未传入 `--meeting-id` | 传入从 `+meeting-join --as bot` 得到的 `meeting.id`,或应用身份 `+meeting-list-active` 返回的 `meeting_id` |
|
||||
| `meeting not found` / `invalid meeting_id` | 误传了 9 位会议号 | 必须使用 `meeting.id`,不是会议号 |
|
||||
| `not in meeting` | 当前身份并不在该会议中 | 确认先 `+meeting-join` 成功 |
|
||||
|
||||
## 提示
|
||||
|
||||
- 只有用户明确要求退出 / 离开 / 结束参会时才调用;离会会让机器人从参会列表消失,对其他参会人可见。若需要重新入会直接再 `+meeting-join`,不是真正的"不可逆"。参数格式不确定时可选 `--dry-run` 预览。
|
||||
- `+meeting-leave` 依赖 `+meeting-join` 返回的 `meeting.id`,但不是每次 join 后都必须调用 leave。
|
||||
- `meeting_id` 优先使用 `+meeting-join` 返回的 `meeting.id`;如果来自 `+search`,也必须先确认当前身份就在该会议中。不要用 9 位会议号。
|
||||
- `+meeting-leave` 优先使用 `+meeting-join --as bot` 返回的 `meeting.id`,但不是每次 join 后都必须调用 leave。
|
||||
- `meeting_id` 如果来自 `+meeting-list-active`,必须来自应用身份,并确认应用机器人就在该会议中。不要用 9 位会议号。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 对应的入会命令
|
||||
- [lark-vc-agent-meeting-list-active](lark-vc-agent-meeting-list-active.md) — 发现当前可读事件的进行中会议 ID
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 会中事件流
|
||||
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id)
|
||||
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# vc +meeting-list-active
|
||||
|
||||
列出当前进行中的会议,用来发现 `+meeting-events` 需要的长数字 `meeting_id`。
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli vc +meeting-list-active`(调用 `GET /open-apis/vc/v1/bots/user_active_meeting`)。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 查询当前登录用户正在参加的会议
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
|
||||
# 查询指定用户当前参加、且应用机器人也在会中的会议
|
||||
lark-cli vc +meeting-list-active --as bot --user-id ou_xxx --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--user-id <id>` | 应用身份必填 | 目标用户 open_id,格式为 `ou_...`。用户身份不传;应用身份直接透传给接口,不接受 internal user_id 或数字 ID |
|
||||
|
||||
## 身份语义
|
||||
|
||||
不要向用户暴露内部身份缩写;对用户只说“用户身份”或“应用身份”。
|
||||
|
||||
| 身份 | 命令 | 返回范围 | 后续事件读取 |
|
||||
| ---- | ---- | -------- | ------------ |
|
||||
| 用户身份 | `--as user` | 当前登录用户正在参加的会议 | 继续 `+meeting-events --as user` |
|
||||
| 应用身份 | `--as bot --user-id <user_open_id>` | 目标用户正在参加、且应用机器人也在会中的会议 | 继续 `+meeting-events --as bot` |
|
||||
|
||||
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份。不要把用户身份拿到的 `meeting_id` 改用应用身份查,也不要把应用身份拿到的 `meeting_id` 改用用户身份查,除非用户明确要求切换场景。
|
||||
|
||||
应用身份返回空,不代表目标用户不在任何会议中,只能说明没有找到“目标用户在会中且应用机器人也在会中”的当前会。
|
||||
|
||||
常见流程:
|
||||
|
||||
```bash
|
||||
# 方式 1:先让应用机器人入会,直接从 join 响应拿 meeting.id
|
||||
lark-cli vc +meeting-join --as bot --meeting-number 123456789 --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting.id> --page-all --format pretty
|
||||
|
||||
# 方式 2:应用机器人已经在会中时,用应用身份发现 meeting_id
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
lark-cli vc +meeting-events --as bot --meeting-id <meeting_id> --page-all --format pretty
|
||||
|
||||
# 方式 3:只回答当前登录用户所在会议发生了什么
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
lark-cli vc +meeting-events --as user --meeting-id <meeting_id> --page-all --format pretty
|
||||
```
|
||||
|
||||
## 多会议选择
|
||||
|
||||
- 如果返回多个会议,不要自动挑第一个。
|
||||
- 向用户展示每个候选的 `meeting_title` / `meeting_no` / `meeting_id`,等待用户选择。
|
||||
- 选择后继续使用发现该会议时的同一身份调用 `+meeting-events`。
|
||||
|
||||
## 9 位会议号匹配
|
||||
|
||||
用户提供 9 位会议号但没有明确要求应用机器人入会时,把会议号当作 active meeting 的筛选条件,而不是写操作指令。
|
||||
|
||||
```bash
|
||||
# 用户问“我当前这个会讲了什么”
|
||||
lark-cli vc +meeting-list-active --as user --format json
|
||||
|
||||
# 用户问“让应用机器人所在/可见的这个会讲了什么”
|
||||
lark-cli vc +meeting-list-active --as bot --user-id <user_open_id> --format json
|
||||
```
|
||||
|
||||
匹配规则:
|
||||
|
||||
- 在返回会议中匹配 `meeting_no == <9位会议号>`。
|
||||
- 匹配到唯一会议:取该项的长数字 `meeting_id`,后续用同一身份调用 `+meeting-events`。
|
||||
- 匹配到多个会议:展示候选,让用户选择。
|
||||
- 没有匹配:说明当前身份没有发现该会议号对应的 active meeting;不要自动调用 `+meeting-join`,除非用户明确要求应用机器人入会。
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| `--user-id is required when --as bot` | 应用身份未传目标用户 | 传入目标用户 open_id |
|
||||
| 用户身份返回空列表 | 当前登录用户没有可见的进行中会议 | 确认用户是否在会中,或是否切错身份 |
|
||||
| 用户身份不支持 | 当前接口不支持用用户身份访问 | 不要反复执行 `auth login`。改用应用身份流程:先拿目标用户 open_id,再执行 `+meeting-list-active --as bot --user-id <user_open_id>`;同时按应用身份权限配置检查应用权限、安装、数据范围和灰度 |
|
||||
| 应用身份返回空列表 | 没有满足“目标用户在会中且应用机器人也在会中”的当前会 | 先让应用机器人入会,或确认 `user_id` 和会议状态 |
|
||||
| `--user-id` 格式错误 | 传入了 internal user_id 或其他非 `ou_...` 值 | 改传目标用户 open_id |
|
||||
| 应用身份权限不足 | 应用权限、租户安装、权限可访问的数据范围或 VC Agent privilege 未配置完整 | 不要执行 `auth login`。以 CLI 返回的 metadata / error envelope 为准确认缺失权限;检查应用发布/安装,以及开放平台“权限可访问的数据范围”:选择“按条件筛选”,条件为“会议的归属者 包含 与应用的可用范围一致”;仍失败再排查内测 privilege / 灰度 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 让应用机器人真实入会并拿 `meeting.id`
|
||||
- [lark-vc-agent-meeting-events](lark-vc-agent-meeting-events.md) — 使用 `meeting_id` 读取会中事件
|
||||
@@ -14,7 +14,7 @@
|
||||
- TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -29,7 +29,7 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir` | dry-run only; no live export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
|
||||
@@ -99,3 +99,44 @@ func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_BitableBaseOnlySchema(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", "+export",
|
||||
"--token", "bitableDryRunExport",
|
||||
"--doc-type", "bitable",
|
||||
"--file-extension", "base",
|
||||
"--only-schema",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
|
||||
t.Fatalf("body.token=%q, want bitableDryRunExport\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.type").String(); got != "bitable" {
|
||||
t.Fatalf("body.type=%q, want bitable\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "base" {
|
||||
t.Fatalf("body.file_extension=%q, want base\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.only_schema").Bool(); !got {
|
||||
t.Fatalf("body.only_schema=%v, want true\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
58
tests/cli_e2e/drive/drive_import_dryrun_test.go
Normal file
58
tests/cli_e2e/drive/drive_import_dryrun_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.WriteFile(workDir+"/notes.md", []byte("# dry run\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+import",
|
||||
"--file", "notes.md",
|
||||
"--type", "docx",
|
||||
"--folder-token", "fldcnImportDryRunTarget",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
|
||||
t.Fatalf("api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
|
||||
t.Fatalf("api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
|
||||
t.Fatalf("api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user