Compare commits

..

15 Commits

Author SHA1 Message Date
houzhicong
c5e5c16356 test: add vc meeting message dry-run e2e 2026-07-01 11:11:44 +08:00
houzhicong
535cafb288 docs: refine vc meeting emoji guidance 2026-06-26 15:57:00 +08:00
houzhicong
1e01a0fcf5 feat(vc): add meeting message send shortcut 2026-06-25 14:11:27 +08:00
houzhicong
4dd775bdc7 Revert "docs: trim vc agent skill token budget"
This reverts commit 8560bb9c19.
2026-06-16 17:43:46 +08:00
houzhicong
8560bb9c19 docs: trim vc agent skill token budget 2026-06-16 17:39:43 +08:00
houzhicong
d9e0b9d705 docs: align vc agent skill with quality guidelines 2026-06-16 16:51:37 +08:00
houzhicong
931f41c3b1 docs: clarify vc meeting product wording 2026-06-16 15:35:34 +08:00
houzhicong
bc0748d12f docs: address vc agent skill review feedback 2026-06-16 15:35:34 +08:00
houzhicong
82ed719d42 docs: refine vc agent meeting flow guidance 2026-06-16 15:35:34 +08:00
houzhicong
65d0e0a5ed docs: clarify vc agent active meeting flow 2026-06-16 15:35:34 +08:00
houzhicong
d2adec3462 fix: reject meeting numbers for vc events 2026-06-16 15:35:34 +08:00
houzhicong
de6c285745 docs: clarify active meeting id fields 2026-06-16 15:35:34 +08:00
houzhicong
d9c1713b41 fix: align active meeting shortcut scope 2026-06-16 15:35:34 +08:00
houzhicong
8209c1da3c docs: clarify vc agent active meeting flow 2026-06-16 15:35:34 +08:00
houzhicong
dd95f5eb4a feat: support vc agent active meetings 2026-06-16 15:35:34 +08:00
43 changed files with 610 additions and 1341 deletions

View File

@@ -2,20 +2,6 @@
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
@@ -1189,7 +1175,6 @@ 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

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
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.

View File

@@ -4,117 +4,21 @@
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"
)
// 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"`
}
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
}
// 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%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
// 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%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}
return fmt.Sprintf("%s/app/%s/event", host, appID)
}

View File

@@ -4,109 +4,33 @@
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 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_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 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_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 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)
}
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)
}
}

View File

@@ -146,28 +146,14 @@ 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,
subscribedCallbacks: subscribedCallbacks,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -243,9 +229,6 @@ 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).
@@ -283,66 +266,46 @@ 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.brand, pf.appID, pf.identity, missing))
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
// 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 {
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, 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 console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
func preflightEventTypes(pf *preflightCtx) error {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
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
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !have[t] {
if !subscribed[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 %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
"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))
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).

View File

@@ -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/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,8 +157,9 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.Hint
wantSubstrings := []string{
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -173,109 +174,3 @@ 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)
}
}

View File

@@ -1,47 +0,0 @@
// 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
}

View File

@@ -1,101 +0,0 @@
// 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)
}
}

View File

@@ -265,8 +265,8 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
Lang: app.Lang,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -132,27 +132,6 @@ 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),

View File

@@ -269,26 +269,8 @@ 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)
// 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)
}
// 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)

View File

@@ -5,15 +5,12 @@ package bus
import (
"bufio"
"bytes"
"io"
"log"
"net"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/protocol"
)
@@ -197,60 +194,3 @@ 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)
}
}

View File

@@ -6,34 +6,13 @@ 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
@@ -145,63 +124,6 @@ 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

View File

@@ -6,7 +6,6 @@ package bus
import (
"encoding/json"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -356,69 +355,3 @@ 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")
}
}

View File

@@ -16,7 +16,6 @@ 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"
)
@@ -103,9 +102,6 @@ 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 {
@@ -175,17 +171,6 @@ 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)
}

View File

@@ -1,36 +0,0 @@
// 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)
}
}

View File

@@ -43,11 +43,9 @@ type Hello struct {
}
type HelloAck struct {
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"`
Type string `json:"type"`
BusVersion string `json:"bus_version"`
FirstForKey bool `json:"first_for_key"`
}
// Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss.
@@ -119,17 +117,6 @@ 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,

