diff --git a/cmd/event/appmeta_err.go b/cmd/event/appmeta_err.go index fd9c05108..2500ec2ae 100644 --- a/cmd/event/appmeta_err.go +++ b/cmd/event/appmeta_err.go @@ -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. diff --git a/cmd/event/console_url.go b/cmd/event/console_url.go index 26e2bbdf7..efe95597c 100644 --- a/cmd/event/console_url.go +++ b/cmd/event/console_url.go @@ -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=&addons=. +// 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} } diff --git a/cmd/event/console_url_test.go b/cmd/event/console_url_test.go index 6277eb2b9..a9f3ce1ee 100644 --- a/cmd/event/console_url_test.go +++ b/cmd/event/console_url_test.go @@ -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) + } } } diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 4b7e27c3d..376384686 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -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). diff --git a/cmd/event/preflight_test.go b/cmd/event/preflight_test.go index b0239f012..bbe3a2fdc 100644 --- a/cmd/event/preflight_test.go +++ b/cmd/event/preflight_test.go @@ -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) + } +} diff --git a/internal/appmeta/app_callbacks.go b/internal/appmeta/app_callbacks.go new file mode 100644 index 000000000..5b0b97bf7 --- /dev/null +++ b/internal/appmeta/app_callbacks.go @@ -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 +} diff --git a/internal/appmeta/app_callbacks_test.go b/internal/appmeta/app_callbacks_test.go new file mode 100644 index 000000000..d7a94cf4d --- /dev/null +++ b/internal/appmeta/app_callbacks_test.go @@ -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) + } +} diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index d018ce868..849285694 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -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) diff --git a/internal/event/bus/handle_hello_test.go b/internal/event/bus/handle_hello_test.go index 034fbd1b4..b8b1e36ff 100644 --- a/internal/event/bus/handle_hello_test.go +++ b/internal/event/bus/handle_hello_test.go @@ -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) + } +} diff --git a/internal/event/bus/hub.go b/internal/event/bus/hub.go index 620d3df64..a53fc1554 100644 --- a/internal/event/bus/hub.go +++ b/internal/event/bus/hub.go @@ -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 diff --git a/internal/event/bus/hub_test.go b/internal/event/bus/hub_test.go index 04fe53641..7956b4b18 100644 --- a/internal/event/bus/hub_test.go +++ b/internal/event/bus/hub_test.go @@ -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") + } +} diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index 76a5b5c39..ec8aa7f66 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -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) } diff --git a/internal/event/consume/reject_test.go b/internal/event/consume/reject_test.go new file mode 100644 index 000000000..5ad59b693 --- /dev/null +++ b/internal/event/consume/reject_test.go @@ -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) + } +} diff --git a/internal/event/protocol/messages.go b/internal/event/protocol/messages.go index 9274c6476..afab7bbe9 100644 --- a/internal/event/protocol/messages.go +++ b/internal/event/protocol/messages.go @@ -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, diff --git a/internal/event/protocol/messages_test.go b/internal/event/protocol/messages_test.go index 2893789fa..29d00d02a 100644 --- a/internal/event/protocol/messages_test.go +++ b/internal/event/protocol/messages_test.go @@ -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) + } +} diff --git a/internal/event/registry.go b/internal/event/registry.go index 58a97dc76..dd684be67 100644 --- a/internal/event/registry.go +++ b/internal/event/registry.go @@ -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) diff --git a/internal/event/registry_test.go b/internal/event/registry_test.go index d99afb3e1..9de278079 100644 --- a/internal/event/registry_test.go +++ b/internal/event/registry_test.go @@ -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"}`)}}, + }) +} diff --git a/internal/event/types.go b/internal/event/types.go index 1121c11dc..28592c274 100644 --- a/internal/event/types.go +++ b/internal/event/types.go @@ -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"` }