View File

@@ -105,25 +105,3 @@ 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)
}
}

View File

@@ -26,14 +26,6 @@ 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)

View File

@@ -244,58 +244,3 @@ 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"}`)}},
})
}

View File

@@ -42,18 +42,6 @@ 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"`
@@ -108,10 +96,6 @@ 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"`
@@ -164,8 +148,4 @@ 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"`
}

View File

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

View File

@@ -19,7 +19,6 @@ 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"},
@@ -90,9 +89,6 @@ 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 {
@@ -122,16 +118,6 @@ 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 JSONfull/空模式返回 nil让服务端走默认全文路径。
func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
mode := strings.TrimSpace(runtime.Str("scope"))

View File

@@ -10,7 +10,6 @@ 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"
@@ -63,47 +62,6 @@ 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()
@@ -304,7 +262,6 @@ 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", "", "")
@@ -324,7 +281,6 @@ 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", "", "")

View File

@@ -34,7 +34,6 @@ 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"},
@@ -45,7 +44,6 @@ 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 {
@@ -54,7 +52,6 @@ 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.
@@ -81,9 +78,6 @@ 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").
@@ -101,7 +95,6 @@ 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"))

View File

@@ -34,7 +34,6 @@ type driveExportSpec struct {
DocType string
FileExtension string
SubID string
OnlySchema bool
}
// driveExportTaskResultCommand prints the resume command shown when bounded
@@ -151,10 +150,6 @@ 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")
}
@@ -190,9 +185,6 @@ 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 {

View File

@@ -51,15 +51,6 @@ 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"},
@@ -621,7 +612,6 @@ func TestDriveExportBitableBaseAsyncSuccess(t *testing.T) {
"--token", "bitable123",
"--doc-type", "bitable",
"--file-extension", "base",
"--only-schema",
"--as", "bot",
}, f, stdout)
if err != nil {
@@ -638,9 +628,6 @@ 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 {

View File

@@ -25,8 +25,7 @@ var DriveImport = common.Shortcut{
"docs:document.media:upload",
"docs:document:import",
},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
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},
@@ -62,7 +61,6 @@ 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").
@@ -89,9 +87,6 @@ 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)

View File

@@ -257,46 +257,6 @@ 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 {

View File

@@ -5,12 +5,10 @@ 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"
)
@@ -278,134 +276,6 @@ 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())

View File

@@ -114,20 +114,16 @@ 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) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
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)
uploadName, _ := got.API[0].Body["file_name"].(string)
if uploadName != "base-import.xlsx" {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}
importName, _ := got.API[2].Body["file_name"].(string)
importName, _ := got.API[1].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}

View File

@@ -15,5 +15,6 @@ func Shortcuts() []common.Shortcut {
VCMeetingLeave,
VCMeetingListActive,
VCMeetingEvents,
VCMeetingMessageSend,
}
}

View File

@@ -838,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-list-active", "+meeting-events"}
want := []string{"+search", "+notes", "+recording", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("shortcut commands = %#v, want %#v", commands, want)
}

View File

@@ -0,0 +1,135 @@
// 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/shortcuts/common"
)
const (
meetingMessageTypeText = "text"
meetingMessageTypeReaction = "reaction"
)
// VCMeetingMessageSend sends an in-meeting text message or reaction emoji.
var VCMeetingMessageSend = common.Shortcut{
Service: "vc",
Command: "+meeting-message-send",
Description: "Send an in-meeting text message or reaction emoji",
Risk: "write",
Scopes: []string{"vc:meeting.message:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to send into"},
{Name: "msg-type", Desc: "message type: text or reaction"},
{Name: "text", Desc: "text content when --msg-type text"},
{Name: "emoji-type", Desc: "emoji key when --msg-type reaction, for example LOVE, THUMBSUP, VC_NoSound"},
{Name: "uuid", Desc: "optional idempotency key"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil {
return err
}
_, err := resolveMeetingMessageType(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(buildMeetingMessageSendPath()).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintln(w, "Meeting message sent.")
if msgType := common.GetString(data, "msg_type"); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
} else if msgType, _ := body["msg_type"].(string); msgType != "" {
fmt.Fprintf(w, " Type: %s\n", msgType)
}
if uuid := common.GetString(data, "uuid"); uuid != "" {
fmt.Fprintf(w, " UUID: %s\n", uuid)
}
})
return nil
},
}
func buildMeetingMessageSendPath() string {
return "/open-apis/vc/v1/bots/message"
}
func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
msgType, err := resolveMeetingMessageType(runtime)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
"msg_type": msgType,
}
switch msgType {
case meetingMessageTypeText:
body["content"] = strings.TrimSpace(runtime.Str("text"))
case meetingMessageTypeReaction:
body["content"] = strings.TrimSpace(runtime.Str("emoji-type"))
}
if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" {
body["uuid"] = uuid
}
return body, nil
}
func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) {
msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type")))
text := strings.TrimSpace(runtime.Str("text"))
emojiType := strings.TrimSpace(runtime.Str("emoji-type"))
if msgType == "" {
switch {
case text != "" && emojiType == "":
msgType = meetingMessageTypeText
case text == "" && emojiType != "":
msgType = meetingMessageTypeReaction
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type")
}
}
switch msgType {
case meetingMessageTypeText:
if text == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text")
}
case meetingMessageTypeReaction:
if emojiType == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type")
}
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type")
}
return msgType, nil
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
func newMeetingMessageSendRuntime() *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
cmd.Flags().String("msg-type", "", "")
cmd.Flags().String("text", "", "")
cmd.Flags().String("emoji-type", "", "")
cmd.Flags().String("uuid", "", "")
return common.TestNewRuntimeContext(cmd, defaultConfig())
}
func mustSetMeetingMessageSendFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err)
}
}
func TestMeetingMessageSendBuildBody_Text(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ")
mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeText {
t.Fatalf("msg_type = %v, want text", body["msg_type"])
}
if body["content"] != "hello" {
t.Fatalf("content = %v, want hello", body["content"])
}
if body["uuid"] != "cid-1" {
t.Fatalf("uuid = %v, want cid-1", body["uuid"])
}
}
func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["msg_type"] != meetingMessageTypeReaction {
t.Fatalf("msg_type = %v, want reaction", body["msg_type"])
}
if body["content"] != "LOVE" {
t.Fatalf("content = %v, want LOVE", body["content"])
}
if _, ok := body["text"]; ok {
t.Fatalf("text should be omitted for reaction, got %#v", body["text"])
}
if _, ok := body["emoji_type"]; ok {
t.Fatalf("emoji_type should be omitted for reaction, got %#v", body["emoji_type"])
}
}
func TestMeetingMessageSendBuildBody_ReactionVCFeedbackKey(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_NoSound")
body, err := buildMeetingMessageSendBody(runtime)
if err != nil {
t.Fatalf("buildMeetingMessageSendBody() error = %v", err)
}
if body["content"] != "VC_NoSound" {
t.Fatalf("content = %v, want VC_NoSound", body["content"])
}
}
func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789")
mustSetMeetingMessageSendFlag(t, runtime, "text", "hello")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "9-digit meeting number") {
t.Fatalf("error = %v, want 9-digit meeting number hint", err)
}
}
func TestMeetingMessageSendValidateRejectsMissingEmojiType(t *testing.T) {
runtime := newMeetingMessageSendRuntime()
mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044")
mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction")
err := VCMeetingMessageSend.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--emoji-type is required") {
t.Fatalf("error = %v, want --emoji-type required", err)
}
}
func TestMeetingMessageSendDryRun_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCMeetingMessageSend, []string{
"+meeting-message-send", "--dry-run", "--as", "user",
"--meeting-id", "7651377260537433044",
"--text", "hello",
"--uuid", "cid-1",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"/open-apis/vc/v1/bots/message",
"\"meeting_id\": \"7651377260537433044\"",
"\"msg_type\": \"text\"",
"\"content\": \"hello\"",
"\"uuid\": \"cid-1\"",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}

View File

@@ -76,14 +76,6 @@ 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>" \
@@ -100,7 +92,6 @@ 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` | 否 | 覆盖已存在文件 |
@@ -109,7 +100,6 @@ lark-cli drive +export \
- `markdown` 只支持 `docx`
- `base` 只支持 `bitable`
- `--only-schema` 只支持 `bitable` 导出为 `.base`,用于仅导出表结构
- `pptx` 只支持 `slides`
- `slides` 支持导出为 `pptx` / `pdf`
- `sheet` / `bitable` 导出为 `csv` 时必须带 `--sub-id`

View File

@@ -1,7 +1,7 @@
---
name: lark-vc-agent
version: 1.0.0
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件,如参会人加入/离开、发言、聊天、屏幕共享。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询这些使用 lark-vc 技能。"
description: "飞书视频会议会中能力:用于让应用机器人真实加入或离开正在进行的会议,并读取当前身份可见的会中事件、发送会中文本消息或会中表情。适用于用户询问正在开的会议发生了什么、谁在发言、是否共享内容,或需要发现当前可读的进行中会议 ID。不负责已结束会议搜索、参会人快照、纪要、逐字稿或录制查询这些使用 lark-vc 技能。"
metadata:
requires:
bins: ["lark-cli"]
@@ -26,7 +26,7 @@ metadata:
本 skill 与 [`lark-vc`](../lark-vc/SKILL.md) 并列:
- **`lark-vc`** **负责"会后查询"**:搜索历史会议、参会人快照、纪要/逐字稿/录制
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 机器人离会
- **`lark-vc-agent`** **负责"会中动作"**:机器人入会 / 读取进行中会议的实时事件 / 发送会中文本或会中表情 / 机器人离会
按此分工路由,避免两个 skill 语义混淆。
@@ -35,6 +35,7 @@ metadata:
| "帮我入会 123456789"、"代我参会"、"让机器人进会旁听" | **本 skill** `+meeting-join` |
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议** | **本 skill** `+meeting-events` |
| "我/某个用户现在在哪个会里"、"给我找当前可拉事件的 meeting_id" | **本 skill** `+meeting-list-active` |
| "在会里发一句 xx"、"提示大家 xx"、"反馈听不到/看不到/声音清楚/效果不错"**进行中会议** | **本 skill** `+meeting-message-send` |
| "退出会议"、"让机器人离开" | **本 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) 发群 |
@@ -49,7 +50,7 @@ metadata:
| 查询目标用户且应用机器人也在会中的会议 | `--as bot --user-id <user_open_id>` | `--user-id` 必须是 `ou_...`;拿到的 `meeting_id` 后续继续用 `--as bot` 读事件 |
| 用户明确要求应用机器人入会/旁听/代参会 | `--as bot` | 这是写操作,会真实产生入会记录;返回的 `meeting.id` 后续继续用 `--as bot` |
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
硬规则:`meeting_id` 从哪种身份路径拿到,后续 `+meeting-events` / `+meeting-message-send` 就沿用哪种身份,除非用户明确要求切换场景(例如从“仅查询我当前会”改成“让应用机器人入会旁听”)。
## 核心场景
@@ -79,14 +80,33 @@ metadata:
10. 用户直接问“这个会议讲了什么 / 现在讲到哪了”且上下文没有明确 `meeting_id` 时,先用用户身份发现当前会议;如果用户明确要求应用机器人视角,或上下文已经是应用机器人参会流程,再用应用身份发现。若返回多个会议,展示候选并让用户选择。
11. 用户直接提供 **9 位会议号** 并询问会中事件/会议内容时,默认把它当作 active meeting 的筛选条件:先按当前身份查 active meetings并在返回里匹配 `meeting_no == <9位会议号>`;匹配到唯一会议后取长数字 `meeting_id`,再用同一身份查事件。只有用户明确要求“入会 / 让应用机器人旁听 / 代我参会”时才改用 `+meeting-join`
### 3. 离开会议(写操作)
### 3. 发送会中文本或会中表情(写操作)
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type``--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
5. 支持普通 Feishu reaction emoji`LOVE``SMILE``THUMBSUP`)和 4 个 VC 反馈 key`VC_CanNotSee``VC_NoSound``VC_LooksGood``VC_SoundsClear`)。
6. 不要编造列表外的 `emoji_type`,也不要把 natural language 硬编码成不存在的 key如果用户只给语义可在完整列表中选择最接近的 key无法判断时先确认。
7. 该命令只暴露会中文本和会中表情,不作为“发送绑定群消息”的默认能力;如果用户明确要发群聊,请路由到 [`lark-im`](../lark-im/SKILL.md)。
8. 若使用应用身份发送,应用机器人必须在会中;若使用用户身份发送,当前用户必须正在该会议中。权限错误时按“应用身份权限配置检查”或“用户身份被拒绝时”处理。
示例:
```bash
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type LOVE
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
```
### 4. 离开会议(写操作)
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. 使用与入会或 active meeting 发现相同的应用身份离会。
### 4. 获取当前可用的进行中会议 ID读操作
### 5. 获取当前可用的进行中会议 ID读操作
1. `+meeting-list-active` 用来发现当前进行中的会议,并拿到后续 `+meeting-events` 需要的长数字 `meeting_id`
2. 用户身份:`lark-cli vc +meeting-list-active --as user --format json`,用于发现当前登录用户正在参加的会议;后续 `+meeting-events` 继续 `--as user`
@@ -95,7 +115,7 @@ metadata:
5. 如果返回多个会议,不要自动任选一个;按 `meeting_title` / `meeting_no` / `meeting_id` 展示候选,等待用户明确选择后再调用 `+meeting-events`
6. 如果用户给了 9 位会议号,先在 active meeting 结果中按 `meeting_no` 匹配。匹配失败时,不要自动入会;只有用户明确要求应用机器人真实入会时,才询问或执行 `+meeting-join`
### 5. Agent 参会示范
### 6. Agent 参会示范
```bash
# 1. 入会,捕获 meeting.id
@@ -136,11 +156,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |
| [`+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-message-send`](references/lark-vc-agent-meeting-message-send.md) | 写 | Send an in-meeting text message or reaction emoji |
| [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md) | 写 | Leave a meeting by 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-message-send`](references/lark-vc-agent-meeting-message-send.md):会中文本、完整 `emoji_type` 列表、身份延续和写操作风险。
- [`+meeting-leave`](references/lark-vc-agent-meeting-leave.md)`meeting_id` 的来源与写操作可见性。
## 应用身份权限配置检查

View File

@@ -0,0 +1,134 @@
# vc +meeting-message-send
发送会中文本消息或会中 reaction emoji。
本 skill 对应 shortcut`lark-cli vc +meeting-message-send`(调用 `POST /open-apis/vc/v1/bots/message`)。
## 适用场景
- 用户要求“在会里发一句话”“提示大家”“给当前会议发消息”。
- 用户要求发送会中表情,例如“发个点赞”“发个 OK”“发个爱心”。
- 用户要求表达会中反馈,例如“听不到”“看不到”“声音清楚”“效果不错”。
- 只用于正在进行中的会议;已结束会议不支持。
## 身份规则
`meeting_id` 从哪种身份路径拿到,发送消息时就沿用哪种身份:
| meeting_id 来源 | 发送时身份 |
| --- | --- |
| `+meeting-list-active --as user` | `+meeting-message-send --as user` |
| `+meeting-list-active --as bot --user-id <user_open_id>` | `+meeting-message-send --as bot` |
| `+meeting-join --as bot` 返回的 `meeting.id` | `+meeting-message-send --as bot` |
不要把用户身份发现的 `meeting_id` 改用应用身份发送,也不要把应用身份发现的 `meeting_id` 改用用户身份发送,除非用户明确要求切换。
## 参数
| 参数 | 说明 |
| --- | --- |
| `--meeting-id` | 必填,长数字 `meeting_id`,不是 9 位会议号 |
| `--msg-type` | 可选,`text``reaction`;只传 `--text` 或只传 `--emoji-type` 时可自动推断 |
| `--text` | 文本消息内容 |
| `--emoji-type` | 会中 reaction emoji key大小写敏感必须从本文“完整 `emoji_type` 列表”中选择 |
| `--uuid` | 可选,幂等 key不传则服务端生成 |
CLI 会把 `--text``--emoji-type` 统一映射到 OpenAPI 请求体的 `content` 字段;`meeting_id` 也在请求体中传递。
## 文本消息
```bash
lark-cli vc +meeting-message-send --as user --meeting-id <meeting_id> --text "稍等,我在看文档"
```
文本消息会出现在会议内的文本互动区。不要把它当成绑定群消息发送能力;如果用户明确要求发到群聊,路由到 `lark-im`
## 会中表情
会中 reaction 支持普通 Feishu reaction emoji也支持 4 个 VC 反馈 key。
常见语义:
| 用户表达 | 推荐 `emoji_type` |
| --- | --- |
| 点赞、赞一下、认可 | `THUMBSUP` |
| +1、加一、附议、同上 | `JIAYI` |
| OK、好的 | `OK` |
| 收到、了解 | `Get` |
| 爱心、红心 | `HEART` |
| 喜欢、爱了 | `LOVE` |
| 比心 | `FINGERHEART` |
| 看起来没问题、可以继续 | `LGTM` |
| 搞定、已完成 | `DONE` |
| -1、减一 | `MinusOne` |
| 不赞同、踩 | `ThumbsDown` |
| 听不到、没声音 | `VC_NoSound` |
| 看不到、画面有问题 | `VC_CanNotSee` |
| 声音清楚 | `VC_SoundsClear` |
| 会议画面效果不错、画面看起来可以 | `VC_LooksGood` |
```bash
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type LOVE
lark-cli vc +meeting-message-send --as bot --meeting-id <meeting_id> --msg-type reaction --emoji-type VC_NoSound
```
不要编造列表外的 `emoji_type`,也不要把 mixed-case 值改成全大写,例如 `EatingFood``CheckMark``StatusInFlight` 都要按原值传。
如果用户给的是自然语言语义,可以在下方列表中选择语义最接近的 key如果不确定先向用户确认。
### 完整 `emoji_type` 列表
以下列表与 IM reaction 官方 emoji 列表保持一致,并额外包含 VC 会中特定反馈 key
```text
OK, THUMBSUP, THANKS, MUSCLE, FINGERHEART, APPLAUSE, FISTBUMP, JIAYI
DONE, SMILE, BLUSH, LAUGH, SMIRK, LOL, FACEPALM, LOVE
WINK, PROUD, WITTY, SMART, SCOWL, THINKING, SOB, CRY
ERROR, NOSEPICK, HAUGHTY, SLAP, SPITBLOOD, TOASTED, GLANCE, DULL
INNOCENTSMILE, JOYFUL, WOW, TRICK, YEAH, ENOUGH, TEARS, EMBARRASSED
KISS, SMOOCH, DROOL, OBSESSED, MONEY, TEASE, SHOWOFF, COMFORT
CLAP, PRAISE, STRIVE, XBLUSH, SILENT, WAVE, WHAT, FROWN
SHY, DIZZY, LOOKDOWN, CHUCKLE, WAIL, CRAZY, WHIMPER, HUG
BLUBBER, WRONGED, HUSKY, SHHH, SMUG, ANGRY, HAMMER, SHOCKED
TERROR, PETRIFIED, SKULL, SWEAT, SPEECHLESS, SLEEP, DROWSY, YAWN
SICK, PUKE, BETRAYED, HEADSET, EatingFood, MeMeMe, Sigh, Typing
Lemon, Get, LGTM, OnIt, OneSecond, VRHeadset, YouAreTheBest, SALUTE
SHAKE, HIGHFIVE, UPPERLEFT, ThumbsDown, SLIGHT, TONGUE, EYESCLOSED, RoarForYou
CALF, BEAR, BULL, RAINBOWPUKE, ROSE, HEART, PARTY, LIPS
BEER, CAKE, GIFT, CUCUMBER, Drumstick, Pepper, CANDIEDHAWS, BubbleTea
Coffee, Yes, No, OKR, CheckMark, CrossMark, MinusOne, Hundred
AWESOMEN, Pin, Alarm, Loudspeaker, Trophy, Fire, BOMB, Music
XmasTree, Snowman, XmasHat, FIREWORKS, 2022, REDPACKET, FORTUNE, LUCK
FIRECRACKER, StickyRiceBalls, HEARTBROKEN, POOP, StatusFlashOfInspiration, 18X, CLEAVER, Soccer
Basketball, GeneralDoNotDisturb, Status_PrivateMessage, GeneralInMeetingBusy, StatusReading, StatusInFlight, GeneralBusinessTrip, GeneralWorkFromHome
StatusEnjoyLife, GeneralTravellingCar, StatusBus, GeneralSun, GeneralMoonRest, MoonRabbit, Mooncake, JubilantRabbit
TV, Movie, Pumpkin, BeamingFace, Delighted, ColdSweat, FullMoonFace, Partying
GoGoGo, ThanksFace, SaluteFace, Shrug, ClownFace, HappyDragon
VC_CanNotSee, VC_NoSound, VC_LooksGood, VC_SoundsClear
```
## 9 位会议号处理
如果用户给的是 9 位会议号并要求发送会中消息:
1. 先按当前身份执行 `+meeting-list-active`
2. 在返回结果中按 `meeting_no` 匹配该 9 位会议号。
3. 匹配到唯一会议后取长数字 `meeting_id`
4. 用发现该会议时的同一身份执行 `+meeting-message-send`
匹配失败时不要自动入会。只有用户明确要求“让应用机器人入会/旁听/代参会”时,才改用 `+meeting-join`
## 权限和前置条件
- 用户身份:当前用户必须正在该会议中。
- 应用身份:应用机器人必须正在该会议中。
- 会议需要开启会中智能体/Agent 能力开关。
- 需要 `vc:meeting.message:write` 权限;应用身份还需要应用已安装、数据范围已配置。
应用身份权限错误时,不要引导用户反复 `auth login`。按主 skill 的“应用身份权限配置检查”处理。
## 相关
- [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-agent-meeting-join](lark-vc-agent-meeting-join.md) — 应用机器人入会

View File

@@ -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 / 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.
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata 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 + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export 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-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |

View File

@@ -99,44 +99,3 @@ 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)
}
}

View File

@@ -1,58 +0,0 @@
// 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)
}
}

View File

@@ -0,0 +1,11 @@
# VC CLI E2E Coverage
## Summary
- TestVCMeetingMessageSendDryRun: dry-run coverage for `vc +meeting-message-send`; asserts CLI flag parsing, validation, and dry-run request shape for both text and reaction messages.
- Live coverage for `vc +meeting-message-send` is intentionally not included here because it requires an active meeting, a joined user or bot identity, and meeting-message permission setup.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| dry-run ✓ / live ✕ | vc +meeting-message-send | shortcut | vc/vc_meeting_message_send_dryrun_test.go::TestVCMeetingMessageSendDryRun | `--meeting-id`; `--text`; `--msg-type reaction`; `--emoji-type`; `--uuid` | live E2E requires active VC meeting and message-enabled in-meeting identity |

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestVCMeetingMessageSendDryRun(t *testing.T) {
setVCMeetingMessageSendDryRunEnv(t)
tests := []struct {
name string
args []string
wantMsgType string
wantContent string
wantUUID string
}{
{
name: "text",
args: []string{
"vc", "+meeting-message-send",
"--meeting-id", "7651377260537433044",
"--text", "hello from dry-run",
"--uuid", "cid-dryrun-text",
"--dry-run",
},
wantMsgType: "text",
wantContent: "hello from dry-run",
wantUUID: "cid-dryrun-text",
},
{
name: "reaction",
args: []string{
"vc", "+meeting-message-send",
"--meeting-id", "7651377260537433044",
"--msg-type", "reaction",
"--emoji-type", "VC_NoSound",
"--dry-run",
},
wantMsgType: "reaction",
wantContent: "VC_NoSound",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(1), gjson.Get(out, "api.#").Int(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/vc/v1/bots/message", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "7651377260537433044", gjson.Get(out, "api.0.body.meeting_id").String(), "stdout:\n%s", out)
require.Equal(t, tt.wantMsgType, gjson.Get(out, "api.0.body.msg_type").String(), "stdout:\n%s", out)
require.Equal(t, tt.wantContent, gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out)
if tt.wantUUID == "" {
require.False(t, gjson.Get(out, "api.0.body.uuid").Exists(), "stdout:\n%s", out)
} else {
require.Equal(t, tt.wantUUID, gjson.Get(out, "api.0.body.uuid").String(), "stdout:\n%s", out)
}
})
}
}
func setVCMeetingMessageSendDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "vc_meeting_message_send_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "vc_meeting_message_send_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}