diff --git a/.gitleaks.toml b/.gitleaks.toml index 8dbe4165f2..ac71766d09 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -3,6 +3,18 @@ title = "lark-cli gitleaks config" [extend] useDefault = true +# Extends the default rule of the same id: the base regex is kept and this +# allowlist is appended to that rule only, leaving every other rule untouched. +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "EventKey identifiers (dotted lowercase names like minutes.minute.generated_v1) in the event catalog fixtures are public catalog data, not credentials; the entropy heuristic misreads them" +condition = "AND" +paths = ['''cmd/event/testdata/golden/.*'''] +regexTarget = "secret" +regexes = ['''^[a-z0-9_]+(\.[a-z0-9_]+){2,}$'''] + [[rules]] id = "lark-bot-app-id" description = "Detect Lark bot app ids" diff --git a/cmd/build.go b/cmd/build.go index e6a1a29c98..13c10fb7a0 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -20,7 +20,6 @@ import ( "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" "github.com/larksuite/cli/cmd/whoami" - _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" diff --git a/cmd/event/blocked_decision_test.go b/cmd/event/blocked_decision_test.go new file mode 100644 index 0000000000..f3d861e2f7 --- /dev/null +++ b/cmd/event/blocked_decision_test.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/larksuite/cli/cmd/event/render" + "github.com/larksuite/cli/errs" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" +) + +const blockedTestKey = "im.message.receive_v1" + +// blockingPreflight reports one blocked precondition, the shape a real +// preflight produces when credentials cannot be used. +type blockingPreflight struct { + name string + blockErr error +} + +func (p *blockingPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) { + return []appconsume.Precondition{ + {Name: "console_event_published", Status: appconsume.PreconditionOK}, + { + Name: p.name, + Status: appconsume.PreconditionBlocked, + Detail: p.blockErr.Error(), + BlockErr: p.blockErr, + }, + }, nil +} + +type fixedIdentity string + +func (i fixedIdentity) Resolve(context.Context, *catalog.Entry) (string, error) { + return string(i), nil +} + +// spyRunner records whether the delivery stream was ever started. +type spyRunner struct{ started bool } + +func (r *spyRunner) Run(context.Context, appconsume.PrepareFunc) error { + r.started = true + return nil +} + +func blockedDecisionFixture(t *testing.T) (*catalog.Entry, *appconsume.Service, *appconsume.Decision, error, error) { + t.Helper() + snap := compileCatalog() + entry, ok := snap.Resolve(blockedTestKey) + if !ok { + t.Fatalf("catalog has no %s", blockedTestKey) + } + blockErr := errs.NewPermissionError(errs.SubtypeMissingScope, + "missing scopes for %s", blockedTestKey). + WithMissingScopes("im:message", "im:chat:readonly"). + WithHint("run `lark-cli auth login --scope im:message` and retry") + + svc := &appconsume.Service{ + Strategies: appconsume.DefaultRegistry(), + Identity: fixedIdentity("bot"), + Preflight: &blockingPreflight{name: "credentials_available", blockErr: blockErr}, + } + decision, err := svc.Decide(context.Background(), entry, appconsume.Request{EventKey: blockedTestKey}, appconsume.ExecutionContext{}) + return entry, svc, decision, blockErr, err +} + +// The dry-run envelope for a blocked decision is a contract an orchestrator +// reads: ok stays true and the exit code stays 0, because the preview itself +// succeeded, and "would this run" is answered inside the payload. Anything that +// only checks ok or the exit code would treat a refusal as a green light, so +// the status and the blocked precondition must be present and named. +func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) { + _, _, decision, _, err := blockedDecisionFixture(t) + if err != nil { + t.Fatalf("deciding must succeed even when a precondition blocks, got: %v", err) + } + + var stdout, stderr bytes.Buffer + if err := render.WriteDecisionJSON(&stdout, &stderr, "bot", decision.View()); err != nil { + t.Fatalf("render: %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + DryRun bool `json:"dry_run"` + Data struct { + Decision struct { + Status string `json:"status"` + Preconditions []struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + Subtype string `json:"subtype"` + Hint string `json:"hint"` + MissingScopes []string `json:"missing_scopes"` + } `json:"preconditions"` + WouldWrite []string `json:"would_write"` + } `json:"decision"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String()) + } + + if !envelope.OK || !envelope.DryRun { + t.Errorf("the preview succeeded, so ok and dry_run stay true; got: %s", stdout.String()) + } + if envelope.Data.Decision.Status != "blocked" { + t.Errorf("status = %q, want \"blocked\": this is the only field that tells a caller the real run would refuse", + envelope.Data.Decision.Status) + } + + var blocked *struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` + Subtype string `json:"subtype"` + Hint string `json:"hint"` + MissingScopes []string `json:"missing_scopes"` + } + for i := range envelope.Data.Decision.Preconditions { + if envelope.Data.Decision.Preconditions[i].Status == "blocked" { + blocked = &envelope.Data.Decision.Preconditions[i] + } + } + if blocked == nil { + t.Fatalf("a blocked decision must name the precondition that blocks it, got: %s", stdout.String()) + } + if blocked.Name != "credentials_available" { + t.Errorf("blocked precondition = %q, want the one the preflight reported", blocked.Name) + } + if blocked.Detail == "" { + t.Error("a blocked precondition must carry a detail; without it the caller knows only that something failed") + } + // The preview is read before acting, so it has to say what to do about the + // refusal — the same recovery information a real run puts in its error + // envelope, in the same machine-readable shape. + if blocked.Subtype != string(errs.SubtypeMissingScope) { + t.Errorf("subtype = %q, want the classification callers branch on", blocked.Subtype) + } + if blocked.Hint == "" { + t.Error("a blocked precondition must carry the recovery hint; the preview is the surface an agent reads before acting") + } + if len(blocked.MissingScopes) != 2 { + t.Errorf("missing_scopes = %v, want the concrete scopes to grant", blocked.MissingScopes) + } + // A precondition that passed has nothing to recover from and must stay bare. + for _, pc := range envelope.Data.Decision.Preconditions { + if pc.Status == "ok" && (pc.Subtype != "" || pc.Hint != "" || len(pc.MissingScopes) > 0) { + t.Errorf("a passing precondition must carry no recovery fields, got %+v", pc) + } + } + // Declared write side effects stay declarations in a preview. + if len(envelope.Data.Decision.WouldWrite) == 0 { + t.Error("the preview must still declare what a real run would write") + } +} + +// Executing a blocked decision returns the preflight's own error and never +// starts the stream. Everything the command sets up for a live run — the stdin +// EOF watcher included — hangs off the runner for this reason: started earlier, +// it announced "stdin closed — shutting down" on a run that was actually +// refused for an unmet precondition. +func TestBlockedDecision_ExecuteNeverStartsTheStream(t *testing.T) { + entry, svc, decision, blockErr, err := blockedDecisionFixture(t) + if err != nil { + t.Fatalf("decide: %v", err) + } + + runner := &spyRunner{} + execErr := svc.Execute(context.Background(), entry, decision, runner, appconsume.ExecutionContext{}) + + if runner.started { + t.Error("a blocked decision must not start the delivery stream") + } + problem, ok := errs.ProblemOf(execErr) + if !ok { + t.Fatalf("executing a blocked decision must return the preflight's typed error, got: %v", execErr) + } + if problem.Subtype != errs.SubtypeMissingScope { + t.Errorf("subtype = %q, want the preflight's own subtype preserved", problem.Subtype) + } + // The caller must receive the preflight's own error, not a copy of its + // message: the hint is what tells an operator how to recover, and a rewrap + // would drop it. + if !errors.Is(execErr, blockErr) { + t.Errorf("execute returned %v, want the preflight's own error", execErr) + } + if problem.Hint == "" { + t.Error("the recovery hint the preflight attached must survive to the caller") + } +} diff --git a/cmd/event/bus.go b/cmd/event/bus.go index 61d2d3c0e1..277a95a345 100644 --- a/cmd/event/bus.go +++ b/cmd/event/bus.go @@ -16,12 +16,14 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/lark/websocket" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/event/bus" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/catalog" ) // NewCmdBus creates the hidden `event _bus` daemon subcommand, forked by the consume client; fork argv lives in consume/startup.go. -func NewCmdBus(f *cmdutil.Factory) *cobra.Command { +func NewCmdBus(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var domain string cmd := &cobra.Command{ @@ -44,7 +46,13 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command { } tr := transport.New() - b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger) + ingress := &websocket.FeishuSource{ + AppID: cfg.AppID, + AppSecret: cfg.AppSecret, + Domain: domain, + Logger: logger, + } + b := bus.NewBus(cfg.AppID, cfg.AppSecret, domain, tr, logger, snap, ingress) ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() diff --git a/cmd/event/bus_test.go b/cmd/event/bus_test.go index b974cb38a1..ffd428b40b 100644 --- a/cmd/event/bus_test.go +++ b/cmd/event/bus_test.go @@ -27,7 +27,7 @@ func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu, }) - cmd := NewCmdBus(f) + cmd := NewCmdBus(f, compileCatalog()) cmd.SetArgs([]string{}) err := cmd.Execute() diff --git a/cmd/event/consume.go b/cmd/event/consume.go index 833a58fb14..538e7065c9 100644 --- a/cmd/event/consume.go +++ b/cmd/event/consume.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/cmd/event/render" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/appmeta" "github.com/larksuite/cli/internal/auth" @@ -23,8 +24,10 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/consume" - "github.com/larksuite/cli/internal/event/transport" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" ) @@ -37,9 +40,10 @@ type consumeCmdOpts struct { maxEvents int timeout time.Duration + dryRun bool } -func NewCmdConsume(f *cmdutil.Factory) *cobra.Command { +func NewCmdConsume(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var o consumeCmdOpts cmd := &cobra.Command{ @@ -57,15 +61,16 @@ Use 'event list' to see all available EventKeys. Use 'event schema ' for parameter details.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runConsume(cmd, f, args[0], o) + return runConsume(cmd, f, snap, args[0], o) }, } cmd.Flags().StringArrayVarP(&o.params, "param", "p", nil, "Key=value parameter (repeatable)") cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output") - cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr") + cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress routine and per-event stderr output, including ready/exit markers and drop diagnostics. This can hide event loss; omit --quiet when integrity matters") cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)") cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.") + cmd.Flags().BoolVar(&o.dryRun, "dry-run", false, "Decide and preview the consume (identity, preconditions, side effects) without performing any of them, then exit") cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.") cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)") _ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { @@ -76,7 +81,7 @@ Use 'event schema ' for parameter details.`, return cmd } -func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consumeCmdOpts) error { +func runConsume(cmd *cobra.Command, f *cmdutil.Factory, snap *catalog.Snapshot, eventKey string, o consumeCmdOpts) error { // Pipe-close (e.g. `... | head -n 1`) must reach the EPIPE error path in the loop, not SIGPIPE-kill. ignoreBrokenPipe() @@ -90,10 +95,11 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu return err } - keyDef, ok := eventlib.Lookup(eventKey) + entry, ok := snap.Resolve(eventKey) if !ok { - return unknownEventKeyErr(eventKey) + return unknownEventKeyErr(snap, eventKey) } + keyDef := entry.Definition() identity, err := resolveIdentity(cmd, f, keyDef) if err != nil { @@ -120,9 +126,16 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu domain := core.ResolveEndpoints(cfg.Brand).Open - // Surface auth errors before forking the bus daemon. + // Surface auth errors before forking the bus daemon. A dry run instead + // reports the unusable credential as a blocked precondition: the caller + // asked what would happen, and "a real run would refuse to authenticate" + // is a legitimate part of that answer. + var tokenErr error if _, err := resolveTenantToken(cmd.Context(), f, cfg.AppID); err != nil { - return err + if !o.dryRun { + return err + } + tokenErr = err } apiClient, err := f.NewAPIClient() @@ -169,11 +182,31 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu appVer: appVer, subscribedCallbacks: subscribedCallbacks, } - if err := preflightEventTypes(pf); err != nil { + + svc := &appconsume.Service{ + Strategies: consumeStrategies, + Identity: identityResolverFunc(func(context.Context, *catalog.Entry) (string, error) { return string(identity), nil }), + Preflight: preflightReaderFunc(func(ctx context.Context, _ *catalog.Entry, _ string) ([]appconsume.Precondition, error) { + return readPreconditions(ctx, pf, appVerErr, tokenErr), nil + }), + } + req := appconsume.Request{ + EventKey: eventKey, + Params: paramMap, + JQExpr: o.jqExpr, + OutputDir: outputDir, + DryRun: o.dryRun, + MaxEvents: o.maxEvents, + Timeout: o.timeout, + IsTTY: f.IOStreams.IsTerminal, + } + decision, err := svc.Decide(cmd.Context(), entry, req, appconsume.ExecutionContext{API: runtime}) + if err != nil { return err } - if err := preflightScopes(cmd.Context(), pf); err != nil { - return err + + if o.dryRun { + return render.WriteDecisionJSON(f.IOStreams.Out, f.IOStreams.ErrOut, string(identity), decision.View()) } ctx, cancel := context.WithCancel(cmd.Context()) @@ -198,29 +231,56 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu errOut = io.Discard } - // Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers. - // Bounded runs already have --max-events/--timeout as their lifecycle control. - if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) { - watchStdinEOF(os.Stdin, cancel, errOut) - } - - if err := consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, consume.Options{ - EventKey: eventKey, - Params: paramMap, - JQExpr: o.jqExpr, - Quiet: o.quiet, - OutputDir: outputDir, - Runtime: runtime, - Out: f.IOStreams.Out, - ErrOut: errOut, - RemoteAPIClient: botRuntime, - MaxEvents: o.maxEvents, - Timeout: o.timeout, - IsTTY: f.IOStreams.IsTerminal, - }); err != nil { - return err - } - return nil + runner := streamRunnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + // Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess + // callers. Bounded runs already have --max-events/--timeout as their + // lifecycle control. + // + // The watcher starts here rather than before the decision is executed: + // a blocked decision never reaches this point, and starting it earlier + // announced "stdin closed — shutting down" on a run that was actually + // refused for an unmet precondition, pointing the caller at the wrong + // cause. + if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) { + watchStdinEOF(os.Stdin, cancel, errOut) + } + + return consume.Run(ctx, transport.New(), cfg.AppID, cfg.ProfileName, domain, + applyDecision(consume.Options{ + EventKey: eventKey, + Def: keyDef, + JQExpr: o.jqExpr, + Quiet: o.quiet, + OutputDir: outputDir, + Runtime: runtime, + Out: f.IOStreams.Out, + ErrOut: errOut, + RemoteAPIClient: botRuntime, + MaxEvents: o.maxEvents, + Timeout: o.timeout, + IsTTY: f.IOStreams.IsTerminal, + }, decision, prepare)) + }) + return svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: runtime}) +} + +// applyDecision transfers the decided parts of a consume onto the host's +// options. It exists as a named function because these three assignments are +// couplings the command alone can get wrong, and a test can only pin them where +// they are written. +// +// The parameters and the flag travel together: the deciding layer already ran +// the normalizer on exactly these values to compute the subscription identity. +// The flag without the values would leave the host normalizing input the bus +// was never told about; the values without the flag would run a +// once-per-consumer hook a second time. Prepare carries the strategy the +// decision settled on, so what was decided is what executes instead of the +// declaration's own hook. +func applyDecision(opts consume.Options, decision *appconsume.Decision, prepare appconsume.PrepareFunc) consume.Options { + opts.Params = decision.NormalizedParams() + opts.ParamsNormalized = true + opts.Prepare = prepare + return opts } // resolveIdentity resolves the session identity and enforces keyDef.AuthTypes as a whitelist. @@ -248,10 +308,14 @@ type preflightCtx struct { subscribedCallbacks []string } -// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes). -func preflightScopes(ctx context.Context, pf *preflightCtx) error { +// preflightScopes compares required scopes against session-available scopes +// (user: UAT stored; bot: appVer.TenantScopes). checked reports whether a +// comparison actually happened: "the ledger was unavailable" and "the check +// passed" are different answers, and only the caller can decide how loudly to +// say the first one. +func preflightScopes(ctx context.Context, pf *preflightCtx) (checked bool, err error) { if len(pf.keyDef.Scopes) == 0 || pf.identity == "" { - return nil + return true, nil } if ctx == nil { ctx = context.Background() @@ -261,22 +325,22 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error { switch { case pf.identity.IsBot(): if pf.appVer == nil { - return nil + return false, nil } storedScopes = strings.Join(pf.appVer.TenantScopes, " ") case pf.identity == core.AsUser: result, err := pf.factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(pf.identity, pf.appID)) if err != nil || result == nil || result.Scopes == "" { - return nil //nolint:nilerr // best-effort: bus handshake will surface real auth error + return false, nil //nolint:nilerr // best-effort: the bus handshake surfaces the real auth error } storedScopes = result.Scopes default: - return nil + return false, nil } missing := auth.MissingScopes(storedScopes, pf.keyDef.Scopes) if len(missing) == 0 { - return nil + return true, nil } permissionErr := errs.NewPermissionError(errs.SubtypeMissingScope, "missing required scopes for EventKey %s (as %s): %s", @@ -286,7 +350,11 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error { if pf.identity.IsBot() { permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing)) } - return permissionErr + // The scope check itself completed, so the precondition is answered even + // though it answered "missing". A user-identity hint is deliberately left + // unset: the root presenter generates it from the identity and missing + // scopes, projected onto the commands this distribution actually ships. + return true, permissionErr } // scopeRemediationHint returns an identity-appropriate fix for missing scopes. diff --git a/cmd/event/consume_dryrun_test.go b/cmd/event/consume_dryrun_test.go new file mode 100644 index 0000000000..d583b6104a --- /dev/null +++ b/cmd/event/consume_dryrun_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// A dry run in a degraded environment (the test factory has no reachable +// platform, so every weak read-only check comes back unanswered) still exits +// zero with a structured decision that honestly says "unknown" — and performs +// none of its declared write effects. +func TestDryRun_DegradedEnvironmentStaysHonestAndSideEffectFree(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_test"}) + snap := compileCatalog() + + tmp := t.TempDir() + prevWD, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(prevWD) }) + + cmd := NewCmdConsume(f, snap) + cmd.SetArgs([]string{"im.message.receive_v1", "--as", "bot", "--dry-run", "--output-dir", "events-out"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err != nil { + t.Fatalf("dry-run must not fail on unusable credentials, got: %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + DryRun bool `json:"dry_run"` + Data struct { + Decision struct { + Status string `json:"status"` + Preconditions []struct { + Name string `json:"name"` + Status string `json:"status"` + } `json:"preconditions"` + WouldWrite []string `json:"would_write"` + } `json:"decision"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String()) + } + if !envelope.OK || !envelope.DryRun { + t.Errorf("want ok=true dry_run=true, got: %s", stdout.String()) + } + if envelope.Data.Decision.Status != "unknown" { + t.Errorf("unanswerable weak checks must render unknown, not fake readiness; got status %q", envelope.Data.Decision.Status) + } + names := map[string]string{} + for _, p := range envelope.Data.Decision.Preconditions { + names[p.Name] = p.Status + } + if names["credentials_available"] == "" || names["console_event_published"] == "" || names["scopes_granted"] == "" { + t.Errorf("preconditions must name every check, got: %v", names) + } + + // The declared write side effects must stay declarations: the requested + // output dir must not exist after a dry run. + if _, err := os.Stat(filepath.Join(tmp, "events-out")); !os.IsNotExist(err) { + t.Error("dry-run created the output directory; the preview performed a side effect") + } +} diff --git a/cmd/event/event.go b/cmd/event/event.go index c1f26b5396..93fe0f8d24 100644 --- a/cmd/event/event.go +++ b/cmd/event/event.go @@ -18,12 +18,13 @@ func NewCmdEvents(f *cmdutil.Factory) *cobra.Command { SilenceUsage: true, } - cmd.AddCommand(NewCmdConsume(f)) - cmd.AddCommand(NewCmdList(f)) - cmd.AddCommand(NewCmdSchema(f)) + snap := compileCatalog() + cmd.AddCommand(NewCmdConsume(f, snap)) + cmd.AddCommand(NewCmdList(f, snap)) + cmd.AddCommand(NewCmdSchema(f, snap)) cmd.AddCommand(NewCmdStatus(f)) cmd.AddCommand(NewCmdStop(f)) - cmd.AddCommand(NewCmdBus(f)) + cmd.AddCommand(NewCmdBus(f, snap)) return cmd } diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index 5e4117b8a6..2398f83d25 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -12,7 +12,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" "github.com/larksuite/cli/internal/output" ) @@ -288,9 +288,10 @@ func errorAs(err error, target interface{}) bool { func TestNewCmdFactories_WireFlags(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_XXXXXXXXXXXXXXXX"}) + snap := compileCatalog() t.Run("consume", func(t *testing.T) { - cmd := NewCmdConsume(f) + cmd := NewCmdConsume(f, snap) for _, flag := range []string{"param", "jq", "quiet", "output-dir", "max-events", "timeout", "as"} { if cmd.Flags().Lookup(flag) == nil { t.Errorf("consume missing --%s flag", flag) @@ -320,14 +321,22 @@ func TestNewCmdFactories_WireFlags(t *testing.T) { }) t.Run("list", func(t *testing.T) { - cmd := NewCmdList(f) + cmd := NewCmdList(f, snap) if cmd.Flags().Lookup("json") == nil { t.Error("list missing --json flag") } + domainFlag := cmd.Flags().Lookup("domain") + if domainFlag == nil { + t.Fatal("list missing --domain flag") + } + wantUsage := "Only list EventKeys of this domain. Valid domains: " + strings.Join(snap.Domains(), ", ") + if domainFlag.Usage != wantUsage { + t.Errorf("--domain usage = %q, want %q", domainFlag.Usage, wantUsage) + } }) t.Run("bus", func(t *testing.T) { - cmd := NewCmdBus(f) + cmd := NewCmdBus(f, snap) if !cmd.Hidden { t.Error("bus should be hidden (internal daemon entrypoint)") } diff --git a/cmd/event/golden_test.go b/cmd/event/golden_test.go new file mode 100644 index 0000000000..ece1b5caf5 --- /dev/null +++ b/cmd/event/golden_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +var updateGolden = flag.Bool("update", false, "rewrite golden files instead of comparing") + +// goldenSchemaKeys picks one key per rendering path so every branch of the +// list/schema output stays pinned: a processed key with a flat custom schema, +// a native key with field overrides, a callback key with a single consumer, +// and a key with a required parameter plus a pre-consume hook. +var goldenSchemaKeys = map[string]string{ + "schema_im_message_receive": "im.message.receive_v1", + "schema_im_chat_updated": "im.chat.updated_v1", + "schema_card_action_trigger": "card.action.trigger", + "schema_board_whiteboard": "board.whiteboard.updated_v1", +} + +// The golden files pin stdout byte-for-byte. The output is deterministic: +// the snapshot keeps keys sorted, encoding/json sorts object keys, and nothing +// on the rendering path reads the clock or randomness. Regenerate with: +// +// go test ./cmd/event/ -run TestGolden -update +func TestGolden_ListOutput(t *testing.T) { + snap := compileCatalog() + for name, asJSON := range map[string]bool{"list_text": false, "list_json": true} { + t.Run(name, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runList(f, snap, "", asJSON); err != nil { + t.Fatalf("runList: %v", err) + } + assertGolden(t, name, stdout.String()) + }) + } +} + +func TestGolden_SchemaOutput(t *testing.T) { + snap := compileCatalog() + for name, key := range goldenSchemaKeys { + for suffix, asJSON := range map[string]bool{"_text": false, "_json": true} { + t.Run(name+suffix, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runSchema(f, snap, key, asJSON); err != nil { + t.Fatalf("runSchema(%s): %v", key, err) + } + assertGolden(t, name+suffix, stdout.String()) + }) + } + } +} + +func assertGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", "golden", name+".golden") + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("missing golden %s (regenerate with -update): %v", name, err) + } + if string(want) != got { + t.Errorf("output drifted from golden %s\n--- want\n%s\n--- got\n%s", name, want, got) + } +} diff --git a/cmd/event/list.go b/cmd/event/list.go index de2a95720d..beeb419099 100644 --- a/cmd/event/list.go +++ b/cmd/event/list.go @@ -10,31 +10,44 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/output" ) -func NewCmdList(f *cmdutil.Factory) *cobra.Command { +func NewCmdList(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var asJSON bool + var domain string cmd := &cobra.Command{ Use: "list", Short: "List all available EventKeys", - Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --json for machine-readable output.", + Long: "Show all registered EventKeys grouped by domain (first segment of the key). Use --domain to keep one domain only, --json for machine-readable output.", RunE: func(cmd *cobra.Command, args []string) error { - return runList(f, asJSON) + return runList(f, snap, domain, asJSON) }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)") + cmd.Flags().StringVar(&domain, "domain", "", fmt.Sprintf( + "Only list EventKeys of this domain. Valid domains: %s", + strings.Join(snap.Domains(), ", "), + )) cmdutil.SetRisk(cmd, "read") return cmd } -func runList(f *cmdutil.Factory, asJSON bool) error { - all := eventlib.ListAll() - +func runList(f *cmdutil.Factory, snap *catalog.Snapshot, domain string, asJSON bool) error { + entries, err := entriesForDomain(snap, domain) + if err != nil { + return err + } if asJSON { - return writeListJSON(f, all) + return writeListJSON(f, entries) + } + all := make([]*eventlib.KeyDefinition, 0, len(entries)) + for _, entry := range entries { + all = append(all, entry.Definition()) } if len(all) == 0 { @@ -104,18 +117,43 @@ func runList(f *cmdutil.Factory, asJSON bool) error { return nil } -func writeListJSON(f *cmdutil.Factory, all []*eventlib.KeyDefinition) error { - type row struct { - *eventlib.KeyDefinition - ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` +// listRow is the JSON shape of one `event list --json` row. It is a named +// type (not a function-local literal) so the render contract test can walk +// its fields and reject accidental additions to the public output. +type listRow struct { + *eventlib.KeyDefinition + ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` +} + +// entriesForDomain filters at the snapshot query layer: without a domain the +// full catalog comes back untouched; with one, rows are only removed, never +// reshaped. An unknown domain is rejected with the valid set spelled out. +func entriesForDomain(snap *catalog.Snapshot, domain string) ([]*catalog.Entry, error) { + if domain == "" { + return snap.Entries(), nil } - rows := make([]row, len(all)) - for i, def := range all { - resolved, _, err := resolveSchemaJSON(def) - if err != nil { - return err + var filtered []*catalog.Entry + for _, entry := range snap.Entries() { + if entry.Descriptor().Domain == domain { + filtered = append(filtered, entry) + } + } + if len(filtered) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown domain: %s", domain). + WithParam("--domain"). + WithHint("valid domains: %s", strings.Join(snap.Domains(), ", ")) + } + return filtered, nil +} + +func writeListJSON(f *cmdutil.Factory, entries []*catalog.Entry) error { + rows := make([]listRow, len(entries)) + for i, entry := range entries { + rows[i] = listRow{ + KeyDefinition: entry.Definition(), + ResolvedSchema: entry.Output().SchemaJSON, } - rows[i] = row{KeyDefinition: def, ResolvedSchema: resolved} } output.PrintJson(f.IOStreams.Out, rows) return nil diff --git a/cmd/event/list_domain_test.go b/cmd/event/list_domain_test.go new file mode 100644 index 0000000000..5452bae92d --- /dev/null +++ b/cmd/event/list_domain_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// Filtering only removes rows: the vc selection must be exactly the catalog's +// vc keys, and every remaining row keeps the unfiltered field set. +func TestListDomain_FilterKeepsExactlyTheRequestedDomain(t *testing.T) { + snap := compileCatalog() + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runList(f, snap, "vc", true); err != nil { + t.Fatal(err) + } + var rows []map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil { + t.Fatal(err) + } + + want := map[string]bool{} + for _, key := range snap.Keys() { + if strings.HasPrefix(key, "vc.") { + want[key] = true + } + } + if len(want) == 0 { + t.Fatal("the catalog has no vc keys; the filter test proves nothing") + } + got := map[string]bool{} + for _, row := range rows { + var key string + _ = json.Unmarshal(row["key"], &key) + got[key] = true + for _, field := range []string{"event_type", "schema", "resolved_output_schema"} { + if _, ok := row[field]; !ok { + t.Errorf("%s: filtering must not reshape rows; %q is missing", key, field) + } + } + } + if len(got) != len(want) { + t.Fatalf("filtered rows = %v, want the exact vc set %v", got, want) + } + for key := range want { + if !got[key] { + t.Errorf("vc key missing from the filtered list: %s", key) + } + } +} + +func TestListDomain_TextFilter(t *testing.T) { + snap := compileCatalog() + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runList(f, snap, "im", false); err != nil { + t.Fatal(err) + } + out := stdout.String() + if !strings.Contains(out, "im.message.receive_v1") { + t.Error("im keys must be listed") + } + for _, foreign := range []string{"vc.", "minutes.", "board.", "approval."} { + if strings.Contains(out, foreign) { + t.Errorf("foreign domain %q leaked into the filtered text output", foreign) + } + } +} + +func TestListDomain_UnknownDomainIsRejectedWithTheValidSet(t *testing.T) { + snap := compileCatalog() + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + err := runList(f, snap, "definitely-bogus", true) + if err == nil { + t.Fatal("an unknown domain must be rejected") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("want invalid_argument, got %v", err) + } + if !strings.Contains(err.Error(), "unknown domain: definitely-bogus") { + t.Errorf("error must name the rejected value, got %v", err) + } + for _, domain := range []string{"application", "approval", "board", "card", "im", "minutes", "task", "vc"} { + if !strings.Contains(problem.Hint, domain) { + t.Errorf("hint must list valid domain %q, got %q", domain, problem.Hint) + } + } +} diff --git a/cmd/event/list_test.go b/cmd/event/list_test.go index 294a2d5470..1610aecff3 100644 --- a/cmd/event/list_test.go +++ b/cmd/event/list_test.go @@ -10,20 +10,18 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" - eventlib "github.com/larksuite/cli/internal/event" - - _ "github.com/larksuite/cli/events" ) func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { + snap := compileCatalog() for _, key := range []string{ "approval.instance.status_changed_v4", "approval.task.status_changed_v4", "vc.meeting.participant_meeting_started_v1", "vc.meeting.participant_meeting_joined_v1", } { - if _, ok := eventlib.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) should succeed", key) + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q) should succeed", key) } } } @@ -31,7 +29,7 @@ func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { func TestRunList_TextOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, false); err != nil { + if err := runList(f, compileCatalog(), "", false); err != nil { t.Fatalf("runList: %v", err) } @@ -55,7 +53,7 @@ func TestRunList_TextOutput(t *testing.T) { func TestRunList_JSONOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runList(f, true); err != nil { + if err := runList(f, compileCatalog(), "", true); err != nil { t.Fatalf("runList json: %v", err) } diff --git a/cmd/event/preconditions_test.go b/cmd/event/preconditions_test.go new file mode 100644 index 0000000000..23cdaa5030 --- /dev/null +++ b/cmd/event/preconditions_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "errors" + "testing" + + "github.com/larksuite/cli/internal/core" + eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" +) + +func preconditionByName(list []appconsume.Precondition, name string) *appconsume.Precondition { + for i := range list { + if list[i].Name == name { + return &list[i] + } + } + return nil +} + +// An unusable credential blocks the decision and carries the exact error a +// real run would have returned, so both paths refuse for the same reason. +func TestReadPreconditions_TokenErrorBlocksWithTheSameError(t *testing.T) { + tokenErr := errors.New("no tenant token available") + pf := &preflightCtx{ + appID: "cli_test", + identity: core.AsBot, + keyDef: &eventlib.KeyDefinition{Key: "demo.thing.updated_v1"}, + } + got := readPreconditions(context.Background(), pf, nil, tokenErr) + + cred := preconditionByName(got, "credentials_available") + if cred == nil { + t.Fatal("credentials_available precondition missing") + } + if cred.Status != appconsume.PreconditionBlocked || !errors.Is(cred.BlockErr, tokenErr) { + t.Errorf("token failure must block with the original error, got %+v", cred) + } +} + +// A scope ledger nobody could read is reported as unknown — never as ok. +func TestReadPreconditions_UnreadableScopesAreUnknown(t *testing.T) { + pf := &preflightCtx{ + appID: "cli_test", + identity: core.AsBot, + keyDef: &eventlib.KeyDefinition{ + Key: "demo.thing.updated_v1", + Scopes: []string{"demo:read"}, + }, + appVer: nil, // no published version: the bot scope ledger is unreadable + } + got := readPreconditions(context.Background(), pf, nil, nil) + + scopes := preconditionByName(got, "scopes_granted") + if scopes == nil { + t.Fatal("scopes_granted precondition missing") + } + if scopes.Status != appconsume.PreconditionUnknown { + t.Errorf("an unreadable ledger must report unknown, got %q", scopes.Status) + } +} diff --git a/cmd/event/preflight_test.go b/cmd/event/preflight_test.go index ef757e4c3f..e2509d369f 100644 --- a/cmd/event/preflight_test.go +++ b/cmd/event/preflight_test.go @@ -108,7 +108,7 @@ func TestPreflightScopes_Bot_NoAppVer_SkipsCheck(t *testing.T) { Key: "im.message.text", Scopes: []string{"im:message", "im:message.group_at_msg"}, } - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)) + _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)) if err != nil { t.Fatalf("bot + nil appVer should skip, got: %v", err) } @@ -124,7 +124,7 @@ func TestPreflightScopes_Bot_AllGranted_Passes(t *testing.T) { "im:message.group_at_msg", "contact:user:readonly", }} - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) + _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) if err != nil { t.Fatalf("all scopes granted, unexpected error: %v", err) } @@ -136,7 +136,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) { Scopes: []string{"im:message", "im:message.group_at_msg"}, } appVer := &appmeta.AppVersion{TenantScopes: []string{"im:message"}} - err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) + _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, appVer)) if err == nil { t.Fatal("expected error for missing scope") } @@ -169,7 +169,7 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) { func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) { def := &eventlib.KeyDefinition{Key: "x"} - if err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil { + if _, err := preflightScopes(nil, newPreflightCtx("cli_x", "feishu", core.AsBot, def, nil)); err != nil { t.Fatalf("no required scopes means nothing to verify, got: %v", err) } } diff --git a/cmd/event/render/decision.go b/cmd/event/render/decision.go new file mode 100644 index 0000000000..96e6c92ca1 --- /dev/null +++ b/cmd/event/render/decision.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package render turns consume decisions into user-facing output. It is the +// only place a decision becomes JSON; the application layer never formats +// anything itself. +package render + +import ( + "io" + "regexp" + + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/output" +) + +// sensitiveParamName matches parameter names whose values must never be +// echoed back in a rendered decision. Names are matched, not values: a +// credential-bearing parameter is identifiable by its declaration, and +// guessing at value shapes would miss more than it catches. +var sensitiveParamName = regexp.MustCompile(`(?i)(token|secret|password|credential|cookie)`) + +func redactParams(params map[string]string) map[string]string { + out := make(map[string]string, len(params)) + for name, value := range params { + if sensitiveParamName.MatchString(name) { + out[name] = "[redacted]" + continue + } + out[name] = value + } + return out +} + +// decisionPayload is the JSON shape under data.decision — snake_case, stable, +// documented in the event skill. Field additions must be additive. +type decisionPayload struct { + EventKey string `json:"event_key"` + Domain string `json:"domain"` + Identity string `json:"identity"` + Status string `json:"status"` + Params map[string]string `json:"params"` + Scope string `json:"scope"` + Preconditions []preconditionView `json:"preconditions"` + Preparation *preparationView `json:"preparation,omitempty"` + WouldRead []string `json:"would_read"` + WouldWrite []string `json:"would_write"` +} + +type preconditionView struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + // The machine-readable half of a failure, mirroring the error envelope a + // real run would emit: callers branch on subtype and act on hint instead of + // matching prose. Omitted when the check did not fail. + Subtype string `json:"subtype,omitempty"` + Hint string `json:"hint,omitempty"` + MissingScopes []string `json:"missing_scopes,omitempty"` +} + +type preparationView struct { + Strategy string `json:"strategy"` + Condition string `json:"condition"` + Action string `json:"action"` +} + +// WriteDecisionJSON emits the decision inside the standard success envelope +// with the envelope's own top-level dry_run marker set. +func WriteDecisionJSON(out, errOut io.Writer, identity string, v appconsume.DecisionView) error { + return output.WriteSuccessEnvelope(map[string]any{ + "decision": toPayload(v), + }, output.SuccessEnvelopeOptions{ + CommandPath: "event consume", + Identity: identity, + DryRun: true, + Out: out, + ErrOut: errOut, + }) +} + +func toPayload(v appconsume.DecisionView) decisionPayload { + p := decisionPayload{ + EventKey: v.EventKey, + Domain: v.Domain, + Identity: v.Identity, + Status: v.Status, + Params: redactParams(v.Params), + Scope: v.Scope, + WouldRead: v.WouldRead, + WouldWrite: v.WouldWrite, + } + p.Preconditions = make([]preconditionView, 0, len(v.Preconditions)) + for _, pc := range v.Preconditions { + p.Preconditions = append(p.Preconditions, preconditionView(pc)) + } + if v.Preparation != nil { + pv := preparationView(*v.Preparation) + p.Preparation = &pv + } + return p +} diff --git a/cmd/event/render/decision_test.go b/cmd/event/render/decision_test.go new file mode 100644 index 0000000000..f27a7f219f --- /dev/null +++ b/cmd/event/render/decision_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package render + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + appconsume "github.com/larksuite/cli/internal/event/application/consume" +) + +func sampleView() appconsume.DecisionView { + return appconsume.DecisionView{ + EventKey: "vc.note.generated_v1", + Domain: "vc", + Identity: "user", + Status: "ready", + Params: map[string]string{"whiteboard_id": "wb-1", "access_token": "sk-SENSITIVE-VALUE"}, + Scope: "vc.note.generated_v1", + Preconditions: []appconsume.PreconditionView{ + {Name: "console_event_published", Status: "ok"}, + {Name: "scopes_granted", Status: "ok"}, + }, + Preparation: &appconsume.PreparationView{ + Strategy: "legacy_preconsume", Condition: "first_consumer_for_scope", Action: "register_event_delivery", + }, + WouldRead: []string{"local_bus_probe", "app_metadata_preflight"}, + WouldWrite: []string{"start_or_reuse_local_bus", "register_consumer", "run_preparation_when_first", "open_event_stream"}, + } +} + +// The JSON contract: dry_run is the envelope's own top-level marker (never a +// data field), and the decision sits under data.decision with its documented +// members. +func TestWriteDecisionJSON_EnvelopeContract(t *testing.T) { + var out, errOut bytes.Buffer + if err := WriteDecisionJSON(&out, &errOut, "user", sampleView()); err != nil { + t.Fatal(err) + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String()) + } + if string(envelope["ok"]) != "true" || string(envelope["dry_run"]) != "true" { + t.Errorf("envelope must carry top-level ok=true and dry_run=true, got %s", out.String()) + } + if _, misplaced := envelope["decision"]; misplaced { + t.Error("decision must live under data, not at the envelope top level") + } + + var data struct { + Decision struct { + EventKey string `json:"event_key"` + Domain string `json:"domain"` + Identity string `json:"identity"` + Status string `json:"status"` + Params map[string]string `json:"params"` + Scope string `json:"scope"` + Preparation *struct { + Strategy string `json:"strategy"` + Condition string `json:"condition"` + Action string `json:"action"` + } `json:"preparation"` + WouldRead []string `json:"would_read"` + WouldWrite []string `json:"would_write"` + DryRun *bool `json:"dry_run"` + } `json:"decision"` + } + if err := json.Unmarshal(envelope["data"], &data); err != nil { + t.Fatalf("data.decision does not match the documented shape: %v", err) + } + d := data.Decision + if d.EventKey != "vc.note.generated_v1" || d.Domain != "vc" || d.Identity != "user" || d.Status != "ready" { + t.Errorf("identity facts drifted: %+v", d) + } + if d.Preparation == nil || d.Preparation.Condition != "first_consumer_for_scope" { + t.Errorf("conditional preparation must be stated: %+v", d.Preparation) + } + if len(d.WouldRead) == 0 || len(d.WouldWrite) == 0 { + t.Error("would_read / would_write must be present") + } + if d.DryRun != nil { + t.Error("dry_run inside data.decision would duplicate the envelope marker") + } +} + +// Sensitive parameter values never reach the rendered output. The control +// assertion first proves the sentinel would be visible if leaked. +func TestWriteDecision_RedactsSensitiveParams(t *testing.T) { + const sentinel = "sk-SENSITIVE-VALUE" + view := sampleView() + if !strings.Contains(view.Params["access_token"], sentinel) { + t.Fatal("control failed: the sentinel is not in the input, the test cannot prove redaction") + } + + var jsonOut, jsonErr bytes.Buffer + if err := WriteDecisionJSON(&jsonOut, &jsonErr, "user", view); err != nil { + t.Fatal(err) + } + if strings.Contains(jsonOut.String(), sentinel) { + t.Errorf("JSON output leaks a sensitive param value: %s", jsonOut.String()) + } + compact := strings.ReplaceAll(strings.ReplaceAll(jsonOut.String(), "\n", ""), " ", "") + if !strings.Contains(compact, `"access_token":"[redacted]"`) { + t.Errorf("sensitive param must render as redacted, got: %s", jsonOut.String()) + } + if !strings.Contains(compact, `"whiteboard_id":"wb-1"`) { + t.Errorf("non-sensitive params must render verbatim, got: %s", jsonOut.String()) + } +} diff --git a/cmd/event/render/redaction_guard_test.go b/cmd/event/render/redaction_guard_test.go new file mode 100644 index 0000000000..82b8392f53 --- /dev/null +++ b/cmd/event/render/redaction_guard_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package render + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// This file is a guard, not a contract: it does not pin what the redaction +// regex matches, it hunts for declared parameter names that smell like +// credentials yet would render verbatim. The detector wordlist is therefore +// deliberately wider than the production sensitiveParamName pattern — a hit +// here means either the parameter should be renamed or the production +// pattern must grow, decided by a human, never by loosening this list. + +// credentialWords are matched against whole '_'/'-'/'.'-separated segments of +// a parameter name, so chat_key or tokenizer_mode cannot trip them. The bare +// word "key" is intentionally absent (identifier names like whiteboard_id or +// a hypothetical chat_key are not credentials); the api/key pairing is what +// carries credential semantics and is detected as a pair below. +var credentialWords = map[string]bool{ + "token": true, + "secret": true, + "password": true, + "credential": true, + "credentials": true, + "cookie": true, + "auth": true, + "signature": true, + "bearer": true, + "apikey": true, +} + +// smellsLikeCredential reports whether a parameter name carries credential +// semantics per the guard wordlist: any single segment in credentialWords, +// or the adjacent segment pair api+key. +func smellsLikeCredential(name string) bool { + segments := strings.FieldsFunc(strings.ToLower(name), func(r rune) bool { + return r == '_' || r == '-' || r == '.' + }) + for i, seg := range segments { + if credentialWords[seg] { + return true + } + if seg == "api" && i+1 < len(segments) && segments[i+1] == "key" { + return true + } + } + return false +} + +// unredactedCredentialParams returns the names that smell like credentials +// but are NOT matched by the production redaction pattern — every such name +// would render its value verbatim in a dry-run decision. +func unredactedCredentialParams(names []string) []string { + var findings []string + for _, name := range names { + if smellsLikeCredential(name) && !sensitiveParamName.MatchString(name) { + findings = append(findings, name) + } + } + return findings +} + +// The detector itself must bite before the live scan means anything: known +// credential-shaped names that the production pattern misses must be caught, +// and ordinary identifier names must pass. +func TestRedactionGuardDetector_SelfCheck(t *testing.T) { + // Credential-shaped and covered by the production pattern: no finding. + for _, name := range []string{"access_token", "client_secret", "user_password", "session_cookie", "sso_credential"} { + if got := unredactedCredentialParams([]string{name}); len(got) != 0 { + t.Errorf("%q is redacted by the production pattern, the guard must not flag it, got %v", name, got) + } + } + // Credential-shaped but NOT covered by the production pattern today: the + // guard must flag these, otherwise it can never catch a real gap. + for _, name := range []string{"api_key", "auth_code", "request_signature", "bearer_value"} { + if got := unredactedCredentialParams([]string{name}); len(got) != 1 { + t.Errorf("%q smells like a credential and is not redacted; the guard must flag it, got %v", name, got) + } + } + // Ordinary identifiers, including the wide-false-positive shapes the + // wordlist is segment-matched to avoid: no finding. + for _, name := range []string{"whiteboard_id", "chat_key", "tokenizer_mode", "author", "meeting_no"} { + if got := unredactedCredentialParams([]string{name}); len(got) != 0 { + t.Errorf("%q is an ordinary identifier, the guard must not flag it, got %v", name, got) + } + } +} + +// Every declared parameter of every compiled EventKey either carries no +// credential semantics or is caught by the production redaction pattern. +func TestRedactionGuard_CatalogParamsHaveNoUnredactedCredentials(t *testing.T) { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + + var names []string + for _, entry := range snap.Entries() { + desc := entry.Descriptor() + for _, p := range desc.Params { + names = append(names, desc.Key+": "+p.Name) + if findings := unredactedCredentialParams([]string{p.Name}); len(findings) != 0 { + t.Errorf("EventKey %s declares param %q which smells like a credential but is not matched by the redaction pattern; rename the param or extend sensitiveParamName deliberately", desc.Key, p.Name) + } + } + } + // A scan that visited no parameters proves nothing. + if len(names) == 0 { + t.Fatal("the compiled catalog declares no parameters at all; the guard scanned nothing") + } +} diff --git a/cmd/event/render_contract_test.go b/cmd/event/render_contract_test.go new file mode 100644 index 0000000000..9b75976794 --- /dev/null +++ b/cmd/event/render_contract_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "reflect" + "strings" + "testing" +) + +// renderedDeclarationFields lists every JSON field the list/schema commands +// are allowed to expose, each with the reason it belongs to the public +// contract. Golden files pin today's bytes; this gate protects tomorrow: a +// field added to the rendered structs (promoted through the embedded +// definition or nested anywhere under it) must either appear here +// deliberately or be tagged `json:"-"`. The set is flat — an entry admits its +// rendered name at any nesting level, which is the same latitude +// encoding/json gives a name. +var renderedDeclarationFields = map[string]string{ + "key": "stable identifier agents subscribe by", + "domain": "declared domain override; empty for every shipped key (filtering reads the derived descriptor value), so legacy output is byte-identical", + "display_name": "human-readable name for pickers", + "description": "what the event means (KeyDefinition) / what the parameter does (ParamDef)", + "event_type": "upstream event type behind this key", + "subscription_type": "which console ledger the precheck reads", + "params": "declared consume parameters", + "schema": "declared schema source (native/custom markers)", + "scopes": "OAuth scopes required to consume", + "auth_types": "identities the key accepts", + "required_console_events": "console switches that must be enabled", + "buffer_size": "delivery buffer size after normalization", + "workers": "worker count after normalization", + "single_consumer": "whether a second consumer is rejected", + "resolved_output_schema": "fully resolved JSON schema of stdout events", + "jq_root_path": "schema command only: jq root for consuming stdout", + + // Nested under params (ParamDef): everything an agent needs to pass the + // parameter correctly. + "name": "parameter name as passed via --param", + "type": "parameter value type (string/enum/multi/bool/int)", + "required": "whether the parameter must be provided", + "default": "value applied when the parameter is omitted", + "values": "allowed values for enum/multi parameters", + "subscription_key": "whether the parameter is part of the subscription identity", + + // Nested under params.values (ParamValue). + "value": "one allowed parameter value", + "desc": "what choosing this value means", + + // Nested under schema (SchemaDef / SchemaSpec): declaration markers only; + // the resolved schema is the sibling resolved_output_schema. + "native": "marker for keys delivering the raw V2 envelope", + "custom": "marker for keys delivering processed output", + "field_overrides": "per-field annotations overriding the reflected schema", + "raw": "raw declared schema bytes; empty for reflected types", + + // Nested under schema.field_overrides (schemas.FieldMeta). The type has + // no json tags, so encoding/json renders the Go field names — pinned + // as-is because retagging them would change the public bytes. + "Description": "override for the field's schema description", + "Enum": "override for the field's allowed values", + "Kind": "override rendered as the field's schema format", +} + +// TestRenderContract_NoRuntimeFieldLeaksIntoJSON walks both rendered shapes, +// following embedded struct promotion and recursing into every named type +// reachable through the rendered fields, and fails on any exported member +// that is neither allowlisted nor explicitly excluded from JSON. +func TestRenderContract_NoRuntimeFieldLeaksIntoJSON(t *testing.T) { + emitted := map[string]bool{} + for _, typ := range []reflect.Type{ + reflect.TypeFor[listRow](), + reflect.TypeFor[schemaPayload](), + } { + walkRenderedFields(t, typ, emitted, map[reflect.Type]bool{}) + } + + if len(emitted) == 0 { + t.Fatal("no rendered fields were visited; the gate scanned nothing") + } + // The embedded definition is where leaks would hide: prove promotion was + // actually followed by requiring fields that only exist on it. The nested + // sentinels prove each recursion path is really taken: subscription_key + // (slice-of-struct: ParamDef), desc (slice inside a nested struct: + // ParamValue), raw (pointer-to-struct: SchemaSpec), Enum (map value: + // FieldMeta, rendered under its Go name because the type is untagged). + for _, sentinel := range []string{ + "key", "event_type", "resolved_output_schema", + "subscription_key", "desc", "raw", "Enum", + } { + if !emitted[sentinel] { + t.Fatalf("field %q was not visited; the walker no longer reaches every rendered shape", sentinel) + } + } + for name := range renderedDeclarationFields { + if !emitted[name] { + t.Errorf("allowlist entry %q is stale: no rendered struct emits it", name) + } + } +} + +// walkRenderedFields records every JSON field name typ can render: embedded +// structs promote into the parent object, and any struct reachable through a +// field's type — behind pointers, slice/array elements, or map values — is +// walked in turn, so a field added to a nested type like ParamDef cannot +// escape the gate. visited breaks cycles; a type already recorded in this +// walk contributes nothing new. +func walkRenderedFields(t *testing.T, typ reflect.Type, emitted map[string]bool, visited map[reflect.Type]bool) { + t.Helper() + typ = nestedStructType(typ) + if typ == nil || visited[typ] { + return + } + visited[typ] = true + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + tag := field.Tag.Get("json") + if tag == "-" { + continue + } + if field.Anonymous && tag == "" { + if ft := nestedStructType(field.Type); ft != nil { + // Embedded struct without a tag: fields promote into the + // parent JSON object. + walkRenderedFields(t, ft, emitted, visited) + continue + } + } + name, _, _ := strings.Cut(tag, ",") + if name == "" { + // encoding/json renders an untagged exported field under its Go + // name (schemas.FieldMeta does this today); the rendered name is + // what the contract governs, so it is what must be declared. + name = field.Name + } + if _, ok := renderedDeclarationFields[name]; !ok { + t.Errorf("%s.%s renders JSON field %q that is not in the declared output contract; add it deliberately or exclude it with json:\"-\"", typ.Name(), field.Name, name) + } + emitted[name] = true + walkRenderedFields(t, field.Type, emitted, visited) + } +} + +// nestedStructType unwraps pointers, slice/array elements, and map values +// until it reaches the struct that would render as a JSON object; nil means +// the type renders as a leaf (scalar, string, raw bytes) and holds no fields +// to govern. +func nestedStructType(typ reflect.Type) reflect.Type { + for { + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map: + typ = typ.Elem() + case reflect.Struct: + return typ + default: + return nil + } + } +} diff --git a/cmd/event/schema.go b/cmd/event/schema.go index c078767f78..0896ba0b6b 100644 --- a/cmd/event/schema.go +++ b/cmd/event/schema.go @@ -11,75 +11,13 @@ import ( "github.com/spf13/cobra" - "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" eventlib "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/schemas" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/output" ) -// resolveSchemaJSON returns the final JSON Schema for an EventKey (reflected base, V2-wrapped for Native, overlay applied); orphans lists unresolved FieldOverrides pointers. -func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string, error) { - spec, isNative := pickSpec(def.Schema) - if spec == nil { - return nil, nil, nil - } - - base, err := renderSpec(spec) - if err != nil { - return nil, nil, err - } - if base == nil { - return nil, nil, nil - } - - if isNative { - base = schemas.WrapV2Envelope(base) - } - - if len(def.Schema.FieldOverrides) > 0 { - var parsed map[string]interface{} - if err := json.Unmarshal(base, &parsed); err != nil { - return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, - "parse base schema for field overrides: %s", err).WithCause(err) - } - orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides) - out, err := json.Marshal(parsed) - if err != nil { - return nil, nil, errs.NewInternalError(errs.SubtypeUnknown, - "serialize schema with field overrides: %s", err).WithCause(err) - } - return out, orphans, nil - } - - return base, nil, nil -} - -// pickSpec returns the non-nil spec and whether it is Native (requires V2 envelope wrap). -func pickSpec(s eventlib.SchemaDef) (*eventlib.SchemaSpec, bool) { - if s.Native != nil { - return s.Native, true - } - if s.Custom != nil { - return s.Custom, false - } - return nil, false -} - -// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied). -func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) { - if s.Type != nil { - return schemas.FromType(s.Type), nil - } - if len(s.Raw) > 0 { - buf := make(json.RawMessage, len(s.Raw)) - copy(buf, s.Raw) - return buf, nil - } - return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw") -} - -func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { +func NewCmdSchema(f *cmdutil.Factory, snap *catalog.Snapshot) *cobra.Command { var asJSON bool cmd := &cobra.Command{ Use: "schema ", @@ -87,7 +25,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { Long: "Display detailed information about an EventKey including type, events, parameters, and response schema. Use --json for machine-readable output.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runSchema(f, args[0], asJSON) + return runSchema(f, snap, args[0], asJSON) }, } cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)") @@ -95,14 +33,15 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command { return cmd } -func runSchema(f *cmdutil.Factory, key string, asJSON bool) error { - def, ok := eventlib.Lookup(key) +func runSchema(f *cmdutil.Factory, snap *catalog.Snapshot, key string, asJSON bool) error { + entry, ok := snap.Resolve(key) if !ok { - return unknownEventKeyErr(key) + return unknownEventKeyErr(snap, key) } + def := entry.Definition() if asJSON { - return writeSchemaJSON(f, def) + return writeSchemaJSON(f, entry) } out := f.IOStreams.Out @@ -170,10 +109,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error { } } - resolved, _, err := resolveSchemaJSON(def) - if err != nil { - return err - } + resolved := entry.Output().SchemaJSON if resolved != nil { fmt.Fprintf(out, "\nOutput Schema:\n") printIndentedJSON(out, resolved) @@ -202,30 +138,22 @@ func printIndentedJSON(out io.Writer, raw json.RawMessage) { fmt.Fprintf(out, " %s\n", string(formatted)) } +// schemaPayload is the JSON shape of `event schema --json`. It is a named +// type (not a function-local literal) so the render contract test can walk +// its fields and reject accidental additions to the public output. +type schemaPayload struct { + *eventlib.KeyDefinition + ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` + JQRootPath string `json:"jq_root_path,omitempty"` +} + // writeSchemaJSON emits the EventKey definition plus resolved schema; jq_root_path tells callers whether fields live at `.` or `.event`. -func writeSchemaJSON(f *cmdutil.Factory, def *eventlib.KeyDefinition) error { - type payload struct { - *eventlib.KeyDefinition - ResolvedSchema json.RawMessage `json:"resolved_output_schema,omitempty"` - JQRootPath string `json:"jq_root_path,omitempty"` - } - resolved, _, err := resolveSchemaJSON(def) - if err != nil { - return err - } - var jqRootPath string - if resolved != nil { - // Native → V2 envelope ⇒ `.event.xxx`; Custom → flat ⇒ `.`. - _, isNative := pickSpec(def.Schema) - jqRootPath = "." - if isNative { - jqRootPath = ".event" - } - } - output.PrintJson(f.IOStreams.Out, payload{ - KeyDefinition: def, - ResolvedSchema: resolved, - JQRootPath: jqRootPath, +func writeSchemaJSON(f *cmdutil.Factory, entry *catalog.Entry) error { + contract := entry.Output() + output.PrintJson(f.IOStreams.Out, schemaPayload{ + KeyDefinition: entry.Definition(), + ResolvedSchema: contract.SchemaJSON, + JQRootPath: contract.JQRootPath, }) return nil } diff --git a/cmd/event/schema_test.go b/cmd/event/schema_test.go index 9d0acf82ac..4f8bc8658e 100644 --- a/cmd/event/schema_test.go +++ b/cmd/event/schema_test.go @@ -10,15 +10,27 @@ import ( "strings" "testing" - "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/schemas" - - _ "github.com/larksuite/cli/events" ) +// compileTestSnapshot compiles synthetic declarations into a snapshot using +// the same strategy set the production wiring provides. +func compileTestSnapshot(t *testing.T, defs ...eventlib.KeyDefinition) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(defs, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test catalog: %v", err) + } + return snap +} + type approvalSchemaJSONPayload struct { JQRootPath string `json:"jq_root_path"` AuthTypes []string `json:"auth_types"` @@ -45,7 +57,7 @@ type approvalSchemaJSONProperty struct { func TestRunSchema_ProcessedKey_Text(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.receive_v1", false); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.receive_v1", false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -65,7 +77,7 @@ func TestRunSchema_ProcessedKey_Text(t *testing.T) { func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.message_read_v1", false); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.message_read_v1", false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -85,7 +97,7 @@ func TestRunSchema_NativeKey_WrapsEnvelope(t *testing.T) { func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - err := runSchema(f, "im.message.recieve_v1", false) + err := runSchema(f, compileCatalog(), "im.message.recieve_v1", false) if err == nil { t.Fatal("expected error for unknown key") } @@ -101,7 +113,7 @@ func TestRunSchema_UnknownKey_SuggestsAlternatives(t *testing.T) { func TestRunSchema_JSONOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.receive_v1", true); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -122,7 +134,7 @@ func TestRunSchema_JSONOutput(t *testing.T) { func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "im.message.receive_v1", true); err != nil { + if err := runSchema(f, compileCatalog(), "im.message.receive_v1", true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -156,7 +168,7 @@ func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) { func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil { + if err := runSchema(f, compileCatalog(), "task.task.update_user_access_v2", true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -195,7 +207,7 @@ func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, tc.key, true); err != nil { + if err := runSchema(f, compileCatalog(), tc.key, true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -243,7 +255,7 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) { t.Run(key, func(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, key, true); err != nil { + if err := runSchema(f, compileCatalog(), key, true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -276,9 +288,8 @@ func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) { func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { const syntheticKey = "test.evt_sub" - t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) - eventlib.RegisterKey(eventlib.KeyDefinition{ + snap := compileTestSnapshot(t, eventlib.KeyDefinition{ Key: syntheticKey, EventType: syntheticKey, Params: []eventlib.ParamDef{ @@ -289,7 +300,7 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { }) f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, syntheticKey, false); err != nil { + if err := runSchema(f, snap, syntheticKey, false); err != nil { t.Fatalf("runSchema: %v", err) } @@ -325,9 +336,8 @@ func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { const syntheticKey = "test.evt_json" - t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) - eventlib.RegisterKey(eventlib.KeyDefinition{ + snap := compileTestSnapshot(t, eventlib.KeyDefinition{ Key: syntheticKey, EventType: syntheticKey, Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}}, @@ -335,7 +345,7 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { }) f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) - if err := runSchema(f, syntheticKey, true); err != nil { + if err := runSchema(f, snap, syntheticKey, true); err != nil { t.Fatalf("runSchema json: %v", err) } @@ -349,12 +359,13 @@ func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { const syntheticKey = "t.custom.overlay" - t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) type out struct { SenderID string `json:"sender_id"` } - eventlib.RegisterKey(eventlib.KeyDefinition{ + // A compile that succeeds proves the overlay left no orphan pointers; the + // entry's output contract carries the resolved schema. + snap := compileTestSnapshot(t, eventlib.KeyDefinition{ Key: syntheticKey, EventType: syntheticKey, Schema: eventlib.SchemaDef{ @@ -367,13 +378,12 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { return nil, nil }, }) - def, _ := eventlib.Lookup(syntheticKey) - resolved, orphans, err := resolveSchemaJSON(def) - if err != nil || len(orphans) != 0 { - t.Fatalf("resolve: err=%v orphans=%v", err, orphans) + entry, ok := snap.Resolve(syntheticKey) + if !ok { + t.Fatalf("snap.Resolve(%q) should succeed", syntheticKey) } var parsed map[string]interface{} - if err := json.Unmarshal(resolved, &parsed); err != nil { + if err := json.Unmarshal(entry.Output().SchemaJSON, &parsed); err != nil { t.Fatal(err) } got := parsed["properties"].(map[string]interface{})["sender_id"].(map[string]interface{})["format"] @@ -382,37 +392,35 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { } } -func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) { - _, err := renderSpec(&eventlib.SchemaSpec{}) +func TestCompile_EmptySpecIsRejected(t *testing.T) { + _, err := catalog.Compile([]eventlib.KeyDefinition{{ + Key: "synthetic.empty.spec", + EventType: "synthetic.empty.spec", + Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{}}, + }}, catalog.StrategyRefs{catalog.StrategyNone}) if err == nil { t.Fatal("expected error for spec with neither Type nor Raw") } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed errs error, got %T: %v", err, err) - } - if p.Category != errs.CategoryInternal { - t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal) + if !strings.Contains(err.Error(), "exactly one of Type or Raw") { + t.Errorf("error should reject the empty spec, got: %v", err) } } -func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) { - def := &eventlib.KeyDefinition{ - Key: "synthetic.invalid.base", +func TestCompile_InvalidBaseWithOverridesIsRejected(t *testing.T) { + _, err := catalog.Compile([]eventlib.KeyDefinition{{ + Key: "synthetic.invalid.base", + EventType: "synthetic.invalid.base", Schema: eventlib.SchemaDef{ Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")}, FieldOverrides: map[string]schemas.FieldMeta{"x": {}}, }, - } - _, _, err := resolveSchemaJSON(def) + }}, catalog.StrategyRefs{catalog.StrategyNone}) if err == nil { t.Fatal("expected error for unparsable base schema") } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed errs error, got %T: %v", err, err) - } - if p.Category != errs.CategoryInternal { - t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal) + // Garbage raw bytes are rejected by the spec check itself, before the + // overlay machinery would even try to parse them. + if !strings.Contains(err.Error(), "is not a JSON object") { + t.Errorf("error should reject the unparsable base schema, got: %v", err) } } diff --git a/cmd/event/service_adapters.go b/cmd/event/service_adapters.go new file mode 100644 index 0000000000..5209fc95c7 --- /dev/null +++ b/cmd/event/service_adapters.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + + eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" +) + +// consumeStrategies is the executable strategy set for this binary. The same +// registry is handed to catalog compilation, so a reference the compiler +// accepted is guaranteed to resolve here. +var consumeStrategies = appconsume.DefaultRegistry() + +type identityResolverFunc func(ctx context.Context, entry *catalog.Entry) (string, error) + +func (f identityResolverFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) { + return f(ctx, entry) +} + +type preflightReaderFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) + +func (f preflightReaderFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]appconsume.Precondition, error) { + return f(ctx, entry, identity) +} + +type streamRunnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error + +func (f streamRunnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error { + return f(ctx, prepare) +} + +// readPreconditions classifies the existing read-only preflight checks into +// named preconditions. Weak dependencies that could not answer stay visible +// as "unknown" instead of silently passing; a failed check carries the exact +// error a real run returns, so refusal is identical on both paths. +func readPreconditions(ctx context.Context, pf *preflightCtx, appVerErr, tokenErr error) []appconsume.Precondition { + credentials := appconsume.Precondition{Name: "credentials_available", Status: appconsume.PreconditionOK} + if tokenErr != nil { + credentials.Status = appconsume.PreconditionBlocked + credentials.Detail = tokenErr.Error() + credentials.BlockErr = tokenErr + } + + console := appconsume.Precondition{Name: "console_event_published", Status: appconsume.PreconditionOK} + switch { + case len(pf.keyDef.RequiredConsoleEvents) == 0: + // nothing to verify + case pf.keyDef.SubscriptionType == eventlib.SubTypeCallback && pf.subscribedCallbacks == nil, + pf.keyDef.SubscriptionType != eventlib.SubTypeCallback && pf.appVer == nil: + console.Status = appconsume.PreconditionUnknown + if appVerErr != nil { + console.Detail = describeAppMetaErr(appVerErr) + } else { + console.Detail = "console ledger unavailable" + } + default: + if err := preflightEventTypes(pf); err != nil { + console.Status = appconsume.PreconditionBlocked + console.Detail = err.Error() + console.BlockErr = err + } + } + + scopes := appconsume.Precondition{Name: "scopes_granted", Status: appconsume.PreconditionOK} + checked, err := preflightScopes(ctx, pf) + switch { + case err != nil: + scopes.Status = appconsume.PreconditionBlocked + scopes.Detail = err.Error() + scopes.BlockErr = err + case !checked: + // The scope ledger could not be read (no published version for bots, + // no resolvable token for users). Saying "ok" here would dress up + // "nobody looked" as "it was verified". + scopes.Status = appconsume.PreconditionUnknown + scopes.Detail = "granted scopes could not be read for this identity" + } + + return []appconsume.Precondition{credentials, console, scopes} +} diff --git a/cmd/event/status.go b/cmd/event/status.go index ece33958e4..c602998a27 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -14,10 +14,10 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/event/busctl" - "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/busctl" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" ) diff --git a/cmd/event/status_orphan_test.go b/cmd/event/status_orphan_test.go index 9dea73b927..ece848748a 100644 --- a/cmd/event/status_orphan_test.go +++ b/cmd/event/status_orphan_test.go @@ -11,8 +11,8 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) type fakeScanner struct { diff --git a/cmd/event/stop.go b/cmd/event/stop.go index adab2d3bbc..725390c9f2 100644 --- a/cmd/event/stop.go +++ b/cmd/event/stop.go @@ -13,9 +13,9 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/event/busctl" - "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/busctl" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/output" ) diff --git a/cmd/event/stop_discover_test.go b/cmd/event/stop_discover_test.go index cfad83f1d5..6b185cd47d 100644 --- a/cmd/event/stop_discover_test.go +++ b/cmd/event/stop_discover_test.go @@ -9,7 +9,7 @@ import ( "sort" "testing" - "github.com/larksuite/cli/internal/event/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" ) func TestDiscoverAppIDs_OnlyLiveLockHolders(t *testing.T) { diff --git a/cmd/event/stop_integration_test.go b/cmd/event/stop_integration_test.go index ad843d63ba..4738c8c9bc 100644 --- a/cmd/event/stop_integration_test.go +++ b/cmd/event/stop_integration_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) type mockTransport struct { diff --git a/cmd/event/stream_wiring_test.go b/cmd/event/stream_wiring_test.go new file mode 100644 index 0000000000..92a22c5b8d --- /dev/null +++ b/cmd/event/stream_wiring_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "maps" + "sync/atomic" + "testing" + + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/consume" +) + +// These assert what the command hands the stream host, on the very function the +// command calls. Asserting the host honours the options is a different claim +// and lives with the host: a test that builds its own options would keep +// passing after the command stopped setting them. + +type wiringIdentity struct{} + +func (wiringIdentity) Resolve(context.Context, *catalog.Entry) (string, error) { return "bot", nil } + +type wiringPreflight struct{} + +func (wiringPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) { + return []appconsume.Precondition{{Name: "credentials_available", Status: appconsume.PreconditionOK}}, nil +} + +// wiringKey must be a key that takes a parameter: with a parameterless key the +// normalized-parameter assignment has nothing to observe, and deleting it from +// the command would go unnoticed. +const wiringKey = "board.whiteboard.updated_v1" + +func wiringDecision(t *testing.T) (*catalog.Entry, *appconsume.Decision) { + t.Helper() + snap := compileCatalog() + entry, ok := snap.Resolve(wiringKey) + if !ok { + t.Fatalf("catalog has no %s", wiringKey) + } + svc := &appconsume.Service{ + Strategies: consumeStrategies, + Identity: wiringIdentity{}, + Preflight: wiringPreflight{}, + } + decision, err := svc.Decide(context.Background(), entry, + appconsume.Request{EventKey: wiringKey, Params: map[string]string{"whiteboard_id": "board-A"}}, + appconsume.ExecutionContext{}) + if err != nil { + t.Fatalf("decide: %v", err) + } + return entry, decision +} + +// The host must be given the decision's own parameters together with the flag +// that says they are already normalized. Splitting the pair is silent either +// way: the flag without the values leaves the host normalizing input the bus +// was never told about, and the values without the flag run a +// once-per-consumer hook a second time. +func TestStreamOptions_PassesNormalizedParamsWithTheFlag(t *testing.T) { + _, decision := wiringDecision(t) + + // Seeded with a value the decision must overwrite: without a stale value to + // replace, an assignment that stopped happening would look identical. + opts := applyDecision(consume.Options{ + EventKey: wiringKey, + Params: map[string]string{"whiteboard_id": "stale-never-normalized"}, + }, decision, nil) + + if !opts.ParamsNormalized { + t.Error("the host must be told the parameters are already normalized, or it runs the hook again") + } + if !maps.Equal(opts.Params, decision.NormalizedParams()) { + t.Errorf("params = %v, want the decision's normalized values %v", opts.Params, decision.NormalizedParams()) + } + if len(decision.NormalizedParams()) == 0 { + t.Fatal("the fixture key must take a parameter, otherwise this test cannot observe the assignment") + } +} + +// The preparation the decision settled on must reach the host, so what was +// decided is what runs. +func TestStreamOptions_PassesTheDecidedPreparation(t *testing.T) { + _, decision := wiringDecision(t) + + var ran atomic.Int64 + prepare := func(context.Context) (appconsume.Cleanup, error) { + ran.Add(1) + return nil, nil + } + + opts := applyDecision(consume.Options{EventKey: wiringKey}, decision, prepare) + + if opts.Prepare == nil { + t.Fatal("the host must receive the decided preparation; without it the host falls back to the declaration's own hook and the decision is bypassed") + } + if _, err := opts.Prepare(context.Background()); err != nil { + t.Fatalf("invoking the wired preparation: %v", err) + } + if got := ran.Load(); got != 1 { + t.Errorf("the wired preparation ran %d time(s), want the one the decision chose", got) + } +} diff --git a/cmd/event/suggestions.go b/cmd/event/suggestions.go index a49979e735..75fd3d3af8 100644 --- a/cmd/event/suggestions.go +++ b/cmd/event/suggestions.go @@ -9,14 +9,14 @@ import ( "strings" "github.com/larksuite/cli/errs" - eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/suggest" ) const maxSuggestions = 3 // suggestEventKeys returns up to maxSuggestions keys resembling input (substring match beats edit distance). -func suggestEventKeys(input string) []string { +func suggestEventKeys(snap *catalog.Snapshot, input string) []string { type match struct { key string dist int @@ -24,13 +24,13 @@ func suggestEventKeys(input string) []string { var hits []match threshold := max(2, len(input)/5) - for _, def := range eventlib.ListAll() { - if strings.Contains(def.Key, input) { - hits = append(hits, match{def.Key, 0}) + for _, key := range snap.Keys() { + if strings.Contains(key, input) { + hits = append(hits, match{key, 0}) continue } - if d := suggest.Levenshtein(input, def.Key); d <= threshold { - hits = append(hits, match{def.Key, d}) + if d := suggest.Levenshtein(input, key); d <= threshold { + hits = append(hits, match{key, d}) } } sort.Slice(hits, func(i, j int) bool { return hits[i].dist < hits[j].dist }) @@ -59,9 +59,9 @@ func formatSuggestions(keys []string) string { } // unknownEventKeyErr builds the shared "unknown EventKey" error with a suggestion tail when available. -func unknownEventKeyErr(key string) error { +func unknownEventKeyErr(snap *catalog.Snapshot, key string) error { msg := fmt.Sprintf("unknown EventKey: %s", key) - if guesses := suggestEventKeys(key); len(guesses) > 0 { + if guesses := suggestEventKeys(snap, key); len(guesses) > 0 { msg += " — did you mean " + formatSuggestions(guesses) + "?" } return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg). diff --git a/cmd/event/suggestions_test.go b/cmd/event/suggestions_test.go index fdaaa2c01a..614cc5ecd1 100644 --- a/cmd/event/suggestions_test.go +++ b/cmd/event/suggestions_test.go @@ -6,11 +6,10 @@ package event import ( "strings" "testing" - - _ "github.com/larksuite/cli/events" ) func TestSuggestEventKeys(t *testing.T) { + snap := compileCatalog() cases := []struct { name string input string @@ -41,7 +40,7 @@ func TestSuggestEventKeys(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := suggestEventKeys(tc.input) + got := suggestEventKeys(snap, tc.input) if tc.wantEmpty { if len(got) != 0 { t.Errorf("expected empty slice, got %v", got) @@ -98,7 +97,7 @@ func TestFormatSuggestions(t *testing.T) { } func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) { - err := unknownEventKeyErr("im.message.recieve_v1") + err := unknownEventKeyErr(compileCatalog(), "im.message.recieve_v1") if err == nil { t.Fatal("expected error") } @@ -115,7 +114,7 @@ func TestUnknownEventKeyErr_IncludesSuggestion(t *testing.T) { } func TestUnknownEventKeyErr_NoSuggestion(t *testing.T) { - err := unknownEventKeyErr("xyzzy_no_such_event_key_at_all") + err := unknownEventKeyErr(compileCatalog(), "xyzzy_no_such_event_key_at_all") if err == nil { t.Fatal("expected error") } diff --git a/cmd/event/testdata/golden/list_json.golden b/cmd/event/testdata/golden/list_json.golden new file mode 100644 index 0000000000..433177a88f --- /dev/null +++ b/cmd/event/testdata/golden/list_json.golden @@ -0,0 +1,3023 @@ +[ + { + "key": "application.bot.menu_v6", + "display_name": "Bot menu", + "description": "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.", + "event_type": "application.bot.menu_v6", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "auth_types": [ + "bot" + ], + "required_console_events": [ + "application.bot.menu_v6" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "app_id": { + "type": "string", + "description": "Application ID from the event header" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_key": { + "type": "string", + "description": "Developer-defined bot menu event key" + }, + "menu_timestamp": { + "type": "string", + "description": "Menu click timestamp from the event body", + "format": "timestamp_ms" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id; kept as a short alias of operator_open_id", + "format": "open_id" + }, + "operator_name": { + "type": "string", + "description": "Operator display name" + }, + "operator_open_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "operator_union_id": { + "type": "string", + "description": "Operator union_id", + "format": "union_id" + }, + "operator_user_id": { + "type": "string", + "description": "Operator user_id", + "format": "user_id" + }, + "tenant_key": { + "type": "string", + "description": "Tenant key from the event header" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always application.bot.menu_v6" + } + } + } + }, + { + "key": "approval.instance.status_changed_v4", + "display_name": "Approval instance status changed", + "description": "Triggered after an approval instance status becomes visible to the requester or approval participants", + "event_type": "approval.instance.status_changed_v4", + "subscription_type": "event", + "params": [ + { + "name": "subscription_type", + "type": "multi", + "required": false, + "description": "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.", + "values": [ + { + "value": "INVOLVED_APPROVAL", + "desc": "Receive events where the current user is the approval requester or approver." + }, + { + "value": "MANAGED_APPROVAL", + "desc": "Receive events under approval definitions managed by the current user." + } + ] + } + ], + "schema": { + "custom": {} + }, + "scopes": [ + "approval:instance:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "approval.instance.status_changed_v4" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "approval_code": { + "type": "string", + "description": "Approval definition code; not a subscription dimension" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "external_id": { + "type": "string", + "description": "Third-party approval instance id; present only for third-party approvals" + }, + "instance_code": { + "type": "string", + "description": "Approval instance code" + }, + "operate_time": { + "type": "string", + "description": "Status change time in milliseconds", + "format": "timestamp_ms" + }, + "start_user": { + "type": "object", + "description": "Approval instance starter; omitted when unavailable", + "properties": { + "open_id": { + "type": "string", + "description": "User open_id; prefixed with ou_", + "format": "open_id" + }, + "union_id": { + "type": "string", + "description": "User union_id", + "format": "union_id" + }, + "user_id": { + "type": "string", + "description": "User id within the tenant", + "format": "user_id" + } + } + }, + "status": { + "type": "string", + "description": "Approval instance status", + "enum": [ + "PENDING", + "APPROVED", + "REJECTED", + "CANCELED", + "DELETED", + "REVERTED", + "OVERTIME_CLOSE", + "OVERTIME_RECOVER" + ] + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always approval.instance.status_changed_v4", + "enum": [ + "approval.instance.status_changed_v4" + ] + } + } + } + }, + { + "key": "approval.task.status_changed_v4", + "display_name": "Approval task status changed", + "description": "Triggered after an approval task status becomes visible to the requester or task approver", + "event_type": "approval.task.status_changed_v4", + "subscription_type": "event", + "params": [ + { + "name": "subscription_type", + "type": "multi", + "required": false, + "description": "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.", + "values": [ + { + "value": "INVOLVED_APPROVAL", + "desc": "Receive events where the current user is the approval requester or approver." + }, + { + "value": "MANAGED_APPROVAL", + "desc": "Receive events under approval definitions managed by the current user." + } + ] + } + ], + "schema": { + "custom": {} + }, + "scopes": [ + "approval:task:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "approval.task.status_changed_v4" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "approval_code": { + "type": "string", + "description": "Approval definition code; not a subscription dimension" + }, + "assigned_user": { + "type": "object", + "description": "Task assignee or operator user ids; omitted for automatic flows without an operator", + "properties": { + "open_id": { + "type": "string", + "description": "User open_id; prefixed with ou_", + "format": "open_id" + }, + "union_id": { + "type": "string", + "description": "User union_id", + "format": "union_id" + }, + "user_id": { + "type": "string", + "description": "User id within the tenant", + "format": "user_id" + } + } + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "external_id": { + "type": "string", + "description": "Third-party approval external id; present only for third-party approvals" + }, + "instance_code": { + "type": "string", + "description": "Approval instance code" + }, + "operate_time": { + "type": "string", + "description": "Status change time in milliseconds", + "format": "timestamp_ms" + }, + "status": { + "type": "string", + "description": "Approval task status", + "enum": [ + "REVERTED", + "PENDING", + "APPROVED", + "REJECTED", + "TRANSFERRED", + "ROLLBACK", + "DONE", + "OVERTIME_CLOSE", + "OVERTIME_RECOVER" + ] + }, + "task_external_id": { + "type": "string", + "description": "Third-party approval task external id; present only when emitted by the upstream service" + }, + "task_id": { + "type": "string", + "description": "Approval task id" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always approval.task.status_changed_v4", + "enum": [ + "approval.task.status_changed_v4" + ] + } + } + } + }, + { + "key": "board.whiteboard.updated_v1", + "display_name": "Whiteboard updated", + "description": "Pushed when the whiteboard content is updated.", + "event_type": "board.whiteboard.updated_v1", + "subscription_type": "event", + "params": [ + { + "name": "whiteboard_id", + "type": "string", + "required": true, + "description": "Whiteboard id to subscribe; subscription is per-whiteboard.", + "subscription_key": true + } + ], + "schema": { + "native": {}, + "field_overrides": { + "/event/operator_ids/*/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_ids/*/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_ids/*/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/whiteboard_id": { + "Description": "whiteboard id to subscribe", + "Enum": null, + "Kind": "whiteboard_id" + } + } + }, + "scopes": [ + "board:whiteboard:node:read" + ], + "auth_types": [ + "user", + "bot" + ], + "required_console_events": [ + "board.whiteboard.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "operator_ids": { + "items": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "whiteboard_id": { + "description": "whiteboard id to subscribe", + "format": "whiteboard_id", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "card.action.trigger", + "display_name": "Card action", + "description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).", + "event_type": "card.action.trigger", + "subscription_type": "callback", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "card.action.trigger" + ], + "buffer_size": 100, + "workers": 1, + "single_consumer": true, + "resolved_output_schema": { + "type": "object", + "properties": { + "action_name": { + "type": "string", + "description": "Element name attribute" + }, + "action_tag": { + "type": "string", + "description": "Triggered element type: button/select_static/input/checker/etc" + }, + "action_value": { + "type": "string", + "description": "Developer-defined action value as JSON string" + }, + "card_content": { + "type": "string", + "description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails" + }, + "chat_id": { + "type": "string", + "description": "Chat ID", + "format": "chat_id" + }, + "checked": { + "type": "boolean", + "description": "Checkbox state (for checkbox elements)" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID" + }, + "form_value": { + "type": "string", + "description": "Form submission values as JSON string (only on form submit)" + }, + "host": { + "type": "string", + "description": "Host type: im_message / im_top_notice" + }, + "input_value": { + "type": "string", + "description": "Input field value (only for input elements)" + }, + "message_id": { + "type": "string", + "description": "Message ID of the card", + "format": "message_id" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "option": { + "type": "string", + "description": "Selected option value (for single-select dropdown)" + }, + "options": { + "type": "string", + "description": "Selected options, comma-separated (for multi-select)" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string)", + "format": "timestamp_ms" + }, + "timezone": { + "type": "string", + "description": "User timezone for date/time picker interactions" + }, + "token": { + "type": "string", + "description": "Token for delay card update (valid 30 min, max 2 updates)" + }, + "type": { + "type": "string", + "description": "Event type; always card.action.trigger" + } + } + } + }, + { + "key": "im.chat.disbanded_v1", + "display_name": "Chat disbanded", + "description": "Triggered after a chat is disbanded", + "event_type": "im.chat.disbanded_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.disbanded_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.bot.added_v1", + "display_name": "Bot added to chat", + "description": "Triggered when the bot is added to a chat", + "event_type": "im.chat.member.bot.added_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:bot_access" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.bot.added_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.bot.deleted_v1", + "display_name": "Bot removed from chat", + "description": "Triggered after the bot is removed from a chat", + "event_type": "im.chat.member.bot.deleted_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:bot_access" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.bot.deleted_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.user.added_v1", + "display_name": "User added to chat", + "description": "Triggered when a new user joins a chat (including topic chats)", + "event_type": "im.chat.member.user.added_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/users/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/users/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/users/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.user.added_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + }, + "users": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.user.deleted_v1", + "display_name": "User left chat", + "description": "Triggered when a user leaves or is removed from a chat", + "event_type": "im.chat.member.user.deleted_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/users/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/users/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/users/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.user.deleted_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + }, + "users": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.member.user.withdrawn_v1", + "display_name": "User invite withdrawn", + "description": "Triggered after a pending user invite is withdrawn", + "event_type": "im.chat.member.user.withdrawn_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/users/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/users/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/users/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat.members:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.member.user.withdrawn_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + }, + "users": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.chat.updated_v1", + "display_name": "Chat updated", + "description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated", + "event_type": "im.chat.updated_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/after_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/after_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/after_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/before_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/before_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/before_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/moderator_list/added_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/added_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/added_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "after_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "before_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "moderator_list": { + "properties": { + "added_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "removed_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.message_read_v1", + "display_name": "Message read", + "description": "Triggered after a user reads a P2P message sent by the bot", + "event_type": "im.message.message_read_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/message_id_list/*": { + "Description": "", + "Enum": null, + "Kind": "message_id" + }, + "/event/reader/read_time": { + "Description": "", + "Enum": null, + "Kind": "timestamp_ms" + }, + "/event/reader/reader_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/reader/reader_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/reader/reader_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:message:readonly", + "im:message" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.message_read_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "message_id_list": { + "items": { + "format": "message_id", + "type": "string" + }, + "type": "array" + }, + "reader": { + "properties": { + "read_time": { + "format": "timestamp_ms", + "type": "string" + }, + "reader_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "tenant_key": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.reaction.created_v1", + "display_name": "Reaction added", + "description": "Triggered when a reaction is added to a message", + "event_type": "im.message.reaction.created_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/action_time": { + "Description": "", + "Enum": null, + "Kind": "timestamp_ms" + }, + "/event/message_id": { + "Description": "", + "Enum": null, + "Kind": "message_id" + }, + "/event/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:message:readonly", + "im:message.reactions:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.reaction.created_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "action_time": { + "format": "timestamp_ms", + "type": "string" + }, + "app_id": { + "type": "string" + }, + "message_id": { + "format": "message_id", + "type": "string" + }, + "operator_type": { + "type": "string" + }, + "reaction_type": { + "properties": { + "emoji_type": { + "type": "string" + } + }, + "type": "object" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.reaction.deleted_v1", + "display_name": "Reaction removed", + "description": "Triggered when a reaction is removed from a message", + "event_type": "im.message.reaction.deleted_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/action_time": { + "Description": "", + "Enum": null, + "Kind": "timestamp_ms" + }, + "/event/message_id": { + "Description": "", + "Enum": null, + "Kind": "message_id" + }, + "/event/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:message:readonly", + "im:message.reactions:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.reaction.deleted_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "action_time": { + "format": "timestamp_ms", + "type": "string" + }, + "app_id": { + "type": "string" + }, + "message_id": { + "format": "message_id", + "type": "string" + }, + "operator_type": { + "type": "string" + }, + "reaction_type": { + "properties": { + "emoji_type": { + "type": "string" + } + }, + "type": "object" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "im.message.receive_v1", + "display_name": "Receive message", + "description": "Receive IM messages", + "event_type": "im.message.receive_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message.p2p_msg:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.receive_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "description": "Chat/conversation ID; prefixed with oc_", + "format": "chat_id" + }, + "chat_type": { + "type": "string", + "description": "Conversation type", + "enum": [ + "p2p", + "group" + ] + }, + "content": { + "type": "string", + "description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text." + }, + "create_time": { + "type": "string", + "description": "Message creation time (ms timestamp string)", + "format": "timestamp_ms" + }, + "event_id": { + "type": "string", + "description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead." + }, + "id": { + "type": "string", + "description": "Message ID (legacy alias of message_id, kept for compatibility)", + "format": "message_id" + }, + "mentions": { + "type": "array", + "description": "Compact mentions aligned with im +messages-mget", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Mentioned user open_id; prefixed with ou_", + "format": "open_id" + }, + "key": { + "type": "string", + "description": "Mention placeholder key, for example @_user_1" + }, + "name": { + "type": "string", + "description": "Mentioned display name" + } + } + } + }, + "message_id": { + "type": "string", + "description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.", + "format": "message_id" + }, + "message_type": { + "type": "string", + "description": "Message type" + }, + "reply_to": { + "type": "string", + "description": "Parent message ID of the direct reply context, when present", + "format": "message_id" + }, + "root_id": { + "type": "string", + "description": "Root message ID of the reply/thread context, when present", + "format": "message_id" + }, + "sender_id": { + "type": "string", + "description": "Sender open_id; prefixed with ou_", + "format": "open_id" + }, + "sender_type": { + "type": "string", + "description": "Sender type", + "enum": [ + "user", + "bot" + ] + }, + "thread_id": { + "type": "string", + "description": "Thread ID, when present" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always im.message.receive_v1" + }, + "update_time": { + "type": "string", + "description": "Message update time (ms timestamp string); emitted only when different from create_time", + "format": "timestamp_ms" + } + } + } + }, + { + "key": "minutes.minute.generated_v1", + "display_name": "Minute generated", + "description": "Triggered when a minute has been generated", + "event_type": "minutes.minute.generated_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "minutes:minutes.basic:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "minutes.minute.generated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "minute_source": { + "type": "object", + "description": "Minute source metadata", + "properties": { + "source_entity_id": { + "type": "string", + "description": "Source entity ID" + }, + "source_type": { + "type": "string", + "description": "Minute source type" + } + } + }, + "minute_token": { + "type": "string", + "description": "Minute token" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "title": { + "type": "string", + "description": "Minute title" + }, + "type": { + "type": "string", + "description": "Event type; always minutes.minute.generated_v1" + } + } + } + }, + { + "key": "task.task.update_user_access_v2", + "display_name": "Task updated", + "description": "Triggered when tasks visible to the current user or app are created, deleted, or updated", + "event_type": "task.task.update_user_access_v2", + "subscription_type": "event", + "schema": { + "native": {} + }, + "scopes": [ + "task:task:read" + ], + "auth_types": [ + "user", + "bot" + ], + "required_console_events": [ + "task.task.update_user_access_v2" + ], + "buffer_size": 100, + "workers": 1, + "single_consumer": true, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "event_types": { + "description": "Task commit types included in this event", + "items": { + "enum": [ + "task_create", + "task_deleted", + "task_summary_update", + "task_desc_update", + "task_assignees_update", + "task_followers_update", + "task_reminders_update", + "task_start_due_update", + "task_completed_update" + ], + "type": "string" + }, + "type": "array" + }, + "task_guid": { + "description": "Task GUID that changed", + "format": "task_guid", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } + }, + { + "key": "vc.meeting.participant_meeting_ended_v1", + "display_name": "Participant meeting ended", + "description": "Triggered when a meeting the current user participates in has ended", + "event_type": "vc.meeting.participant_meeting_ended_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:meeting.meetingevent:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.meeting.participant_meeting_ended_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "calendar_event_id": { + "type": "string", + "description": "Calendar event ID associated with the meeting" + }, + "end_time": { + "type": "string", + "description": "Meeting end time in RFC3339, converted to the local timezone" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "meeting_id": { + "type": "string", + "description": "Meeting ID", + "format": "meeting_id" + }, + "meeting_no": { + "type": "string", + "description": "Meeting number" + }, + "start_time": { + "type": "string", + "description": "Meeting start time in RFC3339, converted to the local timezone" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "topic": { + "type": "string", + "description": "Meeting topic" + }, + "type": { + "type": "string", + "description": "Event type; always vc.meeting.participant_meeting_ended_v1" + } + } + } + }, + { + "key": "vc.meeting.participant_meeting_joined_v1", + "display_name": "Participant meeting joined", + "description": "Triggered when the current user joins a meeting", + "event_type": "vc.meeting.participant_meeting_joined_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:meeting.meetingevent:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.meeting.participant_meeting_joined_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "calendar_event_id": { + "type": "string", + "description": "Calendar event ID associated with the meeting" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "meeting_id": { + "type": "string", + "description": "Meeting ID", + "format": "meeting_id" + }, + "meeting_no": { + "type": "string", + "description": "Meeting number" + }, + "start_time": { + "type": "string", + "description": "Meeting start time in RFC3339, converted to the local timezone" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "topic": { + "type": "string", + "description": "Meeting topic" + }, + "type": { + "type": "string", + "description": "Event type; always vc.meeting.participant_meeting_joined_v1" + } + } + } + }, + { + "key": "vc.meeting.participant_meeting_started_v1", + "display_name": "Participant meeting started", + "description": "Triggered when a meeting the current user participates in has started", + "event_type": "vc.meeting.participant_meeting_started_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:meeting.meetingevent:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.meeting.participant_meeting_started_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "calendar_event_id": { + "type": "string", + "description": "Calendar event ID associated with the meeting" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "meeting_id": { + "type": "string", + "description": "Meeting ID", + "format": "meeting_id" + }, + "meeting_no": { + "type": "string", + "description": "Meeting number" + }, + "start_time": { + "type": "string", + "description": "Meeting start time in RFC3339, converted to the local timezone" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "topic": { + "type": "string", + "description": "Meeting topic" + }, + "type": { + "type": "string", + "description": "Event type; always vc.meeting.participant_meeting_started_v1" + } + } + } + }, + { + "key": "vc.note.generated_v1", + "display_name": "Note generated", + "description": "Triggered when a note has been generated", + "event_type": "vc.note.generated_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:note:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.note.generated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "note_id": { + "type": "string", + "description": "Note ID" + }, + "note_source": { + "type": "object", + "description": "Note source metadata", + "properties": { + "source_entity_id": { + "type": "string", + "description": "Source entity ID" + }, + "source_type": { + "type": "string", + "description": "Note source type" + } + } + }, + "note_token": { + "type": "string", + "description": "Generated note document token" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); taken from header.create_time when present", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always vc.note.generated_v1" + }, + "verbatim_token": { + "type": "string", + "description": "Generated verbatim document token" + } + } + } + }, + { + "key": "vc.recording.recording_ended_v1", + "display_name": "Recording ended", + "description": "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.", + "event_type": "vc.recording.recording_ended_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:recording:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.recording.recording_ended_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_time": { + "type": "string", + "description": "Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone" + }, + "source": { + "type": "string", + "description": "Recording source; always recording_bean" + }, + "type": { + "type": "string", + "description": "Event type; always vc.recording.recording_ended_v1" + }, + "unique_key": { + "type": "string", + "description": "Unique key generated for one recording_bean recording session" + } + } + } + }, + { + "key": "vc.recording.recording_started_v1", + "display_name": "Recording started", + "description": "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.", + "event_type": "vc.recording.recording_started_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:recording:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.recording.recording_started_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_time": { + "type": "string", + "description": "Recording start time in RFC3339 / ISO 8601 with the current system timezone" + }, + "source": { + "type": "string", + "description": "Recording source; always recording_bean" + }, + "type": { + "type": "string", + "description": "Event type; always vc.recording.recording_started_v1" + }, + "unique_key": { + "type": "string", + "description": "Unique key generated for one recording_bean recording session" + } + } + } + }, + { + "key": "vc.recording.recording_transcript_generated_v1", + "display_name": "Recording transcript generated", + "description": "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.", + "event_type": "vc.recording.recording_transcript_generated_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "vc:recording:read" + ], + "auth_types": [ + "user" + ], + "required_console_events": [ + "vc.recording.recording_transcript_generated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "event_id": { + "type": "string", + "description": "Globally unique event ID; safe for deduplication" + }, + "event_time": { + "type": "string", + "description": "Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone" + }, + "source": { + "type": "string", + "description": "Recording source; always recording_bean" + }, + "transcript_items": { + "type": "array", + "description": "Generated transcript items", + "items": { + "type": "object", + "properties": { + "end_time": { + "type": "string", + "description": "Transcript item end time in RFC3339 / ISO 8601 with the current system timezone" + }, + "sentence_id": { + "type": "string", + "description": "Transcript sentence ID" + }, + "speaker_name": { + "type": "string", + "description": "Speaker display name" + }, + "start_time": { + "type": "string", + "description": "Transcript item start time in RFC3339 / ISO 8601 with the current system timezone" + }, + "text": { + "type": "string", + "description": "Transcript text" + } + } + } + }, + "type": { + "type": "string", + "description": "Event type; always vc.recording.recording_transcript_generated_v1" + }, + "unique_key": { + "type": "string", + "description": "Unique key generated for one recording_bean recording session" + } + } + } + } +] diff --git a/cmd/event/testdata/golden/list_text.golden b/cmd/event/testdata/golden/list_text.golden new file mode 100644 index 0000000000..6ca6446e81 --- /dev/null +++ b/cmd/event/testdata/golden/list_text.golden @@ -0,0 +1,42 @@ +KEY AUTH PARAMS DESCRIPTION + +── application ── +application.bot.menu_v6 bot 0 Triggered when a user clicks a custom bot menu item whose action is configured as a push event. + +── approval ── +approval.instance.status_changed_v4 user 1 Triggered after an approval instance status becomes visible to the requester or approval participants +approval.task.status_changed_v4 user 1 Triggered after an approval task status becomes visible to the requester or task approver + +── board ── +board.whiteboard.updated_v1 user|bot 1 Pushed when the whiteboard content is updated. + +── card ── +card.action.trigger bot 0 Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md). + +── im ── +im.chat.disbanded_v1 bot 0 Triggered after a chat is disbanded +im.chat.member.bot.added_v1 bot 0 Triggered when the bot is added to a chat +im.chat.member.bot.deleted_v1 bot 0 Triggered after the bot is removed from a chat +im.chat.member.user.added_v1 bot 0 Triggered when a new user joins a chat (including topic chats) +im.chat.member.user.deleted_v1 bot 0 Triggered when a user leaves or is removed from a chat +im.chat.member.user.withdrawn_v1 bot 0 Triggered after a pending user invite is withdrawn +im.chat.updated_v1 bot 0 Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated +im.message.message_read_v1 bot 0 Triggered after a user reads a P2P message sent by the bot +im.message.reaction.created_v1 bot 0 Triggered when a reaction is added to a message +im.message.reaction.deleted_v1 bot 0 Triggered when a reaction is removed from a message +im.message.receive_v1 bot 0 Receive IM messages + +── minutes ── +minutes.minute.generated_v1 user 0 Triggered when a minute has been generated + +── task ── +task.task.update_user_access_v2 user|bot 0 Triggered when tasks visible to the current user or app are created, deleted, or updated + +── vc ── +vc.meeting.participant_meeting_ended_v1 user 0 Triggered when a meeting the current user participates in has ended +vc.meeting.participant_meeting_joined_v1 user 0 Triggered when the current user joins a meeting +vc.meeting.participant_meeting_started_v1 user 0 Triggered when a meeting the current user participates in has started +vc.note.generated_v1 user 0 Triggered when a note has been generated +vc.recording.recording_ended_v1 user 0 Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software. +vc.recording.recording_started_v1 user 0 Triggered when a recording_bean recording starts; only generated when connected to Feishu software. +vc.recording.recording_transcript_generated_v1 user 0 Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software. diff --git a/cmd/event/testdata/golden/schema_board_whiteboard_json.golden b/cmd/event/testdata/golden/schema_board_whiteboard_json.golden new file mode 100644 index 0000000000..91fec1a3fb --- /dev/null +++ b/cmd/event/testdata/golden/schema_board_whiteboard_json.golden @@ -0,0 +1,127 @@ +{ + "key": "board.whiteboard.updated_v1", + "display_name": "Whiteboard updated", + "description": "Pushed when the whiteboard content is updated.", + "event_type": "board.whiteboard.updated_v1", + "subscription_type": "event", + "params": [ + { + "name": "whiteboard_id", + "type": "string", + "required": true, + "description": "Whiteboard id to subscribe; subscription is per-whiteboard.", + "subscription_key": true + } + ], + "schema": { + "native": {}, + "field_overrides": { + "/event/operator_ids/*/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_ids/*/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_ids/*/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/whiteboard_id": { + "Description": "whiteboard id to subscribe", + "Enum": null, + "Kind": "whiteboard_id" + } + } + }, + "scopes": [ + "board:whiteboard:node:read" + ], + "auth_types": [ + "user", + "bot" + ], + "required_console_events": [ + "board.whiteboard.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "operator_ids": { + "items": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "whiteboard_id": { + "description": "whiteboard id to subscribe", + "format": "whiteboard_id", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + }, + "jq_root_path": ".event" +} diff --git a/cmd/event/testdata/golden/schema_board_whiteboard_text.golden b/cmd/event/testdata/golden/schema_board_whiteboard_text.golden new file mode 100644 index 0000000000..fb172c4b8b --- /dev/null +++ b/cmd/event/testdata/golden/schema_board_whiteboard_text.golden @@ -0,0 +1,89 @@ +Key: board.whiteboard.updated_v1 +Description: Pushed when the whiteboard content is updated. +Event: board.whiteboard.updated_v1 +Pre-consume: yes + +Required Scopes: + - board:whiteboard:node:read + +Required Console Events (must be enabled in developer console): + - board.whiteboard.updated_v1 + +Parameters: + NAME TYPE REQUIRED SUB-KEY DEFAULT DESCRIPTION + whiteboard_id string yes yes - Whiteboard id to subscribe; subscription is per-whiteboard. + +Output Schema: + { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "operator_ids": { + "items": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "whiteboard_id": { + "description": "whiteboard id to subscribe", + "format": "whiteboard_id", + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } diff --git a/cmd/event/testdata/golden/schema_card_action_trigger_json.golden b/cmd/event/testdata/golden/schema_card_action_trigger_json.golden new file mode 100644 index 0000000000..34b12a8589 --- /dev/null +++ b/cmd/event/testdata/golden/schema_card_action_trigger_json.golden @@ -0,0 +1,104 @@ +{ + "key": "card.action.trigger", + "display_name": "Card action", + "description": "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).", + "event_type": "card.action.trigger", + "subscription_type": "callback", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "card.action.trigger" + ], + "buffer_size": 100, + "workers": 1, + "single_consumer": true, + "resolved_output_schema": { + "type": "object", + "properties": { + "action_name": { + "type": "string", + "description": "Element name attribute" + }, + "action_tag": { + "type": "string", + "description": "Triggered element type: button/select_static/input/checker/etc" + }, + "action_value": { + "type": "string", + "description": "Developer-defined action value as JSON string" + }, + "card_content": { + "type": "string", + "description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails" + }, + "chat_id": { + "type": "string", + "description": "Chat ID", + "format": "chat_id" + }, + "checked": { + "type": "boolean", + "description": "Checkbox state (for checkbox elements)" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID" + }, + "form_value": { + "type": "string", + "description": "Form submission values as JSON string (only on form submit)" + }, + "host": { + "type": "string", + "description": "Host type: im_message / im_top_notice" + }, + "input_value": { + "type": "string", + "description": "Input field value (only for input elements)" + }, + "message_id": { + "type": "string", + "description": "Message ID of the card", + "format": "message_id" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "option": { + "type": "string", + "description": "Selected option value (for single-select dropdown)" + }, + "options": { + "type": "string", + "description": "Selected options, comma-separated (for multi-select)" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string)", + "format": "timestamp_ms" + }, + "timezone": { + "type": "string", + "description": "User timezone for date/time picker interactions" + }, + "token": { + "type": "string", + "description": "Token for delay card update (valid 30 min, max 2 updates)" + }, + "type": { + "type": "string", + "description": "Event type; always card.action.trigger" + } + } + }, + "jq_root_path": "." +} diff --git a/cmd/event/testdata/golden/schema_card_action_trigger_text.golden b/cmd/event/testdata/golden/schema_card_action_trigger_text.golden new file mode 100644 index 0000000000..e556b6759f --- /dev/null +++ b/cmd/event/testdata/golden/schema_card_action_trigger_text.golden @@ -0,0 +1,92 @@ +Key: card.action.trigger +Description: Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md). +Event: card.action.trigger + +Required Scopes: + - im:message:readonly + +Required Console Events (must be enabled in developer console): + - card.action.trigger + +Output Schema: + { + "type": "object", + "properties": { + "action_name": { + "type": "string", + "description": "Element name attribute" + }, + "action_tag": { + "type": "string", + "description": "Triggered element type: button/select_static/input/checker/etc" + }, + "action_value": { + "type": "string", + "description": "Developer-defined action value as JSON string" + }, + "card_content": { + "type": "string", + "description": "Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails" + }, + "chat_id": { + "type": "string", + "description": "Chat ID", + "format": "chat_id" + }, + "checked": { + "type": "boolean", + "description": "Checkbox state (for checkbox elements)" + }, + "event_id": { + "type": "string", + "description": "Globally unique event ID" + }, + "form_value": { + "type": "string", + "description": "Form submission values as JSON string (only on form submit)" + }, + "host": { + "type": "string", + "description": "Host type: im_message / im_top_notice" + }, + "input_value": { + "type": "string", + "description": "Input field value (only for input elements)" + }, + "message_id": { + "type": "string", + "description": "Message ID of the card", + "format": "message_id" + }, + "operator_id": { + "type": "string", + "description": "Operator open_id", + "format": "open_id" + }, + "option": { + "type": "string", + "description": "Selected option value (for single-select dropdown)" + }, + "options": { + "type": "string", + "description": "Selected options, comma-separated (for multi-select)" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string)", + "format": "timestamp_ms" + }, + "timezone": { + "type": "string", + "description": "User timezone for date/time picker interactions" + }, + "token": { + "type": "string", + "description": "Token for delay card update (valid 30 min, max 2 updates)" + }, + "type": { + "type": "string", + "description": "Event type; always card.action.trigger" + } + } + } diff --git a/cmd/event/testdata/golden/schema_im_chat_updated_json.golden b/cmd/event/testdata/golden/schema_im_chat_updated_json.golden new file mode 100644 index 0000000000..6feb316dd8 --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_chat_updated_json.golden @@ -0,0 +1,430 @@ +{ + "key": "im.chat.updated_v1", + "display_name": "Chat updated", + "description": "Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated", + "event_type": "im.chat.updated_v1", + "subscription_type": "event", + "schema": { + "native": {}, + "field_overrides": { + "/event/after_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/after_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/after_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/before_change/owner_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/before_change/owner_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/before_change/owner_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/chat_id": { + "Description": "", + "Enum": null, + "Kind": "chat_id" + }, + "/event/moderator_list/added_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/added_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/added_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/moderator_list/removed_member_list/*/user_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + }, + "/event/operator_id/open_id": { + "Description": "", + "Enum": null, + "Kind": "open_id" + }, + "/event/operator_id/union_id": { + "Description": "", + "Enum": null, + "Kind": "union_id" + }, + "/event/operator_id/user_id": { + "Description": "", + "Enum": null, + "Kind": "user_id" + } + } + }, + "scopes": [ + "im:chat:read" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.chat.updated_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "after_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "before_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "moderator_list": { + "properties": { + "added_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "removed_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + }, + "jq_root_path": ".event" +} diff --git a/cmd/event/testdata/golden/schema_im_chat_updated_text.golden b/cmd/event/testdata/golden/schema_im_chat_updated_text.golden new file mode 100644 index 0000000000..e0624d57d0 --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_chat_updated_text.golden @@ -0,0 +1,337 @@ +Key: im.chat.updated_v1 +Description: Triggered after chat settings (owner, avatar, name, permissions, etc.) are updated +Event: im.chat.updated_v1 + +Required Scopes: + - im:chat:read + +Required Console Events (must be enabled in developer console): + - im.chat.updated_v1 + +Output Schema: + { + "description": "飞书事件", + "properties": { + "event": { + "properties": { + "after_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "before_change": { + "properties": { + "add_member_permission": { + "type": "string" + }, + "at_all_permission": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "description": { + "type": "string" + }, + "edit_permission": { + "type": "string" + }, + "group_message_type": { + "type": "string" + }, + "i18n_names": { + "properties": { + "en_us": { + "type": "string" + }, + "ja_jp": { + "type": "string" + }, + "zh_cn": { + "type": "string" + } + }, + "type": "object" + }, + "join_message_visibility": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "leave_message_visibility": { + "type": "string" + }, + "membership_approval": { + "type": "string" + }, + "moderation_permission": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owner_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "restricted_mode_setting": { + "properties": { + "download_has_permission_setting": { + "type": "string" + }, + "message_has_permission_setting": { + "type": "string" + }, + "screenshot_has_permission_setting": { + "type": "string" + }, + "status": { + "type": "boolean" + } + }, + "type": "object" + }, + "share_card_permission": { + "type": "string" + } + }, + "type": "object" + }, + "chat_id": { + "format": "chat_id", + "type": "string" + }, + "external": { + "type": "boolean" + }, + "moderator_list": { + "properties": { + "added_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "removed_member_list": { + "items": { + "properties": { + "tenant_key": { + "type": "string" + }, + "user_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "operator_id": { + "properties": { + "open_id": { + "format": "open_id", + "type": "string" + }, + "union_id": { + "format": "union_id", + "type": "string" + }, + "user_id": { + "format": "user_id", + "type": "string" + } + }, + "type": "object" + }, + "operator_tenant_key": { + "type": "string" + } + }, + "type": "object" + }, + "header": { + "description": "事件头,所有事件结构一致", + "properties": { + "app_id": { + "description": "接收事件的应用 ID", + "type": "string" + }, + "create_time": { + "description": "事件创建时间,毫秒时间戳字符串", + "type": "string" + }, + "event_id": { + "description": "事件唯一 ID", + "type": "string" + }, + "event_type": { + "description": "事件类型,用于路由", + "type": "string" + }, + "tenant_key": { + "description": "租户唯一标识", + "type": "string" + }, + "token": { + "description": "回调校验 token", + "type": "string" + } + }, + "type": "object" + }, + "schema": { + "description": "飞书事件协议版本", + "enum": [ + "2.0" + ], + "type": "string" + } + }, + "type": "object" + } diff --git a/cmd/event/testdata/golden/schema_im_message_receive_json.golden b/cmd/event/testdata/golden/schema_im_message_receive_json.golden new file mode 100644 index 0000000000..d95c198289 --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_message_receive_json.golden @@ -0,0 +1,130 @@ +{ + "key": "im.message.receive_v1", + "display_name": "Receive message", + "description": "Receive IM messages", + "event_type": "im.message.receive_v1", + "subscription_type": "event", + "schema": { + "custom": {} + }, + "scopes": [ + "im:message.p2p_msg:readonly" + ], + "auth_types": [ + "bot" + ], + "required_console_events": [ + "im.message.receive_v1" + ], + "buffer_size": 100, + "workers": 1, + "resolved_output_schema": { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "description": "Chat/conversation ID; prefixed with oc_", + "format": "chat_id" + }, + "chat_type": { + "type": "string", + "description": "Conversation type", + "enum": [ + "p2p", + "group" + ] + }, + "content": { + "type": "string", + "description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text." + }, + "create_time": { + "type": "string", + "description": "Message creation time (ms timestamp string)", + "format": "timestamp_ms" + }, + "event_id": { + "type": "string", + "description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead." + }, + "id": { + "type": "string", + "description": "Message ID (legacy alias of message_id, kept for compatibility)", + "format": "message_id" + }, + "mentions": { + "type": "array", + "description": "Compact mentions aligned with im +messages-mget", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Mentioned user open_id; prefixed with ou_", + "format": "open_id" + }, + "key": { + "type": "string", + "description": "Mention placeholder key, for example @_user_1" + }, + "name": { + "type": "string", + "description": "Mentioned display name" + } + } + } + }, + "message_id": { + "type": "string", + "description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.", + "format": "message_id" + }, + "message_type": { + "type": "string", + "description": "Message type" + }, + "reply_to": { + "type": "string", + "description": "Parent message ID of the direct reply context, when present", + "format": "message_id" + }, + "root_id": { + "type": "string", + "description": "Root message ID of the reply/thread context, when present", + "format": "message_id" + }, + "sender_id": { + "type": "string", + "description": "Sender open_id; prefixed with ou_", + "format": "open_id" + }, + "sender_type": { + "type": "string", + "description": "Sender type", + "enum": [ + "user", + "bot" + ] + }, + "thread_id": { + "type": "string", + "description": "Thread ID, when present" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always im.message.receive_v1" + }, + "update_time": { + "type": "string", + "description": "Message update time (ms timestamp string); emitted only when different from create_time", + "format": "timestamp_ms" + } + } + }, + "jq_root_path": "." +} diff --git a/cmd/event/testdata/golden/schema_im_message_receive_text.golden b/cmd/event/testdata/golden/schema_im_message_receive_text.golden new file mode 100644 index 0000000000..b22ad0660b --- /dev/null +++ b/cmd/event/testdata/golden/schema_im_message_receive_text.golden @@ -0,0 +1,119 @@ +Key: im.message.receive_v1 +Description: Receive IM messages +Event: im.message.receive_v1 + +Required Scopes: + - im:message.p2p_msg:readonly + +Required Console Events (must be enabled in developer console): + - im.message.receive_v1 + +Output Schema: + { + "type": "object", + "properties": { + "chat_id": { + "type": "string", + "description": "Chat/conversation ID; prefixed with oc_", + "format": "chat_id" + }, + "chat_type": { + "type": "string", + "description": "Conversation type", + "enum": [ + "p2p", + "group" + ] + }, + "content": { + "type": "string", + "description": "Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text." + }, + "create_time": { + "type": "string", + "description": "Message creation time (ms timestamp string)", + "format": "timestamp_ms" + }, + "event_id": { + "type": "string", + "description": "Event delivery ID. Do not use as the message deduplication key; use message_id instead." + }, + "id": { + "type": "string", + "description": "Message ID (legacy alias of message_id, kept for compatibility)", + "format": "message_id" + }, + "mentions": { + "type": "array", + "description": "Compact mentions aligned with im +messages-mget", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Mentioned user open_id; prefixed with ou_", + "format": "open_id" + }, + "key": { + "type": "string", + "description": "Mention placeholder key, for example @_user_1" + }, + "name": { + "type": "string", + "description": "Mentioned display name" + } + } + } + }, + "message_id": { + "type": "string", + "description": "Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers.", + "format": "message_id" + }, + "message_type": { + "type": "string", + "description": "Message type" + }, + "reply_to": { + "type": "string", + "description": "Parent message ID of the direct reply context, when present", + "format": "message_id" + }, + "root_id": { + "type": "string", + "description": "Root message ID of the reply/thread context, when present", + "format": "message_id" + }, + "sender_id": { + "type": "string", + "description": "Sender open_id; prefixed with ou_", + "format": "open_id" + }, + "sender_type": { + "type": "string", + "description": "Sender type", + "enum": [ + "user", + "bot" + ] + }, + "thread_id": { + "type": "string", + "description": "Thread ID, when present" + }, + "timestamp": { + "type": "string", + "description": "Event delivery time (ms timestamp string); prefers header.create_time", + "format": "timestamp_ms" + }, + "type": { + "type": "string", + "description": "Event type; always im.message.receive_v1" + }, + "update_time": { + "type": "string", + "description": "Message update time (ms timestamp string); emitted only when different from create_time", + "format": "timestamp_ms" + } + } + } diff --git a/cmd/event/wiring.go b/cmd/event/wiring.go new file mode 100644 index 0000000000..b8bc240ab8 --- /dev/null +++ b/cmd/event/wiring.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "fmt" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// compileCatalog is the event command tree's single assembly point: it turns +// the aggregated domain declarations into the immutable snapshot every +// subcommand reads. A compile failure is a defect in declarations built into +// this binary — there is nothing to recover at runtime, so it panics. +func compileCatalog() *catalog.Snapshot { + // The strategy registry that validates references is the same one that + // executes them, so "compiled" implies "resolvable at run time". + snap, err := catalog.Compile(events.All(), consumeStrategies) + if err != nil { + panic(fmt.Sprintf("event catalog failed to compile: %v", err)) + } + return snap +} diff --git a/events/register.go b/events/all.go similarity index 54% rename from events/register.go rename to events/all.go index b45406bfc0..f6f33f6d41 100644 --- a/events/register.go +++ b/events/all.go @@ -1,7 +1,9 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package events wires domain EventKey definitions into the global registry. Blank-import to populate. +// Package events aggregates the domain EventKey declarations. All returns +// them explicitly — whoever needs a catalog compiles one; nothing registers +// itself through import side effects. package events import ( @@ -12,12 +14,14 @@ import ( "github.com/larksuite/cli/events/task" "github.com/larksuite/cli/events/vc" "github.com/larksuite/cli/events/whiteboard" - "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" ) +// All returns every domain's declarations, ready for catalog.Compile. // Mail is intentionally omitted in this phase. -func init() { - all := [][]event.KeyDefinition{ +func All() []catalog.KeyDefinition { + var all []catalog.KeyDefinition + for _, keys := range [][]catalog.KeyDefinition{ application.Keys(), approval.Keys(), im.Keys(), @@ -25,10 +29,8 @@ func init() { task.Keys(), vc.Keys(), whiteboard.Keys(), + } { + all = append(all, keys...) } - for _, keys := range all { - for _, k := range keys { - event.RegisterKey(k) - } - } + return all } diff --git a/events/application/menu.go b/events/application/menu.go index f77dec6d80..fa293faea7 100644 --- a/events/application/menu.go +++ b/events/application/menu.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // BotMenuOutput is the flattened shape for application.bot.menu_v6. @@ -29,13 +30,6 @@ type BotMenuOutput struct { func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - AppID string `json:"app_id"` - TenantKey string `json:"tenant_key"` - } `json:"header"` Event struct { EventKey string `json:"event_key"` Timestamp json.RawMessage `json:"timestamp"` @@ -50,11 +44,11 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } menuTimestamp := timestampMillisString(envelope.Event.Timestamp) - timestamp := envelope.Header.CreateTime + timestamp := raw.SourceTime if timestamp == "" { timestamp = menuTimestamp } @@ -62,10 +56,10 @@ func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ out := &BotMenuOutput{ Type: eventTypeBotMenuV6, - EventID: envelope.Header.EventID, + EventID: raw.EventID, Timestamp: timestamp, - AppID: envelope.Header.AppID, - TenantKey: envelope.Header.TenantKey, + AppID: raw.AppID, + TenantKey: raw.TenantKey, EventKey: envelope.Event.EventKey, MenuTimestamp: menuTimestamp, OperatorID: operatorID, diff --git a/events/application/menu_test.go b/events/application/menu_test.go index 1a9c5be1fe..dd4dafbb13 100644 --- a/events/application/menu_test.go +++ b/events/application/menu_test.go @@ -11,6 +11,8 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/processing" ) func TestKeysBotMenuMetadata(t *testing.T) { @@ -51,14 +53,15 @@ func TestKeysBotMenuMetadata(t *testing.T) { func TestBotMenuRegistersCleanly(t *testing.T) { const key = eventTypeBotMenuV6 - event.UnregisterKeyForTest(key) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - - for _, def := range Keys() { - event.RegisterKey(def) + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) } - if _, ok := event.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) not registered", key) + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key) } } @@ -199,14 +202,42 @@ func TestProcessBotMenuMalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processBotMenu(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id, create_time, +// app_id, and tenant_key from the RawEvent, which the consume pipeline fills +// from the envelope header before dispatch; tests that hand-build a RawEvent +// must mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + AppID string `json:"app_id"` + TenantKey string `json:"tenant_key"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime + raw.AppID = envelope.Header.AppID + raw.TenantKey = envelope.Header.TenantKey +} + func runBotMenu(t *testing.T, payload string) BotMenuOutput { t.Helper() raw := &event.RawEvent{ @@ -215,6 +246,7 @@ func runBotMenu(t *testing.T, payload string) BotMenuOutput { Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processBotMenu(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("processBotMenu: %v", err) diff --git a/events/approval/preconsume.go b/events/approval/preconsume.go index c9d0bb4ad1..3e7d859609 100644 --- a/events/approval/preconsume.go +++ b/events/approval/preconsume.go @@ -13,23 +13,13 @@ import ( "github.com/larksuite/cli/internal/event" ) -type approvalEventType string -type approvalSubscriptionPath string - -type approvalSubscriptionConfig struct { - eventType approvalEventType - subscribePath approvalSubscriptionPath -} - -func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) { +func approvalSubscriptionPreConsume(eventType, subscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) { if rt == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "runtime API client is required for pre-consume subscription") } - eventType := string(cfg.eventType) - subscribePath := string(cfg.subscribePath) subscriptionTypes, err := approvalSubscriptionTypes(eventType, params) if err != nil { return nil, err diff --git a/events/approval/register.go b/events/approval/register.go index 55e37db05e..980f5dd7f0 100644 --- a/events/approval/register.go +++ b/events/approval/register.go @@ -10,6 +10,7 @@ import ( "reflect" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) const ( @@ -40,12 +41,9 @@ func Keys() []event.KeyDefinition { Schema: event.SchemaDef{ Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})}, }, - Process: processApprovalInstanceStatusChanged, - PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalInstanceStatusChangedV4, - subscribePath: pathApprovalInstancesSubscription, - }), - Scopes: []string{"approval:instance:read"}, + Process: processApprovalInstanceStatusChanged, + PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, pathApprovalInstancesSubscription), + Scopes: []string{"approval:instance:read"}, AuthTypes: []string{ "user", }, @@ -60,12 +58,9 @@ func Keys() []event.KeyDefinition { Schema: event.SchemaDef{ Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})}, }, - Process: processApprovalTaskStatusChanged, - PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalTaskStatusChangedV4, - subscribePath: pathApprovalTasksSubscription, - }), - Scopes: []string{"approval:task:read"}, + Process: processApprovalTaskStatusChanged, + PreConsume: approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription), + Scopes: []string{"approval:task:read"}, AuthTypes: []string{ "user", }, @@ -99,11 +94,6 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, return nil, nil } var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { ApprovalCode string `json:"approval_code"` InstanceCode string `json:"instance_code"` @@ -114,13 +104,13 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &ApprovalInstanceStatusChangedV4Output{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, ApprovalCode: envelope.Event.ApprovalCode, InstanceCode: envelope.Event.InstanceCode, ExternalID: envelope.Event.ExternalID, @@ -128,9 +118,6 @@ func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, OperateTime: envelope.Event.OperateTime, StartUser: envelope.Event.StartUser, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } @@ -139,11 +126,6 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw return nil, nil } var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { ApprovalCode string `json:"approval_code"` InstanceCode string `json:"instance_code"` @@ -156,13 +138,13 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &ApprovalTaskStatusChangedV4Output{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, ApprovalCode: envelope.Event.ApprovalCode, InstanceCode: envelope.Event.InstanceCode, TaskID: envelope.Event.TaskID, @@ -172,8 +154,5 @@ func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw Status: envelope.Event.Status, OperateTime: envelope.Event.OperateTime, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/approval/register_test.go b/events/approval/register_test.go index c1017bb614..3e08b209b5 100644 --- a/events/approval/register_test.go +++ b/events/approval/register_test.go @@ -14,6 +14,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/event/schemas" ) @@ -255,10 +257,7 @@ func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: approvalEventType(tc.eventType), - subscribePath: approvalSubscriptionPath(tc.subscribePath), - }) + pc := approvalSubscriptionPreConsume(tc.eventType, tc.subscribePath) rt := &fakeAPIClient{} cleanup, err := pc(context.Background(), rt, tc.params) if err != nil { @@ -297,9 +296,7 @@ func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wan func TestApprovalPreConsumeValidationErrors(t *testing.T) { t.Run("nil runtime", func(t *testing.T) { - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalInstanceStatusChangedV4, - }) + pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "") _, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved}) if err == nil { t.Fatal("expected nil runtime error") @@ -312,9 +309,7 @@ func TestApprovalPreConsumeValidationErrors(t *testing.T) { for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} { t.Run("invalid subscription type "+raw, func(t *testing.T) { - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalInstanceStatusChangedV4, - }) + pc := approvalSubscriptionPreConsume(eventTypeApprovalInstanceStatusChangedV4, "") cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw}) if err == nil { t.Fatal("expected invalid subscription_type error") @@ -338,10 +333,7 @@ func TestApprovalPreConsumeValidationErrors(t *testing.T) { t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) { upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed") rt := &fakeAPIClient{err: upstream, errOnCall: 2} - pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{ - eventType: eventTypeApprovalTaskStatusChangedV4, - subscribePath: pathApprovalTasksSubscription, - }) + pc := approvalSubscriptionPreConsume(eventTypeApprovalTaskStatusChangedV4, pathApprovalTasksSubscription) cleanup, err := pc(context.Background(), rt, map[string]string{}) if err == nil { @@ -553,7 +545,7 @@ func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) { } } -func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) { +func TestProcessApprovalStatusChangedMalformedPayloadDrop(t *testing.T) { for _, tc := range []struct { name string eventType string @@ -569,11 +561,11 @@ func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) { Timestamp: time.Now(), } got, err := tc.process(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } }) } @@ -599,6 +591,30 @@ func TestProcessApprovalStatusChangedNilRaw(t *testing.T) { } } +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output { t.Helper() raw := &event.RawEvent{ @@ -606,6 +622,7 @@ func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInst Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process returned error: %v", err) @@ -624,6 +641,7 @@ func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStat Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process returned error: %v", err) @@ -636,17 +654,16 @@ func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStat } func TestApprovalKeysRegisterCleanly(t *testing.T) { - for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} { - event.UnregisterKeyForTest(key) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - } - - for _, def := range Keys() { - event.RegisterKey(def) + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) } for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} { - if _, ok := event.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) not registered", key) + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key) } } } diff --git a/events/arch_test.go b/events/arch_test.go new file mode 100644 index 0000000000..8572611acb --- /dev/null +++ b/events/arch_test.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Architecture gates for the events declaration layer. +// +// events/ packages are declarations: EventKeys, payload shapes, and +// processing hooks. Two kinds of rot would quietly destroy that role: +// +// 1. Importing command wiring, a transport host, or a concrete adapter turns +// declarations into another place where process and transport concerns +// accumulate, and drags the whole adapter tree into every binary that +// only wanted the catalog. +// 2. Re-parsing the envelope header inside a domain duplicates the kernel's +// single header decode; the copies then drift apart the day the envelope +// evolves. +// +// These tests turn both into build breaks. +package events_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "reflect" + "slices" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + archModulePath = "github.com/larksuite/cli" + archAdapterImportPrefix = archModulePath + "/internal/event/adapter" +) + +// archProductionGoFiles returns every non-test .go file under root, +// skipping testdata directories. Paths are relative to root. +func archProductionGoFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "testdata" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + sort.Strings(files) + return files +} + +// archForbiddenDomainImport reports why importPath is banned in events/, if +// it is. Domains may use the kernel (internal/event, model, catalog, +// processing, ...); they must never see the layers that host or transport +// them. +func archForbiddenDomainImport(importPath string) (reason string, banned bool) { + switch importPath { + case "github.com/spf13/cobra": + return "CLI framework; command wiring lives in cmd, a declaration that needs cobra has stopped being a declaration", true + case archModulePath + "/internal/event/bus": + return "bus is a host process; a domain importing its host inverts the dependency direction", true + case archModulePath + "/internal/event/consume": + return "consume is a host process; a domain importing its host inverts the dependency direction", true + } + if importPath == archAdapterImportPrefix || strings.HasPrefix(importPath, archAdapterImportPrefix+"/") { + return "concrete adapter; domains must stay transport-agnostic so any host can serve them", true + } + return "", false +} + +// TestArchEventsImportRedline fails when any production file under events/ +// imports command wiring, an event host, or a concrete adapter. It keeps the +// declaration layer linkable everywhere without pulling in transports. +func TestArchEventsImportRedline(t *testing.T) { + files := archProductionGoFiles(t, ".") + if len(files) == 0 { + t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run") + } + fset := token.NewFileSet() + for _, file := range files { + f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote import in %s: %v", file, err) + } + if reason, banned := archForbiddenDomainImport(path); banned { + t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason) + } + } + } +} + +// envelopeHeaderTags are the metadata fields the kernel decodes exactly once +// from the envelope header. A domain that re-declares any of them inside a +// json:"header" block is re-parsing the envelope instead of consuming the +// kernel's decode — the duplicate drifts silently when the envelope changes. +var envelopeHeaderTags = map[string]bool{ + "event_id": true, + "event_type": true, + "create_time": true, + "app_id": true, + "tenant_key": true, +} + +// headerReparseBaseline is the ratchet of pinned pre-existing residue, keyed +// by file (relative to events/) with the header metadata tags it re-parses. +// It is empty: every domain consumes the kernel-decoded header, so the gate +// runs at zero tolerance. Never add an entry — new code must read the +// kernel-decoded header instead of unmarshalling the envelope again. +var headerReparseBaseline = map[string][]string{} + +type archHeaderReparse struct { + file string // slash path relative to events/ + line int + field string // Go field name inside the header block + tag string // offending json tag +} + +// archJSONTagName extracts the json name (first comma segment) from a struct +// field tag, or "" when absent. +func archJSONTagName(field *ast.Field) string { + if field.Tag == nil { + return "" + } + raw, err := strconv.Unquote(field.Tag.Value) + if err != nil { + return "" + } + name, _, _ := strings.Cut(reflect.StructTag(raw).Get("json"), ",") + return name +} + +// archNamedStructIndex maps type names declared in the given files (one +// package) to their struct bodies, so a json:"header" field with a named +// type still resolves. +func archNamedStructIndex(files []*ast.File) map[string]*ast.StructType { + index := make(map[string]*ast.StructType) + for _, f := range files { + for _, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if st, ok := ts.Type.(*ast.StructType); ok { + index[ts.Name.Name] = st + } + } + } + } + return index +} + +// archStructBody resolves expr to a struct body: inline struct types, +// pointers to them, and named types declared in the same package. +func archStructBody(expr ast.Expr, named map[string]*ast.StructType) *ast.StructType { + switch v := expr.(type) { + case *ast.StructType: + return v + case *ast.StarExpr: + return archStructBody(v.X, named) + case *ast.Ident: + return named[v.Name] + } + return nil +} + +// archFindHeaderReparses flags every field inside a json:"header" struct +// block whose json tag re-declares envelope header metadata. Fields outside +// header blocks are never flagged: a domain body owning its own create_time +// (e.g. a message's own timestamps) is legitimate. +func archFindHeaderReparses(fset *token.FileSet, file *ast.File, relPath string, named map[string]*ast.StructType) []archHeaderReparse { + var found []archHeaderReparse + ast.Inspect(file, func(n ast.Node) bool { + st, ok := n.(*ast.StructType) + if !ok { + return true + } + for _, field := range st.Fields.List { + if archJSONTagName(field) != "header" { + continue + } + body := archStructBody(field.Type, named) + if body == nil { + continue + } + for _, hf := range body.Fields.List { + tag := archJSONTagName(hf) + if !envelopeHeaderTags[tag] { + continue + } + name := "(embedded)" + if len(hf.Names) > 0 { + parts := make([]string, len(hf.Names)) + for i, ident := range hf.Names { + parts[i] = ident.Name + } + name = strings.Join(parts, ",") + } + found = append(found, archHeaderReparse{ + file: relPath, + line: fset.Position(hf.Pos()).Line, + field: name, + tag: tag, + }) + } + } + return true + }) + return found +} + +// TestArchEventsNoHeaderMetadataReparse fails when a production file under +// events/ declares a json:"header" struct block that re-parses envelope +// header metadata, except for the pinned pre-existing residue in +// headerReparseBaseline (which may only shrink). +func TestArchEventsNoHeaderMetadataReparse(t *testing.T) { + files := archProductionGoFiles(t, ".") + if len(files) == 0 { + t.Fatal("scanned zero production files under events/ — the gate is idling; fix the walker before trusting any green run") + } + + // Parse per directory so named header types declared in a sibling file + // of the same package still resolve. + byDir := make(map[string][]string) + for _, file := range files { + dir := filepath.Dir(file) + byDir[dir] = append(byDir[dir], file) + } + dirs := make([]string, 0, len(byDir)) + for dir := range byDir { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + + fset := token.NewFileSet() + var violations []archHeaderReparse + for _, dir := range dirs { + astFiles := make([]*ast.File, 0, len(byDir[dir])) + for _, file := range byDir[dir] { + f, err := parser.ParseFile(fset, file, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + astFiles = append(astFiles, f) + } + named := archNamedStructIndex(astFiles) + for i, f := range astFiles { + rel := filepath.ToSlash(byDir[dir][i]) + violations = append(violations, archFindHeaderReparses(fset, f, rel, named)...) + } + } + + seen := make(map[string]bool) + for _, v := range violations { + seen[v.file+"\x00"+v.tag] = true + if slices.Contains(headerReparseBaseline[v.file], v.tag) { + continue + } + t.Errorf("%s:%d field %s re-parses envelope header metadata %q inside a json:\"header\" block — consume the kernel-decoded header instead of unmarshalling the envelope again", v.file, v.line, v.field, v.tag) + } + + // Stale baseline entries: once a file stops re-parsing a tag, its entry + // must go, otherwise the ratchet is wider than reality and the cleanup + // can silently regress. + for file, tags := range headerReparseBaseline { + for _, tag := range tags { + if !seen[file+"\x00"+tag] { + t.Errorf("stale baseline entry %s / %q: no code matches it anymore — delete the entry so the cleanup is locked in", file, tag) + } + } + } +} + +// TestArchEventsHeaderReparseDetectorSelfCheck runs the header-reparse +// detector on synthetic sources with a known violation count. If the +// detector rots (tag parsing, named-type resolution, header matching), the +// main gate would report green on a violating tree; this test makes that +// failure mode loud. +func TestArchEventsHeaderReparseDetectorSelfCheck(t *testing.T) { + parse := func(src string) []archHeaderReparse { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "synthetic.go", src, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse synthetic source: %v", err) + } + files := []*ast.File{f} + return archFindHeaderReparses(fset, f, "synthetic.go", archNamedStructIndex(files)) + } + + const violating = `package synth + +type namedHeader struct { + AppID string ` + "`json:\"app_id\"`" + ` +} + +type envelope struct { + Header struct { + EventID string ` + "`json:\"event_id\"`" + ` + TenantKey string ` + "`json:\"tenant_key,omitempty\"`" + ` + Custom string ` + "`json:\"custom\"`" + ` + } ` + "`json:\"header,omitempty\"`" + ` + Named *namedHeader ` + "`json:\"header\"`" + ` + Body struct { + CreateTime string ` + "`json:\"create_time\"`" + ` + } ` + "`json:\"body\"`" + ` +} +` + got := parse(violating) + gotIDs := make([]string, len(got)) + for i, v := range got { + gotIDs[i] = v.field + ":" + v.tag + } + sort.Strings(gotIDs) + wantIDs := []string{"AppID:app_id", "EventID:event_id", "TenantKey:tenant_key"} + if !slices.Equal(gotIDs, wantIDs) { + t.Fatalf("detector self-check: flagged %v, want exactly %v — the detector has drifted and the main gate cannot be trusted", gotIDs, wantIDs) + } + + const clean = `package synth + +type output struct { + EventID string ` + "`json:\"event_id\"`" + ` + Header struct { + Custom string ` + "`json:\"custom\"`" + ` + } ` + "`json:\"header\"`" + ` +} +` + if got := parse(clean); len(got) != 0 { + t.Fatalf("detector self-check: clean synthetic source flagged %+v — the detector over-triggers and will produce false reds", got) + } +} diff --git a/events/catalog_helper_test.go b/events/catalog_helper_test.go new file mode 100644 index 0000000000..c69f839ff2 --- /dev/null +++ b/events/catalog_helper_test.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// compileRealCatalog compiles the full shipped declaration set exactly as the +// runtime does. Tests that used to walk the global registry iterate this +// snapshot instead. +func compileRealCatalog(t *testing.T) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + return snap +} diff --git a/events/compile_test.go b/events/compile_test.go new file mode 100644 index 0000000000..ac4991dd18 --- /dev/null +++ b/events/compile_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "encoding/json" + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" +) + +// This gate lives in the events package because the catalog package cannot +// import the declarations it compiles (that would be an import cycle). It is +// the acceptance half of the compiler's own rejection tests: the real catalog +// must compile — a compiler that rejects everything would also pass those. +func TestCompile_RealCatalogCompilesCleanly(t *testing.T) { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("the shipped declarations must compile: %v", err) + } + if snap.Len() == 0 { + t.Fatal("the compiled catalog is empty; the gate proved nothing") + } + if snap.Len() != len(expectedKeys) { + t.Fatalf("compiled %d keys, frozen baseline has %d", snap.Len(), len(expectedKeys)) + } + for _, want := range expectedKeys { + if _, ok := snap.Resolve(want); !ok { + t.Errorf("baseline key missing from the compiled catalog: %s", want) + } + } +} + +// Every shipped key must satisfy its compiled output contract: a resolvable +// non-empty schema, a jq root that matches the output mode, and normalized +// delivery values. Golden files pin a few representative keys byte-for-byte; +// this covers the whole catalog structurally. +func TestOutputContract_HoldsForEveryKey(t *testing.T) { + snap, err := catalog.Compile(events.All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatal(err) + } + + checked := 0 + for _, entry := range snap.Entries() { + checked++ + d := entry.Descriptor() + out := entry.Output() + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(out.SchemaJSON, &parsed); err != nil || len(parsed) == 0 { + t.Errorf("%s: resolved schema must be a non-empty JSON object (err=%v)", d.Key, err) + } + + switch out.Mode { + case catalog.OutputNative: + if out.JQRootPath != ".event" { + t.Errorf("%s: native keys deliver the V2 envelope; jq root must be .event, got %q", d.Key, out.JQRootPath) + } + if entry.Binding().Process != nil { + t.Errorf("%s: native keys must not carry a processor", d.Key) + } + case catalog.OutputProcessed: + if out.JQRootPath != "." { + t.Errorf("%s: processed keys deliver a flat shape; jq root must be ., got %q", d.Key, out.JQRootPath) + } + if entry.Binding().Process == nil { + t.Errorf("%s: processed keys must carry a processor", d.Key) + } + default: + t.Errorf("%s: unknown output mode %q", d.Key, out.Mode) + } + + cap := entry.Capability() + if cap.BufferSize <= 0 || cap.BufferSize > catalog.MaxBufferSize || cap.Workers <= 0 { + t.Errorf("%s: delivery values must be normalized, got buffer=%d workers=%d", d.Key, cap.BufferSize, cap.Workers) + } + if d.Domain == "" { + t.Errorf("%s: descriptor domain must always be resolved", d.Key) + } + } + if checked == 0 { + t.Fatal("no entries were checked; the gate proved nothing") + } +} diff --git a/events/expected_keys_test.go b/events/expected_keys_test.go new file mode 100644 index 0000000000..46043830d8 --- /dev/null +++ b/events/expected_keys_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "testing" +) + +// expectedKeys is the frozen catalog baseline. Adding, removing, or renaming +// an EventKey is a deliberate contract change: update this list in the same +// commit and call the change out in the changelog. +var expectedKeys = []string{ + "application.bot.menu_v6", + "approval.instance.status_changed_v4", + "approval.task.status_changed_v4", + "board.whiteboard.updated_v1", + "card.action.trigger", + "im.chat.disbanded_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.member.user.added_v1", + "im.chat.member.user.deleted_v1", + "im.chat.member.user.withdrawn_v1", + "im.chat.updated_v1", + "im.message.message_read_v1", + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.message.receive_v1", + "minutes.minute.generated_v1", + "task.task.update_user_access_v2", + "vc.meeting.participant_meeting_ended_v1", + "vc.meeting.participant_meeting_joined_v1", + "vc.meeting.participant_meeting_started_v1", + "vc.note.generated_v1", + "vc.recording.recording_ended_v1", + "vc.recording.recording_started_v1", + "vc.recording.recording_transcript_generated_v1", +} + +func TestRegisteredKeys_MatchFrozenBaseline(t *testing.T) { + all := compileRealCatalog(t).Definitions() + if len(all) == 0 { + t.Fatal("no EventKeys registered; the gate scanned nothing") + } + got := make(map[string]bool, len(all)) + for _, def := range all { + got[def.Key] = true + } + for _, want := range expectedKeys { + if !got[want] { + t.Errorf("expected EventKey missing from registry: %s", want) + } + delete(got, want) + } + for extra := range got { + t.Errorf("EventKey not in frozen baseline (update expectedKeys deliberately): %s", extra) + } +} diff --git a/events/im/card_action.go b/events/im/card_action.go index 43b8b6cdbf..678477c821 100644 --- a/events/im/card_action.go +++ b/events/im/card_action.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // CardActionTriggerOutput is the flattened shape for card.action.trigger. @@ -35,11 +36,6 @@ type CardActionTriggerOutput struct { func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Operator struct { OpenID string `json:"open_id"` @@ -64,7 +60,7 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload + return nil, processing.DropMalformed(raw.EventType) } actionValue := marshalToString(envelope.Event.Action.Value) @@ -72,9 +68,9 @@ func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEv options := strings.Join(envelope.Event.Action.Options, ",") out := &CardActionTriggerOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, OperatorID: envelope.Event.Operator.OpenID, MessageID: envelope.Event.Context.OpenMessageID, ChatID: envelope.Event.Context.OpenChatID, diff --git a/events/im/card_action_test.go b/events/im/card_action_test.go index df0c1fe36c..ceb7c3d650 100644 --- a/events/im/card_action_test.go +++ b/events/im/card_action_test.go @@ -10,10 +10,11 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestCardActionTriggerRegistered(t *testing.T) { - def, ok := event.Lookup("card.action.trigger") + def, ok := lookupCompiledDef(t, "card.action.trigger") if !ok { t.Fatal("card.action.trigger should be registered via Keys()") } @@ -243,11 +244,11 @@ func TestProcessCardAction_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processCardAction(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } @@ -415,6 +416,7 @@ func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionT Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processCardAction(context.Background(), rt, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/im/catalog_helper_test.go b/events/im/catalog_helper_test.go new file mode 100644 index 0000000000..6061d30e3d --- /dev/null +++ b/events/im/catalog_helper_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "encoding/json" + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" +) + +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + +// lookupCompiledDef compiles this domain's declarations and resolves one key, +// exactly as the runtime catalog would for a consumer. +func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { + t.Helper() + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + entry, ok := snap.Resolve(key) + if !ok { + return nil, false + } + return entry.Definition(), true +} diff --git a/events/im/message_receive.go b/events/im/message_receive.go index ae73021232..ddad4b4b96 100644 --- a/events/im/message_receive.go +++ b/events/im/message_receive.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" ) @@ -40,11 +41,6 @@ type MentionOutput struct { func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { Message struct { MessageID string `json:"message_id"` @@ -68,7 +64,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } msg := envelope.Event.Message @@ -82,14 +78,14 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra }) } - timestamp := envelope.Header.CreateTime + timestamp := raw.SourceTime if timestamp == "" { timestamp = msg.CreateTime } out := &ImMessageReceiveOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, + Type: raw.EventType, + EventID: raw.EventID, Timestamp: timestamp, ID: msg.MessageID, MessageID: msg.MessageID, diff --git a/events/im/message_receive_test.go b/events/im/message_receive_test.go index 40acb0bae0..0321bb766f 100644 --- a/events/im/message_receive_test.go +++ b/events/im/message_receive_test.go @@ -6,22 +6,15 @@ package im import ( "context" "encoding/json" - "os" "testing" "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) -func TestMain(m *testing.M) { - for _, k := range Keys() { - event.RegisterKey(k) - } - os.Exit(m.Run()) -} - func TestIMKeys_ProcessedReceiveRegistered(t *testing.T) { - def, ok := event.Lookup("im.message.receive_v1") + def, ok := lookupCompiledDef(t, "im.message.receive_v1") if !ok { t.Fatal("im.message.receive_v1 should be registered via Keys()") } @@ -53,7 +46,7 @@ func TestIMKeys_NativeEventsRegistered(t *testing.T) { "im.chat.disbanded_v1", } for _, k := range want { - def, ok := event.Lookup(k) + def, ok := lookupCompiledDef(t, k) if !ok { t.Errorf("%s should be registered via Keys()", k) continue @@ -232,11 +225,11 @@ func TestProcessImMessageReceive_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processImMessageReceive(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } @@ -248,6 +241,7 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput { Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processImMessageReceive(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) @@ -267,6 +261,7 @@ func runReceiveMap(t *testing.T, payload string) map[string]interface{} { Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processImMessageReceive(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/internal/subscribeprep/subscribeprep.go b/events/internal/subscribeprep/subscribeprep.go new file mode 100644 index 0000000000..0eb8c3297a --- /dev/null +++ b/events/internal/subscribeprep/subscribeprep.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package subscribeprep provides the shared PreConsume hook for EventKeys +// whose server-side subscription is a plain event_type register/unregister +// pair against fixed OAPI paths. +package subscribeprep + +import ( + "context" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/event/processing" +) + +// CleanupTimeout bounds how long the unsubscribe call has to finish during +// PreConsume cleanup so a stuck OAPI cannot block process shutdown. +const CleanupTimeout = 5 * time.Second + +// Hook returns a PreConsume that subscribes eventType via subscribePath and +// hands back a cleanup that unsubscribes it via unsubscribePath. +func Hook(eventType, subscribePath, unsubscribePath string) func(context.Context, processing.APIClient, map[string]string) (func() error, error) { + return func(ctx context.Context, rt processing.APIClient, _ map[string]string) (func() error, error) { + if rt == nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, + "runtime API client is required for pre-consume subscription") + } + return SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath) + } +} + +// SubscribeWithCleanup calls the subscribe OAPI for eventType and returns a +// cleanup that invokes the matching unsubscribe, bounded by CleanupTimeout. +// rt must be non-nil; callers that validate their own params (e.g. to build +// per-resource paths) run those checks first and then delegate here. +func SubscribeWithCleanup(ctx context.Context, rt processing.APIClient, eventType, subscribePath, unsubscribePath string) (func() error, error) { + body := map[string]string{"event_type": eventType} + if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { + return nil, err + } + + return func() error { + cleanupCtx, cancel := context.WithTimeout(context.Background(), CleanupTimeout) + defer cancel() + if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { + return err + } + return nil + }, nil +} diff --git a/events/internal/subscribeprep/subscribeprep_test.go b/events/internal/subscribeprep/subscribeprep_test.go new file mode 100644 index 0000000000..bc635e0829 --- /dev/null +++ b/events/internal/subscribeprep/subscribeprep_test.go @@ -0,0 +1,214 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package subscribeprep + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/larksuite/cli/errs" +) + +type call struct { + method string + path string + body any + // deadline is what the callee saw on its context, so the test can prove + // cleanup runs under its own timeout rather than the consume context. + deadline time.Time + hasDL bool +} + +type stubAPIClient struct { + calls []call + failOn string +} + +// apiFailure stands in for what the real client hands back: it classifies +// every failure into a typed problem before returning, so this package always +// receives one and must pass it along intact. +var apiFailure = errs.NewNetworkError(errs.SubtypeNetworkServer, + "api POST /open-apis/demo/v1: upstream unavailable").WithRetryable() + +func (s *stubAPIClient) CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) { + dl, ok := ctx.Deadline() + s.calls = append(s.calls, call{method: method, path: path, body: body, deadline: dl, hasDL: ok}) + if s.failOn != "" && path == s.failOn { + return nil, apiFailure + } + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil +} + +// assertAPIFailurePassedThrough checks the caller still sees the client's own +// typed error. Rewrapping it here would strip the category, subtype and +// retryable flag the client established, and the consume command renders those +// straight into its error envelope. +func assertAPIFailurePassedThrough(t *testing.T, err error) { + t.Helper() + if !errors.Is(err, apiFailure) { + t.Fatalf("the client error did not survive: got %v", err) + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatal("the returned error carries no typed problem") + } + if problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkServer { + t.Errorf("problem = %s/%s, want %s/%s", + problem.Category, problem.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkServer) + } + if !problem.Retryable { + t.Error("retryable was lost; callers use it to decide whether retrying can help") + } +} + +const ( + testEventType = "demo.thing.updated_v1" + testSubPath = "/open-apis/demo/v1/subscribe" + testUnsubPath = "/open-apis/demo/v1/unsubscribe" +) + +// The subscribe call and the cleanup's unsubscribe call must both carry the +// event type in the body: the server keys the registration on it, so a +// dropped or renamed field would silently register nothing. +func TestHook_SubscribesThenUnsubscribesTheSameEventType(t *testing.T) { + rt := &stubAPIClient{} + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) + if err != nil { + t.Fatalf("hook: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(rt.calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(rt.calls)) + } + if rt.calls[0].method != "POST" || rt.calls[0].path != testSubPath { + t.Errorf("subscribe call = %s %s, want POST %s", rt.calls[0].method, rt.calls[0].path, testSubPath) + } + + if err := cleanup(); err != nil { + t.Fatalf("cleanup: %v", err) + } + if len(rt.calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(rt.calls)) + } + if rt.calls[1].method != "POST" || rt.calls[1].path != testUnsubPath { + t.Errorf("unsubscribe call = %s %s, want POST %s", rt.calls[1].method, rt.calls[1].path, testUnsubPath) + } + + for i, c := range rt.calls { + body, ok := c.body.(map[string]string) + if !ok { + t.Fatalf("call %d body is %T, want map[string]string", i, c.body) + } + if body["event_type"] != testEventType { + t.Errorf("call %d event_type = %q, want %q", i, body["event_type"], testEventType) + } + } +} + +// Cleanup runs on its own bounded context, not the consume context: by the +// time a consumer exits, the context it consumed under is usually already +// cancelled, and an unsubscribe on a cancelled context would never reach the +// server — leaking the server-side subscription. +func TestHook_CleanupOutlivesACancelledConsumeContext(t *testing.T) { + rt := &stubAPIClient{} + ctx, cancel := context.WithCancel(context.Background()) + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(ctx, rt, nil) + if err != nil { + t.Fatalf("hook: %v", err) + } + cancel() + + if err := cleanup(); err != nil { + t.Fatalf("cleanup after the consume context was cancelled: %v", err) + } + if len(rt.calls) != 2 { + t.Fatalf("calls = %d, want the unsubscribe to have happened", len(rt.calls)) + } + unsub := rt.calls[1] + if !unsub.hasDL { + t.Fatal("cleanup ran without a deadline; a stuck unsubscribe would block shutdown") + } + if remaining := time.Until(unsub.deadline); remaining <= 0 || remaining > CleanupTimeout { + t.Errorf("cleanup deadline is %v away, want within (0, %v]", remaining, CleanupTimeout) + } +} + +// A failed subscribe must report the error and hand back no cleanup: running +// an unsubscribe for a registration that never happened would tear down a +// co-consumer's subscription. +func TestHook_FailedSubscribeYieldsNoCleanup(t *testing.T) { + rt := &stubAPIClient{failOn: testSubPath} + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) + assertAPIFailurePassedThrough(t, err) + if cleanup != nil { + t.Error("no cleanup may be returned when the subscription was never created") + } + if len(rt.calls) != 1 { + t.Errorf("calls = %d, want only the failed subscribe", len(rt.calls)) + } +} + +// A failed unsubscribe surfaces to the caller, which decides how to report +// it; the server-side subscribe is idempotent, so the residual record is +// recoverable but must not be silently swallowed here. +func TestHook_FailedUnsubscribeSurfaces(t *testing.T) { + rt := &stubAPIClient{failOn: testUnsubPath} + + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), rt, nil) + if err != nil { + t.Fatalf("hook: %v", err) + } + assertAPIFailurePassedThrough(t, cleanup()) +} + +// The hook is the guard for a missing runtime client; it must fail before +// dereferencing it rather than panicking inside the shared core. +func TestHook_RejectsMissingAPIClient(t *testing.T) { + cleanup, err := Hook(testEventType, testSubPath, testUnsubPath)(context.Background(), nil, nil) + if cleanup != nil { + t.Error("no cleanup may be returned when the subscription was never attempted") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("missing API client must produce a typed error, got %v", err) + } + if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown { + t.Errorf("problem = %s/%s, want %s/%s", + problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown) + } +} + +// SubscribeWithCleanup is the entry point for callers that build their own +// per-resource paths after validating params; it must behave like Hook once +// those paths are resolved. +func TestSubscribeWithCleanup_UsesTheCallerSuppliedPaths(t *testing.T) { + rt := &stubAPIClient{} + const ( + perResourceSub = "/open-apis/demo/v1/things/thing-1/subscribe" + perResourceUnsub = "/open-apis/demo/v1/things/thing-1/unsubscribe" + ) + + cleanup, err := SubscribeWithCleanup(context.Background(), rt, testEventType, perResourceSub, perResourceUnsub) + if err != nil { + t.Fatalf("subscribe: %v", err) + } + if err := cleanup(); err != nil { + t.Fatalf("cleanup: %v", err) + } + if len(rt.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(rt.calls)) + } + if rt.calls[0].path != perResourceSub || rt.calls[1].path != perResourceUnsub { + t.Errorf("paths = %q then %q, want %q then %q", + rt.calls[0].path, rt.calls[1].path, perResourceSub, perResourceUnsub) + } +} diff --git a/events/legacy_bus_replay_test.go b/events/legacy_bus_replay_test.go new file mode 100644 index 0000000000..b64b205f07 --- /dev/null +++ b/events/legacy_bus_replay_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/larksuite/cli/errs" + eventlib "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/consume" + "github.com/larksuite/cli/internal/event/testutil" +) + +// replayAppID must match the app_id the baseline fixtures put in their header: +// the compatibility path checks the header's claim against the app the +// consumer is configured for, so a mismatch here would be a dropped event +// rather than a rendered one. +const replayAppID = "cli-baseline-app" + +// A consumer attached to a bus that predates canonical metadata must still +// print exactly what it printed before. This drives the real pipeline — bus +// handshake, frame decode, compatibility restore, arbitration, Match, Process, +// sink — off a byte-for-byte reproduction of the old wire format, and compares +// stdout against the frozen baseline. +// +// Asserting on the restored canonical event instead would be weaker in a way +// that matters: the legacy frame has no observation clock, so its canonical +// event legitimately differs from a current one, and any future Process that +// reads a field the two disagree on would keep both a field-level test and the +// output baseline green while the bytes diverged. Comparing the output closes +// that gap for every field at once, including fields nothing reads today. +func TestLegacyBusReplay_RendersTheFrozenOutput(t *testing.T) { + want := readFrozenBaseline(t) + + for _, def := range compileRealCatalog(t).Definitions() { + if def.Process == nil { + continue + } + fx, ok := baselineFixtures[def.Key] + if !ok { + t.Fatalf("Processed EventKey %q has no baseline fixture", def.Key) + } + expected, ok := want[def.Key] + if !ok { + t.Fatalf("baseline snapshot has no entry for %q", def.Key) + } + + t.Run(def.Key, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + payload := buildBaselineEnvelope(t, def.EventType, fx) + frame := testutil.LegacyEventFrame(def.EventType, baselineEventID, baselineCreateTime, 1, payload) + tr := testutil.NewBusStub(testutil.LegacyAck, frame).Listen(t, replayAppID) + + // The context bounds a regression: if the consumer stopped exiting + // on the event bound it would otherwise block until the package + // timeout and report that instead of the real failure. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var out bytes.Buffer + err := consume.Run(ctx, tr, replayAppID, "", "", consume.Options{ + EventKey: def.Key, + Def: def, + Runtime: &baselineAPIClient{t: t}, + Out: &out, + ErrOut: io.Discard, + Quiet: true, + MaxEvents: 1, + // The declaration's own preparation would reach for the + // subscription API, which has nothing to do with what gets + // printed. Injecting a no-op keeps this test about output and + // exercises the preparation seam the command uses in + // production. + Prepare: func(context.Context) (func() error, error) { return nil, nil }, + }) + if err != nil { + t.Fatalf("consuming a legacy frame must succeed, got: %v", err) + } + + assertSameJSONBytes(t, out.Bytes(), expected) + }) + } +} + +// The one key that cannot fall back must refuse, on the same real path: its +// subscription identity is hashed here and bare on an older bus, so old and +// new consumers of the same board would unsubscribe one another. +func TestLegacyBusReplay_RefusesTheResourceScopedKey(t *testing.T) { + var refused *eventlib.KeyDefinition + for _, def := range compileRealCatalog(t).Definitions() { + for _, p := range def.Params { + if p.SubscriptionKey { + refused = def + } + } + } + if refused == nil { + t.Skip("no shipped key carries a SubscriptionKey param") + } + + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + tr := testutil.NewBusStub(testutil.LegacyAck).Listen(t, replayAppID) + + // Short deadline on purpose: a regression that degrades instead of refusing + // would enter the consume loop, and without this the failure would surface + // as a package timeout pointing at the wrong thing. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + params := map[string]string{} + for _, p := range refused.Params { + if p.Required { + params[p.Name] = "resource-1" + } + } + + err := consume.Run(ctx, tr, replayAppID, "", "", consume.Options{ + EventKey: refused.Key, + Def: refused, + Params: params, + Runtime: &baselineAPIClient{t: t}, + Out: io.Discard, + ErrOut: io.Discard, + Quiet: true, + }) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("%s must refuse a legacy bus with a failed_precondition, got: %v", refused.Key, err) + } +} + +func readFrozenBaseline(t *testing.T) map[string]json.RawMessage { + t.Helper() + data, err := os.ReadFile(baselineSnapshotPath) + if err != nil { + t.Fatalf("read frozen baseline %s: %v", baselineSnapshotPath, err) + } + var snapshot map[string]json.RawMessage + if err := json.Unmarshal(data, &snapshot); err != nil { + t.Fatalf("decode frozen baseline: %v", err) + } + return snapshot +} + +// assertSameJSONBytes compares rendered stdout with a baseline entry after +// normalizing insignificant whitespace only: the stream is newline-delimited +// and the snapshot is stored indented, but every value inside must match. +func assertSameJSONBytes(t *testing.T, got, want json.RawMessage) { + t.Helper() + gotCompact, wantCompact := new(bytes.Buffer), new(bytes.Buffer) + if err := json.Compact(gotCompact, bytes.TrimSpace(got)); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nraw: %s", err, got) + } + if err := json.Compact(wantCompact, want); err != nil { + t.Fatalf("baseline entry is not valid JSON: %v", err) + } + if !bytes.Equal(gotCompact.Bytes(), wantCompact.Bytes()) { + t.Errorf("legacy replay diverged from the frozen output:\n got %s\nwant %s", gotCompact, wantCompact) + } +} diff --git a/events/lint_test.go b/events/lint_test.go index 5b92ea3956..fdd35ea06c 100644 --- a/events/lint_test.go +++ b/events/lint_test.go @@ -9,11 +9,19 @@ import ( "testing" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/schemas" ) func TestAllKeys_FieldOverridePointersResolve(t *testing.T) { - for _, def := range event.ListAll() { + snap, err := catalog.Compile(All(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + for _, def := range snap.Definitions() { if len(def.Schema.FieldOverrides) == 0 { continue } diff --git a/events/minutes/catalog_helper_test.go b/events/minutes/catalog_helper_test.go new file mode 100644 index 0000000000..843bfd7a1c --- /dev/null +++ b/events/minutes/catalog_helper_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package minutes + +import ( + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" +) + +// lookupCompiledDef compiles this domain's declarations and resolves one key, +// exactly as the runtime catalog would for a consumer. +func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { + t.Helper() + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + entry, ok := snap.Resolve(key) + if !ok { + return nil, false + } + return entry.Definition(), true +} diff --git a/events/minutes/minute_generated.go b/events/minutes/minute_generated.go index f4e4ec9dad..42ac3e690c 100644 --- a/events/minutes/minute_generated.go +++ b/events/minutes/minute_generated.go @@ -10,6 +10,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/validate" ) @@ -36,11 +37,6 @@ type MinutesMinuteGeneratedOutput struct { func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { MinuteToken string `json:"minute_token"` MinuteSource struct { @@ -50,18 +46,15 @@ func processMinutesMinuteGenerated(ctx context.Context, rt event.APIClient, raw } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &MinutesMinuteGeneratedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MinuteToken: envelope.Event.MinuteToken, } - if out.Type == "" { - out.Type = raw.EventType - } if src := envelope.Event.MinuteSource; src.SourceType != "" || src.SourceEntityID != "" { out.MinuteSource = &MinutesMinuteSourceOutput{ SourceType: src.SourceType, diff --git a/events/minutes/minute_generated_test.go b/events/minutes/minute_generated_test.go index 9a0a5b13ea..6bfbe08824 100644 --- a/events/minutes/minute_generated_test.go +++ b/events/minutes/minute_generated_test.go @@ -7,12 +7,12 @@ import ( "context" "encoding/json" "fmt" - "os" "reflect" "testing" "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/validate" ) @@ -35,17 +35,10 @@ func assertSubscriptionRequest(t *testing.T, gotBody any, wantEventType string) } } -func TestMain(m *testing.M) { - for _, k := range Keys() { - event.RegisterKey(k) - } - os.Exit(m.Run()) -} - func TestMinutesKeys_ProcessedMinuteGeneratedRegistered(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeMinuteGenerated) + def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) } @@ -274,7 +267,7 @@ func TestProcessMinutesMinuteGenerated_EmptyTitleExhaustsRetries(t *testing.T) { func TestMinutesMinuteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeMinuteGenerated) + def, ok := lookupCompiledDef(t, eventTypeMinuteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeMinuteGenerated) } @@ -326,12 +319,36 @@ func TestProcessMinutesMinuteGenerated_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processMinutesMinuteGenerated(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) + } + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) + } +} + +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType } + raw.SourceTime = envelope.Header.CreateTime } func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) MinutesMinuteGeneratedOutput { @@ -341,6 +358,7 @@ func runMinuteGenerated(t *testing.T, rt event.APIClient, payload string) Minute Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processMinutesMinuteGenerated(context.Background(), rt, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go index b396f63757..fb01d13223 100644 --- a/events/minutes/preconsume.go +++ b/events/minutes/preconsume.go @@ -5,33 +5,18 @@ package minutes import ( "context" - "time" - "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" ) -const cleanupTimeout = 5 * time.Second - +// subscriptionPreConsume registers the minutes event type with the server so +// this tenant starts receiving it, and hands back the matching unregister. +// +// The subscription is per event type (not per minute), so the first consumer +// registers it and the last one to exit unregisters it. The +// register/unregister pair itself is shared with the other domains that follow +// the same OAPI convention. func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { - return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { - if rt == nil { - return nil, errs.NewInternalError(errs.SubtypeUnknown, - "runtime API client is required for pre-consume subscription") - } - - body := map[string]string{"event_type": eventType} - if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { - return nil, err - } - - return func() error { - cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { - return err - } - return nil - }, nil - } + return subscribeprep.Hook(eventType, subscribePath, unsubscribePath) } diff --git a/events/output_baseline_test.go b/events/output_baseline_test.go new file mode 100644 index 0000000000..3e0b2b8cb4 --- /dev/null +++ b/events/output_baseline_test.go @@ -0,0 +1,461 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "maps" + "os" + "path/filepath" + "sort" + "testing" + "time" + + event "github.com/larksuite/cli/internal/event" +) + +var updateBaseline = flag.Bool("update-baseline", false, + "rewrite testdata/output_baseline.json with the current Processed EventKey outputs") + +// TestMain pins the process timezone to UTC before any test runs. Several +// Process handlers format timestamps in the machine's local timezone +// (e.g. meeting start/end times, recording event times), so without the pin +// the snapshot would drift between machines in different timezones. +func TestMain(m *testing.M) { + time.Local = time.UTC + os.Exit(m.Run()) +} + +const baselineSnapshotPath = "testdata/output_baseline.json" + +// wantProcessedKeys freezes how many registered EventKeys define Process +// (im 2, vc 7, minutes 1, application 1, approval 2). The count assertion +// keeps this test honest: if a Processed key is added or removed, the covered +// output surface changed and the baseline would silently widen or narrow +// without it. Update the count, the fixtures, and the snapshot together, +// deliberately. +const wantProcessedKeys = 13 + +const ( + baselineEventID = "evt-baseline-001" + baselineCreateTime = "1700000000000" // 2023-11-14T22:13:20Z in milliseconds +) + +// baselineFixture holds the minimal well-formed inputs for one Processed +// EventKey: the business body placed under "event" in the V2 envelope, plus +// any extra header fields the handler reads beyond event_id / event_type / +// create_time. Every fixture must drive Process down its success path — no +// drop, no malformed-payload passthrough. +type baselineFixture struct { + extraHeader map[string]string + eventBody string +} + +// baselineFixtures maps every Processed EventKey to its synthetic input. +// Field values are fixed constants so the resulting output is byte-stable. +var baselineFixtures = map[string]baselineFixture{ + "application.bot.menu_v6": { + extraHeader: map[string]string{ + "app_id": "cli-baseline-app", + "tenant_key": "tenant-baseline", + }, + // 10-digit seconds timestamp: the handler normalizes it to milliseconds. + eventBody: `{ + "event_key": "baseline_menu_key", + "timestamp": 1700000000, + "operator": { + "operator_id": { + "open_id": "ou-baseline-operator", + "union_id": "on-baseline-operator", + "user_id": "user-baseline-operator" + }, + "operator_name": "Baseline Operator" + } + }`, + }, + "approval.instance.status_changed_v4": { + eventBody: `{ + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "external_id": "external-id-baseline", + "status": "APPROVED", + "operate_time": "1700000000000", + "start_user": { + "open_id": "ou-baseline-starter", + "union_id": "on-baseline-starter", + "user_id": "user-baseline-starter" + } + }`, + }, + "approval.task.status_changed_v4": { + eventBody: `{ + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "task_id": "task-id-baseline", + "external_id": "external-id-baseline", + "task_external_id": "task-external-id-baseline", + "status": "APPROVED", + "operate_time": "1700000000000", + "assigned_user": { + "open_id": "ou-baseline-assignee", + "union_id": "on-baseline-assignee", + "user_id": "user-baseline-assignee" + } + }`, + }, + // The card handler fetches the card content through the API client using + // context.open_message_id; the fake client below serves that request. + "card.action.trigger": { + eventBody: `{ + "operator": {"open_id": "ou-baseline-operator"}, + "token": "card-token-baseline", + "host": "im_message", + "action": { + "tag": "button", + "value": {"key": "baseline"}, + "name": "baseline_button", + "form_value": {"field": "value"}, + "input_value": "baseline input", + "option": "opt-1", + "options": ["opt-1", "opt-2"], + "checked": true, + "timezone": "Asia/Shanghai" + }, + "context": { + "open_message_id": "om-baseline-card", + "open_chat_id": "oc-baseline-chat" + } + }`, + }, + // update_time differs from create_time so the handler emits both; the + // mention placeholder in content exercises mention rendering. + "im.message.receive_v1": { + eventBody: `{ + "sender": { + "sender_type": "user", + "sender_id": {"open_id": "ou-baseline-sender"} + }, + "message": { + "message_id": "om-baseline-msg", + "root_id": "om-baseline-root", + "parent_id": "om-baseline-parent", + "thread_id": "omt-baseline-thread", + "chat_id": "oc-baseline-chat", + "chat_type": "p2p", + "message_type": "text", + "create_time": "1699999999000", + "update_time": "1700000000500", + "content": "{\"text\":\"hello @_user_1\"}", + "mentions": [ + { + "key": "@_user_1", + "id": {"open_id": "ou-baseline-mention"}, + "name": "Baseline User" + } + ] + } + }`, + }, + // The minutes handler enriches the output with the minute title via the + // API client; the fake client answers with a non-empty title on the first + // call so no retry attempt is made. + "minutes.minute.generated_v1": { + eventBody: `{ + "minute_token": "minute-token-baseline", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + }`, + }, + "vc.meeting.participant_meeting_started_v1": { + eventBody: `{ + "meeting": { + "id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "1700000000", + "calendar_event_id": "calendar-event-baseline" + } + }`, + }, + "vc.meeting.participant_meeting_joined_v1": { + eventBody: `{ + "meeting": { + "id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "1700000000", + "calendar_event_id": "calendar-event-baseline" + } + }`, + }, + "vc.meeting.participant_meeting_ended_v1": { + eventBody: `{ + "meeting": { + "id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "1700000000", + "end_time": "1700000600", + "calendar_event_id": "calendar-event-baseline" + } + }`, + }, + // The note handler enriches the output with document tokens via the API + // client; the fake client answers with both artifacts on the first call + // so no retry attempt is made. + "vc.note.generated_v1": { + eventBody: `{"note_id": "note-id-baseline"}`, + }, + // Recording handlers only emit events whose source is recording_bean; + // anything else is dropped, which would break the success-path contract. + "vc.recording.recording_started_v1": { + eventBody: `{ + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }`, + }, + "vc.recording.recording_transcript_generated_v1": { + eventBody: `{ + "unique_key": "recording-key-baseline", + "source": "recording_bean", + "transcript_items": [ + { + "speaker": {"user_name": "Baseline Speaker"}, + "text": "baseline transcript text", + "start_time_ms": "1700000000000", + "end_time_ms": "1700000001000", + "sentence_id": "sentence-baseline-1" + } + ] + }`, + }, + "vc.recording.recording_ended_v1": { + eventBody: `{ + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }`, + }, +} + +// baselineAPIResponses maps request paths to canned success responses for the +// handlers that call the API during Process. Every response satisfies the +// handler on the first call, so retry loops never engage and no real network +// or credentials are involved. +var baselineAPIResponses = map[string]string{ + "/open-apis/im/v1/messages/om-baseline-card?card_msg_content_type=user_card_content": `{ + "code": 0, + "msg": "success", + "data": { + "items": [ + {"body": {"content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}"}} + ] + } + }`, + "/open-apis/vc/v1/notes/note-id-baseline": `{ + "code": 0, + "msg": "success", + "data": { + "note": { + "artifacts": [ + {"artifact_type": 1, "doc_token": "note-doc-token-baseline"}, + {"artifact_type": 2, "doc_token": "verbatim-doc-token-baseline"} + ], + "note_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + } + } + }`, + "/open-apis/minutes/v1/minutes/minute-token-baseline": `{ + "code": 0, + "msg": "success", + "data": { + "minute": {"title": "Baseline minute title"} + } + }`, +} + +// baselineAPIClient serves the canned responses above. An unexpected request +// path fails the test immediately instead of returning an error, because +// several handlers swallow API errors (or retry with delays) and would +// silently produce a degraded output that gets frozen into the baseline. +type baselineAPIClient struct { + t *testing.T +} + +func (c *baselineAPIClient) CallAPI(_ context.Context, method, path string, _ any) (json.RawMessage, error) { + c.t.Helper() + resp, ok := baselineAPIResponses[path] + if !ok { + c.t.Fatalf("unexpected API call during Process: %s %s — add a canned response to baselineAPIResponses", method, path) + } + return json.RawMessage(resp), nil +} + +// TestProcessedOutputBaseline runs every Processed EventKey against a fixed +// well-formed synthetic payload and compares the outputs with the frozen +// snapshot in testdata/output_baseline.json. Any change to what a Processed +// key writes to stdout for a well-formed event shows up here as a named, +// per-key diff. Run with -update-baseline to accept an intentional change. +func TestProcessedOutputBaseline(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + rt := &baselineAPIClient{t: t} + got := map[string]json.RawMessage{} + seenFixtures := map[string]bool{} + + for _, def := range compileRealCatalog(t).Definitions() { + if def.Process == nil { + continue + } + fx, ok := baselineFixtures[def.Key] + if !ok { + t.Fatalf("Processed EventKey %q has no baseline fixture; add one to baselineFixtures, bump wantProcessedKeys, and regenerate with -update-baseline", def.Key) + } + seenFixtures[def.Key] = true + + payload := buildBaselineEnvelope(t, def.EventType, fx) + // The canonical fields mirror the synthetic envelope header exactly, + // including any extra header fields, just as the consume pipeline + // guarantees for real events before Process runs. + raw := &event.RawEvent{ + EventID: baselineEventID, + EventType: def.EventType, + SourceTime: baselineCreateTime, + AppID: fx.extraHeader["app_id"], + TenantKey: fx.extraHeader["tenant_key"], + Payload: payload, + Timestamp: time.Unix(1700000000, 0).UTC(), + } + + out, err := def.Process(context.Background(), rt, raw, nil) + if err != nil { + t.Fatalf("%s: Process returned error on well-formed payload: %v", def.Key, err) + } + if out == nil { + t.Fatalf("%s: Process dropped a well-formed payload; the fixture must exercise the success path", def.Key) + } + if bytes.Equal(compactJSON(t, def.Key, out), compactJSON(t, def.Key, payload)) { + t.Fatalf("%s: Process returned the input unchanged; the fixture must exercise the success path, not the malformed-payload passthrough", def.Key) + } + got[def.Key] = out + } + + if len(got) != wantProcessedKeys { + t.Fatalf("processed %d EventKeys, want exactly %d; a Processed key was added or removed — update baselineFixtures, wantProcessedKeys, and the snapshot together (keys run: %v)", + len(got), wantProcessedKeys, sortedKeys(got)) + } + for key := range baselineFixtures { + if !seenFixtures[key] { + t.Fatalf("baseline fixture %q matches no registered Processed EventKey; remove it or fix the key name", key) + } + } + + if *updateBaseline { + writeBaselineSnapshot(t, got) + return + } + compareBaselineSnapshot(t, got) +} + +// buildBaselineEnvelope wraps a fixture body in the standard V2 event +// envelope with fixed header values. +func buildBaselineEnvelope(t *testing.T, eventType string, fx baselineFixture) json.RawMessage { + t.Helper() + header := map[string]string{ + "event_id": baselineEventID, + "event_type": eventType, + "create_time": baselineCreateTime, + } + maps.Copy(header, fx.extraHeader) + headerJSON, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal envelope header: %v", err) + } + envelope := map[string]json.RawMessage{ + "schema": json.RawMessage(`"2.0"`), + "header": headerJSON, + "event": json.RawMessage(fx.eventBody), + } + payload, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("marshal envelope for %s: %v", eventType, err) + } + return payload +} + +func writeBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) { + t.Helper() + // MarshalIndent sorts map keys, so the snapshot is deterministic. + data, err := json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("marshal snapshot: %v", err) + } + data = append(data, '\n') + if err := os.MkdirAll(filepath.Dir(baselineSnapshotPath), 0o755); err != nil { + t.Fatalf("create testdata dir: %v", err) + } + if err := os.WriteFile(baselineSnapshotPath, data, 0o644); err != nil { + t.Fatalf("write snapshot: %v", err) + } + t.Logf("baseline snapshot rewritten: %s (%d keys)", baselineSnapshotPath, len(got)) +} + +func compareBaselineSnapshot(t *testing.T, got map[string]json.RawMessage) { + t.Helper() + data, err := os.ReadFile(baselineSnapshotPath) + if os.IsNotExist(err) { + t.Fatalf("baseline snapshot %s not found; generate it with: go test ./events/ -run TestProcessedOutput -update-baseline", baselineSnapshotPath) + } + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + var want map[string]json.RawMessage + if err := json.Unmarshal(data, &want); err != nil { + t.Fatalf("snapshot %s is not valid JSON: %v", baselineSnapshotPath, err) + } + + for _, key := range sortedKeys(want) { + if _, ok := got[key]; !ok { + t.Errorf("%s: present in snapshot but produced no output this run; if the key was removed on purpose, regenerate with -update-baseline", key) + } + } + for _, key := range sortedKeys(got) { + wantOut, ok := want[key] + if !ok { + t.Errorf("%s: produced output but missing from snapshot; regenerate with -update-baseline", key) + continue + } + gotC := compactJSON(t, key, got[key]) + wantC := compactJSON(t, key, wantOut) + if !bytes.Equal(gotC, wantC) { + t.Errorf("%s: Processed output drifted from baseline\n got: %s\n want: %s\nIf this change is intentional, regenerate with -update-baseline", key, gotC, wantC) + } + } +} + +// compactJSON canonicalizes whitespace so comparisons are content-only. +func compactJSON(t *testing.T, key string, raw json.RawMessage) []byte { + t.Helper() + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + t.Fatalf("%s: output is not valid JSON: %v\nraw=%s", key, err, string(raw)) + } + return buf.Bytes() +} + +func sortedKeys(m map[string]json.RawMessage) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/events/schema_closure_test.go b/events/schema_closure_test.go new file mode 100644 index 0000000000..0490bb70df --- /dev/null +++ b/events/schema_closure_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" +) + +// closureAPIClient answers any API call with a benign error: a handler facing +// a malformed payload must decide to drop before it ever needs the API. +type closureAPIClient struct{} + +func (closureAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) { + return nil, errors.New("no API access for malformed input") +} + +// Every Processed EventKey declares an output schema; its stdout must stay +// inside that schema. A payload that cannot be decoded therefore has exactly +// one legal outcome: a malformed drop. Passing the raw envelope through would +// hand consumers a shape the schema never described. +// +// Native keys (Process == nil) are exempt by contract: their declared output +// is the raw envelope itself. +func TestAllKeys_MalformedPayloadStaysSchemaClosed(t *testing.T) { + const wantProcessedKeys = 13 + + processed := 0 + for _, def := range compileRealCatalog(t).Definitions() { + if def.Process == nil { + continue + } + processed++ + out, err := safeProcess(t, def, json.RawMessage(`this is definitely not valid json {{{`)) + if out != nil { + t.Errorf("%s: malformed payload produced stdout output; it must be dropped", def.Key) + } + if !processing.IsDropMalformed(err) { + t.Errorf("%s: malformed payload must be dropped with a malformed marker, got err=%v", def.Key, err) + } + } + if processed == 0 { + t.Fatal("no processed keys were exercised; the gate scanned nothing") + } + if processed != wantProcessedKeys { + t.Fatalf("exercised %d processed keys, want exactly %d; update the count when keys are deliberately added or removed", processed, wantProcessedKeys) + } +} + +// safeProcess isolates a panicking handler to a per-key finding instead of +// aborting the whole gate: a handler that dereferences before decoding is a +// bug in that key, not a reason to stop scanning the rest. +func safeProcess(t *testing.T, def *event.KeyDefinition, payload json.RawMessage) (out json.RawMessage, err error) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Errorf("%s: Process panicked on malformed payload: %v", def.Key, r) + out, err = nil, nil + } + }() + raw := &event.RawEvent{ + EventID: "evt-closure-1", + EventType: def.EventType, + Payload: payload, + Timestamp: time.Unix(0, 0), + } + return def.Process(context.Background(), closureAPIClient{}, raw, map[string]string{}) +} diff --git a/events/schema_instance_test.go b/events/schema_instance_test.go new file mode 100644 index 0000000000..db3fba2404 --- /dev/null +++ b/events/schema_instance_test.go @@ -0,0 +1,262 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package events_test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "testing" + + "github.com/larksuite/cli/internal/event/catalog" +) + +// The output baseline freezes what every Processed key writes to stdout; the +// compiled catalog promises a schema for the same bytes. This test closes the +// loop between the two: every frozen output must be an instance of its key's +// resolved schema, so a schema and its real output can never drift apart with +// both sides individually green. +// +// The repository deliberately carries no JSON Schema validation dependency, +// so validation is done by a minimal in-repo checker that covers exactly the +// subset the catalog compiler emits (see validateValue). Any schema construct +// outside that subset is a loud failure, never a silent pass. +func TestProcessedBaselineOutputs_ConformToDeclaredSchemas(t *testing.T) { + snap := compileRealCatalog(t) + baseline := readBaselineSnapshot(t) + + validated := 0 + for _, entry := range snap.Entries() { + out := entry.Output() + if out.Mode != catalog.OutputProcessed { + continue + } + key := entry.Descriptor().Key + frozen, ok := baseline[key] + if !ok { + t.Errorf("%s: Processed key has no entry in %s; regenerate the baseline first", key, baselineSnapshotPath) + continue + } + schema := decodeSchemaNode(t, key, out.SchemaJSON) + instance := decodeInstance(t, key, frozen) + for _, problem := range validateValue("$", schema, instance) { + t.Errorf("%s: frozen output violates the declared schema: %s", key, problem) + } + validated++ + } + + // Idle detection, both directions: every Processed key was checked + // against a baseline entry, and no baseline entry escaped the check. + if validated == 0 { + t.Fatal("no Processed key was validated; the gate scanned nothing") + } + if validated != len(baseline) { + t.Fatalf("validated %d Processed keys but the baseline holds %d entries — a baseline entry matches no compiled Processed key (keys: %v)", + validated, len(baseline), sortedKeys(baseline)) + } +} + +// The validator itself must bite: an output tampered with in memory — an +// undeclared field, a primitive type flip — has to produce findings, +// otherwise a green conformance run proves nothing. The baseline file is +// never modified. +func TestSchemaInstanceValidator_BitesOnTamperedOutput(t *testing.T) { + const key = "im.message.receive_v1" + snap := compileRealCatalog(t) + entry, ok := snap.Resolve(key) + if !ok { + t.Fatalf("key %s is gone from the catalog; pick another Processed key for this self-check", key) + } + baseline := readBaselineSnapshot(t) + frozen, ok := baseline[key] + if !ok { + t.Fatalf("key %s has no baseline entry; the self-check needs a real frozen output", key) + } + schema := decodeSchemaNode(t, key, entry.Output().SchemaJSON) + + // Control: the untampered output is conformant, so any finding below is + // caused by the tampering alone. + if problems := validateValue("$", schema, decodeInstance(t, key, frozen)); len(problems) != 0 { + t.Fatalf("control failed: the untampered output already has findings: %v", problems) + } + + tampered, ok := decodeInstance(t, key, frozen).(map[string]any) + if !ok { + t.Fatalf("baseline output for %s is not a JSON object", key) + } + tampered["field_the_schema_never_declared"] = "smuggled" + if problems := validateValue("$", schema, tampered); len(problems) != 1 { + t.Errorf("an undeclared field must produce exactly one finding, got: %v", problems) + } + + flipped, ok := decodeInstance(t, key, frozen).(map[string]any) + if !ok { + t.Fatalf("baseline output for %s is not a JSON object", key) + } + flipped["message_id"] = true // declared as a string + if problems := validateValue("$", schema, flipped); len(problems) != 1 { + t.Errorf("a primitive type flip must produce exactly one finding, got: %v", problems) + } +} + +func readBaselineSnapshot(t *testing.T) map[string]json.RawMessage { + t.Helper() + data, err := os.ReadFile(baselineSnapshotPath) + if err != nil { + t.Fatalf("read %s: %v", baselineSnapshotPath, err) + } + var out map[string]json.RawMessage + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("%s is not valid JSON: %v", baselineSnapshotPath, err) + } + return out +} + +func decodeSchemaNode(t *testing.T, key string, raw json.RawMessage) map[string]any { + t.Helper() + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("%s: resolved schema is not a JSON object: %v", key, err) + } + return schema +} + +// decodeInstance parses a frozen output with UseNumber so integer/number +// checks see the literal digits instead of a lossy float64. +func decodeInstance(t *testing.T, key string, raw json.RawMessage) any { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + t.Fatalf("%s: baseline output is not valid JSON: %v", key, err) + } + return v +} + +// validateValue checks one instance value against one schema node and returns +// the problems found. It implements only the subset the catalog compiler can +// emit (schemas.FromType plus raw declarations shaped the same way): +// +// - type object with properties: every instance field must be declared in +// properties and conform to its node; undeclared fields are errors. +// Absent declared fields are legal (handlers omit empty members). +// - type string / integer / number / boolean: the JSON value kind must +// match. +// - type array with items: every element must conform to items. +// +// description/format/enum annotations are metadata, not instance constraints +// here. Any construct outside the subset — a missing or unknown type, an +// object without properties, additionalProperties, an array without items — +// is reported as a problem so the validator can only be extended +// deliberately, never bypassed by a schema it does not understand. +func validateValue(path string, schema map[string]any, value any) []string { + typ, ok := schema["type"].(string) + if !ok { + return []string{fmt.Sprintf("%s: schema node has no \"type\"; outside the minimal validator subset, extend the validator deliberately", path)} + } + + switch typ { + case "object": + obj, ok := value.(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s: schema declares object, output has %s", path, jsonKind(value))} + } + if _, has := schema["additionalProperties"]; has { + return []string{fmt.Sprintf("%s: schema uses additionalProperties; outside the minimal validator subset, extend the validator deliberately", path)} + } + props, ok := schema["properties"].(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s: object schema without properties; outside the minimal validator subset, extend the validator deliberately", path)} + } + var problems []string + for _, field := range sortedFieldNames(obj) { + fieldPath := path + "." + field + node, declared := props[field] + if !declared { + problems = append(problems, fmt.Sprintf("%s: field is not declared in the schema properties", fieldPath)) + continue + } + nodeObj, ok := node.(map[string]any) + if !ok { + problems = append(problems, fmt.Sprintf("%s: schema property is not an object", fieldPath)) + continue + } + problems = append(problems, validateValue(fieldPath, nodeObj, obj[field])...) + } + return problems + + case "string": + if _, ok := value.(string); !ok { + return []string{fmt.Sprintf("%s: schema declares string, output has %s", path, jsonKind(value))} + } + case "boolean": + if _, ok := value.(bool); !ok { + return []string{fmt.Sprintf("%s: schema declares boolean, output has %s", path, jsonKind(value))} + } + case "integer": + num, ok := value.(json.Number) + if !ok { + return []string{fmt.Sprintf("%s: schema declares integer, output has %s", path, jsonKind(value))} + } + if _, err := strconv.ParseInt(num.String(), 10, 64); err != nil { + return []string{fmt.Sprintf("%s: schema declares integer, output has non-integer number %s", path, num)} + } + case "number": + if _, ok := value.(json.Number); !ok { + return []string{fmt.Sprintf("%s: schema declares number, output has %s", path, jsonKind(value))} + } + + case "array": + arr, ok := value.([]any) + if !ok { + return []string{fmt.Sprintf("%s: schema declares array, output has %s", path, jsonKind(value))} + } + items, ok := schema["items"].(map[string]any) + if !ok { + return []string{fmt.Sprintf("%s: array schema without items; outside the minimal validator subset, extend the validator deliberately", path)} + } + var problems []string + for i, elem := range arr { + problems = append(problems, validateValue(fmt.Sprintf("%s[%d]", path, i), items, elem)...) + } + return problems + + default: + return []string{fmt.Sprintf("%s: schema type %q; outside the minimal validator subset, extend the validator deliberately", path, typ)} + } + return nil +} + +// jsonKind names a decoded JSON value's kind for problem messages. +func jsonKind(v any) string { + switch v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case string: + return "string" + case json.Number: + return "number" + case []any: + return "array" + case map[string]any: + return "object" + default: + return fmt.Sprintf("%T", v) + } +} + +func sortedFieldNames(obj map[string]any) []string { + names := make([]string, 0, len(obj)) + for name := range obj { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/events/task/register_test.go b/events/task/register_test.go index 06897c1122..b4e932db03 100644 --- a/events/task/register_test.go +++ b/events/task/register_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/schemas" ) @@ -83,13 +83,14 @@ func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) { func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) { const key = eventTypeTaskUpdateUserAccessV2 - event.UnregisterKeyForTest(key) - t.Cleanup(func() { event.UnregisterKeyForTest(key) }) - - for _, def := range Keys() { - event.RegisterKey(def) - } - if _, ok := event.Lookup(key); !ok { - t.Fatalf("event.Lookup(%q) not registered", key) + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + if _, ok := snap.Resolve(key); !ok { + t.Fatalf("snap.Resolve(%q): key missing from compiled catalog", key) } } diff --git a/events/testdata/output_baseline.json b/events/testdata/output_baseline.json new file mode 100644 index 0000000000..1d80d21ea3 --- /dev/null +++ b/events/testdata/output_baseline.json @@ -0,0 +1,177 @@ +{ + "application.bot.menu_v6": { + "type": "application.bot.menu_v6", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "app_id": "cli-baseline-app", + "tenant_key": "tenant-baseline", + "event_key": "baseline_menu_key", + "menu_timestamp": "1700000000000", + "operator_id": "ou-baseline-operator", + "operator_open_id": "ou-baseline-operator", + "operator_union_id": "on-baseline-operator", + "operator_user_id": "user-baseline-operator", + "operator_name": "Baseline Operator" + }, + "approval.instance.status_changed_v4": { + "type": "approval.instance.status_changed_v4", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "external_id": "external-id-baseline", + "status": "APPROVED", + "operate_time": "1700000000000", + "start_user": { + "open_id": "ou-baseline-starter", + "union_id": "on-baseline-starter", + "user_id": "user-baseline-starter" + } + }, + "approval.task.status_changed_v4": { + "type": "approval.task.status_changed_v4", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "approval_code": "approval-code-baseline", + "instance_code": "instance-code-baseline", + "task_id": "task-id-baseline", + "external_id": "external-id-baseline", + "task_external_id": "task-external-id-baseline", + "assigned_user": { + "open_id": "ou-baseline-assignee", + "union_id": "on-baseline-assignee", + "user_id": "user-baseline-assignee" + }, + "status": "APPROVED", + "operate_time": "1700000000000" + }, + "card.action.trigger": { + "type": "card.action.trigger", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "operator_id": "ou-baseline-operator", + "message_id": "om-baseline-card", + "chat_id": "oc-baseline-chat", + "host": "im_message", + "token": "card-token-baseline", + "action_tag": "button", + "action_value": "{\"key\":\"baseline\"}", + "action_name": "baseline_button", + "form_value": "{\"field\":\"value\"}", + "input_value": "baseline input", + "option": "opt-1", + "options": "opt-1,opt-2", + "checked": true, + "timezone": "Asia/Shanghai", + "card_content": "{\"header\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"Baseline card\"}}}" + }, + "im.message.receive_v1": { + "type": "im.message.receive_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "id": "om-baseline-msg", + "message_id": "om-baseline-msg", + "create_time": "1699999999000", + "update_time": "1700000000500", + "chat_id": "oc-baseline-chat", + "chat_type": "p2p", + "message_type": "text", + "sender_id": "ou-baseline-sender", + "sender_type": "user", + "root_id": "om-baseline-root", + "thread_id": "omt-baseline-thread", + "reply_to": "om-baseline-parent", + "content": "hello @Baseline User", + "mentions": [ + { + "key": "@_user_1", + "id": "ou-baseline-mention", + "name": "Baseline User" + } + ] + }, + "minutes.minute.generated_v1": { + "type": "minutes.minute.generated_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "minute_token": "minute-token-baseline", + "title": "Baseline minute title", + "minute_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + }, + "vc.meeting.participant_meeting_ended_v1": { + "type": "vc.meeting.participant_meeting_ended_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "meeting_id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "2023-11-14T22:13:20Z", + "end_time": "2023-11-14T22:23:20Z", + "calendar_event_id": "calendar-event-baseline" + }, + "vc.meeting.participant_meeting_joined_v1": { + "type": "vc.meeting.participant_meeting_joined_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "meeting_id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "2023-11-14T22:13:20Z", + "calendar_event_id": "calendar-event-baseline" + }, + "vc.meeting.participant_meeting_started_v1": { + "type": "vc.meeting.participant_meeting_started_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "meeting_id": "meeting-id-baseline", + "topic": "Baseline meeting", + "meeting_no": "123456789", + "start_time": "2023-11-14T22:13:20Z", + "calendar_event_id": "calendar-event-baseline" + }, + "vc.note.generated_v1": { + "type": "vc.note.generated_v1", + "event_id": "evt-baseline-001", + "timestamp": "1700000000000", + "note_id": "note-id-baseline", + "note_token": "note-doc-token-baseline", + "verbatim_token": "verbatim-doc-token-baseline", + "note_source": { + "source_type": "meeting", + "source_entity_id": "meeting-entity-baseline" + } + }, + "vc.recording.recording_ended_v1": { + "type": "vc.recording.recording_ended_v1", + "event_id": "evt-baseline-001", + "event_time": "2023-11-14T22:13:20Z", + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }, + "vc.recording.recording_started_v1": { + "type": "vc.recording.recording_started_v1", + "event_id": "evt-baseline-001", + "event_time": "2023-11-14T22:13:20Z", + "unique_key": "recording-key-baseline", + "source": "recording_bean" + }, + "vc.recording.recording_transcript_generated_v1": { + "type": "vc.recording.recording_transcript_generated_v1", + "event_id": "evt-baseline-001", + "event_time": "2023-11-14T22:13:20Z", + "unique_key": "recording-key-baseline", + "source": "recording_bean", + "transcript_items": [ + { + "speaker_name": "Baseline Speaker", + "text": "baseline transcript text", + "start_time": "2023-11-14T22:13:20Z", + "end_time": "2023-11-14T22:13:21Z", + "sentence_id": "sentence-baseline-1" + } + ] + } +} diff --git a/events/vc/catalog_helper_test.go b/events/vc/catalog_helper_test.go new file mode 100644 index 0000000000..7f836cdec4 --- /dev/null +++ b/events/vc/catalog_helper_test.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" +) + +// lookupCompiledDef compiles this domain's declarations and resolves one key, +// exactly as the runtime catalog would for a consumer. +func lookupCompiledDef(t *testing.T, key string) (*event.KeyDefinition, bool) { + t.Helper() + snap, err := catalog.Compile(Keys(), catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("catalog.Compile(Keys()): %v", err) + } + entry, ok := snap.Resolve(key) + if !ok { + return nil, false + } + return entry.Definition(), true +} diff --git a/events/vc/internal_helpers.go b/events/vc/internal_helpers.go new file mode 100644 index 0000000000..1b1eff76f3 --- /dev/null +++ b/events/vc/internal_helpers.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// recordingBeanSource is the only recording source the vc.recording.* keys +// emit; events carrying any other source are silently filtered out. +const recordingBeanSource = "recording_bean" + +// recordingBeanEventBody is the shared {"event": ...} body for +// recording_started and recording_ended, whose payloads carry identical fields. +type recordingBeanEventBody struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +// decodeEventBody unmarshals the {"event": ...} envelope of raw and returns +// the decoded body; ok is false when the payload does not decode. +func decodeEventBody[T any](raw *event.RawEvent) (T, bool) { + var envelope struct { + Event T `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + var zero T + return zero, false + } + return envelope.Event, true +} + +// millisToLocalRFC3339 converts a unix-millisecond timestamp string to +// RFC3339 in the local timezone; empty or non-numeric input yields "". +func millisToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} + +// unixSecondsToLocalRFC3339 converts a unix-second timestamp string to +// RFC3339 in the local timezone; empty or non-numeric input yields "". +func unixSecondsToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + secs, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.Unix(secs, 0).Local().Format(time.RFC3339) +} diff --git a/events/vc/note_generated.go b/events/vc/note_generated.go index ac5e45760d..122dda124e 100644 --- a/events/vc/note_generated.go +++ b/events/vc/note_generated.go @@ -11,6 +11,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" "github.com/larksuite/cli/internal/validate" ) @@ -42,28 +43,20 @@ type VCNoteGeneratedOutput struct { func processVCNoteGenerated(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` Event struct { NoteID string `json:"note_id"` } `json:"event"` } if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + return nil, processing.DropMalformed(raw.EventType) } out := &VCNoteGeneratedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, NoteID: envelope.Event.NoteID, } - if out.Type == "" { - out.Type = raw.EventType - } if rt != nil && out.NoteID != "" { fillVCNoteGeneratedDetails(ctx, rt, out) diff --git a/events/vc/note_generated_test.go b/events/vc/note_generated_test.go index c45186236d..9557604c7e 100644 --- a/events/vc/note_generated_test.go +++ b/events/vc/note_generated_test.go @@ -10,12 +10,13 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestVCKeys_ProcessedNoteGeneratedRegistered(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeNoteGenerated) + def, ok := lookupCompiledDef(t, eventTypeNoteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated) } @@ -113,7 +114,7 @@ func TestProcessVCNoteGenerated(t *testing.T) { func TestVCNoteGenerated_PreConsumeSubscriptionLifecycle(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeNoteGenerated) + def, ok := lookupCompiledDef(t, eventTypeNoteGenerated) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeNoteGenerated) } @@ -301,11 +302,11 @@ func TestProcessVCNoteGenerated_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processVCNoteGenerated(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } @@ -316,6 +317,7 @@ func runNoteGenerated(t *testing.T, rt event.APIClient, payload string) VCNoteGe Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processVCNoteGenerated(context.Background(), rt, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/participant_meeting_ended.go b/events/vc/participant_meeting_ended.go index 4941b3b753..773818621d 100644 --- a/events/vc/participant_meeting_ended.go +++ b/events/vc/participant_meeting_ended.go @@ -6,10 +6,9 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCParticipantMeetingEndedOutput is the flattened shape for vc.meeting.participant_meeting_ended_v1. @@ -25,33 +24,28 @@ type VCParticipantMeetingEndedOutput struct { CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` } +type participantMeetingEndedEvent struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` +} + func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` - Event struct { - Meeting struct { - ID string `json:"id"` - Topic string `json:"topic"` - MeetingNo string `json:"meeting_no"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - CalendarEventID string `json:"calendar_event_id"` - } `json:"meeting"` - } `json:"event"` - } - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + body, ok := decodeEventBody[participantMeetingEndedEvent](raw) + if !ok { + return nil, processing.DropMalformed(raw.EventType) } - meeting := envelope.Event.Meeting + meeting := body.Meeting out := &VCParticipantMeetingEndedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MeetingID: meeting.ID, Topic: meeting.Topic, MeetingNo: meeting.MeetingNo, @@ -59,19 +53,5 @@ func processVCParticipantMeetingEnded(_ context.Context, _ event.APIClient, raw EndTime: unixSecondsToLocalRFC3339(meeting.EndTime), CalendarEventID: meeting.CalendarEventID, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } - -func unixSecondsToLocalRFC3339(raw string) string { - if raw == "" { - return "" - } - secs, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return "" - } - return time.Unix(secs, 0).Local().Format(time.RFC3339) -} diff --git a/events/vc/participant_meeting_ended_test.go b/events/vc/participant_meeting_ended_test.go index 0989f484c3..e21caf41fc 100644 --- a/events/vc/participant_meeting_ended_test.go +++ b/events/vc/participant_meeting_ended_test.go @@ -6,24 +6,17 @@ package vc import ( "context" "encoding/json" - "os" "testing" "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) -func TestMain(m *testing.M) { - for _, k := range Keys() { - event.RegisterKey(k) - } - os.Exit(m.Run()) -} - func TestVCKeys_ProcessedMeetingEndedRegistered(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup(eventTypeMeetingEnded) + def, ok := lookupCompiledDef(t, eventTypeMeetingEnded) if !ok { t.Fatalf("%s should be registered via Keys()", eventTypeMeetingEnded) } @@ -130,18 +123,18 @@ func TestProcessVCParticipantMeetingEnded_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } } func TestVCParticipantMeetingEnded_PreConsumeSubscriptionLifecycle(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - def, ok := event.Lookup("vc.meeting.participant_meeting_ended_v1") + def, ok := lookupCompiledDef(t, "vc.meeting.participant_meeting_ended_v1") if !ok { t.Fatal("vc.meeting.participant_meeting_ended_v1 should be registered via Keys()") } @@ -191,6 +184,7 @@ func runMeetingEnded(t *testing.T, payload string) VCParticipantMeetingEndedOutp Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := processVCParticipantMeetingEnded(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/participant_meeting_joined.go b/events/vc/participant_meeting_joined.go index 99ac9e76d3..0e44f2b6f8 100644 --- a/events/vc/participant_meeting_joined.go +++ b/events/vc/participant_meeting_joined.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1. @@ -22,41 +23,33 @@ type VCParticipantMeetingJoinedOutput struct { CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` } +type participantMeetingJoinedEvent struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` +} + func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` - Event struct { - Meeting struct { - ID string `json:"id"` - Topic string `json:"topic"` - MeetingNo string `json:"meeting_no"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - CalendarEventID string `json:"calendar_event_id"` - } `json:"meeting"` - } `json:"event"` - } - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + body, ok := decodeEventBody[participantMeetingJoinedEvent](raw) + if !ok { + return nil, processing.DropMalformed(raw.EventType) } - meeting := envelope.Event.Meeting + meeting := body.Meeting out := &VCParticipantMeetingJoinedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MeetingID: meeting.ID, Topic: meeting.Topic, MeetingNo: meeting.MeetingNo, StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), CalendarEventID: meeting.CalendarEventID, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/vc/participant_meeting_lifecycle_test.go b/events/vc/participant_meeting_lifecycle_test.go index c67b9654fe..224d50a861 100644 --- a/events/vc/participant_meeting_lifecycle_test.go +++ b/events/vc/participant_meeting_lifecycle_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) { @@ -24,7 +25,7 @@ func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) { {eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})}, } { t.Run(tc.eventType, func(t *testing.T) { - def, ok := event.Lookup(tc.eventType) + def, ok := lookupCompiledDef(t, tc.eventType) if !ok { t.Fatalf("%s should be registered via Keys()", tc.eventType) } @@ -193,11 +194,11 @@ func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) { Timestamp: time.Now(), } got, err := tc.process(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } }) } @@ -208,7 +209,7 @@ func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testin for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} { t.Run(eventType, func(t *testing.T) { - def, ok := event.Lookup(eventType) + def, ok := lookupCompiledDef(t, eventType) if !ok { t.Fatalf("%s should be registered via Keys()", eventType) } @@ -273,6 +274,7 @@ func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.Proces Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := process(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/participant_meeting_started.go b/events/vc/participant_meeting_started.go index d91aa3ebe7..2cdfcec3b0 100644 --- a/events/vc/participant_meeting_started.go +++ b/events/vc/participant_meeting_started.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1. @@ -22,40 +23,32 @@ type VCParticipantMeetingStartedOutput struct { CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` } +type participantMeetingStartedEvent struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` +} + func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - var envelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` - Event struct { - Meeting struct { - ID string `json:"id"` - Topic string `json:"topic"` - MeetingNo string `json:"meeting_no"` - StartTime string `json:"start_time"` - CalendarEventID string `json:"calendar_event_id"` - } `json:"meeting"` - } `json:"event"` - } - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + body, ok := decodeEventBody[participantMeetingStartedEvent](raw) + if !ok { + return nil, processing.DropMalformed(raw.EventType) } - meeting := envelope.Event.Meeting + meeting := body.Meeting out := &VCParticipantMeetingStartedOutput{ - Type: envelope.Header.EventType, - EventID: envelope.Header.EventID, - Timestamp: envelope.Header.CreateTime, + Type: raw.EventType, + EventID: raw.EventID, + Timestamp: raw.SourceTime, MeetingID: meeting.ID, Topic: meeting.Topic, MeetingNo: meeting.MeetingNo, StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), CalendarEventID: meeting.CalendarEventID, } - if out.Type == "" { - out.Type = raw.EventType - } return json.Marshal(out) } diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go index ce7e16f745..bc71393de2 100644 --- a/events/vc/preconsume.go +++ b/events/vc/preconsume.go @@ -5,33 +5,18 @@ package vc import ( "context" - "time" - "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" ) -const cleanupTimeout = 5 * time.Second - +// subscriptionPreConsume registers a VC event type with the server so this +// tenant starts receiving it, and hands back the matching unregister. +// +// Every VC EventKey subscribes this way: the subscription is per event type +// (not per meeting), so the first consumer of a key registers it and the last +// one to exit unregisters it. The register/unregister pair itself is shared +// with the other domains that follow the same OAPI convention. func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { - return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { - if rt == nil { - return nil, errs.NewInternalError(errs.SubtypeUnknown, - "runtime API client is required for pre-consume subscription") - } - - body := map[string]string{"event_type": eventType} - if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { - return nil, err - } - - return func() error { - cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { - return err - } - return nil - }, nil - } + return subscribeprep.Hook(eventType, subscribePath, unsubscribePath) } diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go index bc0a4e3c71..cb8b7694ae 100644 --- a/events/vc/recording_ended.go +++ b/events/vc/recording_ended.go @@ -6,10 +6,9 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1. @@ -21,64 +20,20 @@ type VCRecordingEndedOutput struct { Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` } -type recordingEndedEnvelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` - Event recordingEndedEvent `json:"event"` -} - -type recordingEndedEvent struct { - UniqueKey string `json:"unique_key"` - Source string `json:"source"` -} - func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - envelope, ok := parseRecordingEndedEnvelope(raw) + body, ok := decodeEventBody[recordingBeanEventBody](raw) if !ok { - return raw.Payload, nil + return nil, processing.DropMalformed(raw.EventType) } - if !isRecordingEndedBeanEvent(envelope) { + if body.Source != recordingBeanSource { return nil, nil } out := &VCRecordingEndedOutput{ - Type: recordingEndedEventType(envelope, raw), - EventID: envelope.Header.EventID, - EventTime: recordingEndedEventTime(envelope.Header.CreateTime), - UniqueKey: envelope.Event.UniqueKey, - Source: envelope.Event.Source, + Type: raw.EventType, + EventID: raw.EventID, + EventTime: millisToLocalRFC3339(raw.SourceTime), + UniqueKey: body.UniqueKey, + Source: body.Source, } return json.Marshal(out) } - -func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) { - var envelope recordingEndedEnvelope - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return nil, false - } - return &envelope, true -} - -func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool { - return envelope != nil && envelope.Event.Source == "recording_bean" -} - -func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string { - if envelope != nil && envelope.Header.EventType != "" { - return envelope.Header.EventType - } - return raw.EventType -} - -func recordingEndedEventTime(raw string) string { - if raw == "" { - return "" - } - millis, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return "" - } - return time.UnixMilli(millis).Local().Format(time.RFC3339) -} diff --git a/events/vc/recording_started.go b/events/vc/recording_started.go index 00d51caf03..90c6eea92a 100644 --- a/events/vc/recording_started.go +++ b/events/vc/recording_started.go @@ -6,10 +6,9 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1. @@ -21,64 +20,20 @@ type VCRecordingStartedOutput struct { Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` } -type recordingStartedEnvelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` - Event recordingStartedEvent `json:"event"` -} - -type recordingStartedEvent struct { - UniqueKey string `json:"unique_key"` - Source string `json:"source"` -} - func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - envelope, ok := parseRecordingStartedEnvelope(raw) + body, ok := decodeEventBody[recordingBeanEventBody](raw) if !ok { - return raw.Payload, nil + return nil, processing.DropMalformed(raw.EventType) } - if !isRecordingStartedBeanEvent(envelope) { + if body.Source != recordingBeanSource { return nil, nil } out := &VCRecordingStartedOutput{ - Type: recordingStartedEventType(envelope, raw), - EventID: envelope.Header.EventID, - EventTime: recordingStartedEventTime(envelope.Header.CreateTime), - UniqueKey: envelope.Event.UniqueKey, - Source: envelope.Event.Source, + Type: raw.EventType, + EventID: raw.EventID, + EventTime: millisToLocalRFC3339(raw.SourceTime), + UniqueKey: body.UniqueKey, + Source: body.Source, } return json.Marshal(out) } - -func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) { - var envelope recordingStartedEnvelope - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return nil, false - } - return &envelope, true -} - -func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool { - return envelope != nil && envelope.Event.Source == "recording_bean" -} - -func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string { - if envelope != nil && envelope.Header.EventType != "" { - return envelope.Header.EventType - } - return raw.EventType -} - -func recordingStartedEventTime(raw string) string { - if raw == "" { - return "" - } - millis, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return "" - } - return time.UnixMilli(millis).Local().Format(time.RFC3339) -} diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go index 89ba244870..af001d811f 100644 --- a/events/vc/recording_test.go +++ b/events/vc/recording_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) func TestVCKeys_RecordingEventsRegistered(t *testing.T) { @@ -25,7 +26,7 @@ func TestVCKeys_RecordingEventsRegistered(t *testing.T) { {eventTypeRecordingEnded}, } { t.Run(tc.eventType, func(t *testing.T) { - def, ok := event.Lookup(tc.eventType) + def, ok := lookupCompiledDef(t, tc.eventType) if !ok { t.Fatalf("%s should be registered via Keys()", tc.eventType) } @@ -351,7 +352,7 @@ func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) { } } -func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { +func TestProcessVCRecording_MalformedPayloadDrop(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) for _, tc := range []struct { @@ -370,11 +371,11 @@ func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { Timestamp: time.Now(), } got, err := tc.process(context.Background(), nil, raw, nil) - if err != nil { - t.Fatalf("Process should swallow parse errors, got %v", err) + if !processing.IsDropMalformed(err) { + t.Fatalf("malformed payload must be dropped with a malformed marker, got err=%v", err) } - if string(got) != "not json" { - t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + if got != nil { + t.Errorf("malformed payload must be dropped without output, got %q", string(got)) } }) } @@ -391,7 +392,7 @@ func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) { {eventTypeRecordingEnded}, } { t.Run(tc.eventType, func(t *testing.T) { - def, ok := event.Lookup(tc.eventType) + def, ok := lookupCompiledDef(t, tc.eventType) if !ok { t.Fatalf("%s should be registered via Keys()", tc.eventType) } @@ -456,6 +457,7 @@ func runRecordingProcessRaw(t *testing.T, eventType string, process event.Proces Payload: json.RawMessage(payload), Timestamp: time.Now(), } + fillCanonicalFromHeader(t, raw) got, err := process(context.Background(), nil, raw, nil) if err != nil { t.Fatalf("Process error: %v", err) diff --git a/events/vc/recording_transcript_generated.go b/events/vc/recording_transcript_generated.go index fe609bbff0..e9e990033d 100644 --- a/events/vc/recording_transcript_generated.go +++ b/events/vc/recording_transcript_generated.go @@ -6,10 +6,9 @@ package vc import ( "context" "encoding/json" - "strconv" - "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/processing" ) // VCRecordingTranscriptItemOutput is one flattened transcript item for recording events. @@ -31,15 +30,6 @@ type VCRecordingTranscriptGeneratedOutput struct { TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"` } -type recordingTranscriptGeneratedEnvelope struct { - Header struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - CreateTime string `json:"create_time"` - } `json:"header"` - Event recordingTranscriptGeneratedEvent `json:"event"` -} - type recordingTranscriptGeneratedEvent struct { UniqueKey string `json:"unique_key"` Source string `json:"source"` @@ -61,58 +51,24 @@ type recordingTranscriptGeneratedSpeakerIn struct { type recordingTranscriptGeneratedString string func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { - envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw) + body, ok := decodeEventBody[recordingTranscriptGeneratedEvent](raw) if !ok { - return raw.Payload, nil + return nil, processing.DropMalformed(raw.EventType) } - if !isRecordingTranscriptGeneratedBeanEvent(envelope) { + if body.Source != recordingBeanSource { return nil, nil } out := &VCRecordingTranscriptGeneratedOutput{ - Type: recordingTranscriptGeneratedEventType(envelope, raw), - EventID: envelope.Header.EventID, - EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime), - UniqueKey: envelope.Event.UniqueKey, - Source: envelope.Event.Source, - TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems), + Type: raw.EventType, + EventID: raw.EventID, + EventTime: millisToLocalRFC3339(raw.SourceTime), + UniqueKey: body.UniqueKey, + Source: body.Source, + TranscriptItems: recordingTranscriptItems(body.TranscriptItems), } return json.Marshal(out) } -func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) { - var envelope recordingTranscriptGeneratedEnvelope - if err := json.Unmarshal(raw.Payload, &envelope); err != nil { - return nil, false - } - return &envelope, true -} - -func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool { - return envelope != nil && envelope.Event.Source == "recording_bean" -} - -func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string { - if envelope != nil && envelope.Header.EventType != "" { - return envelope.Header.EventType - } - return raw.EventType -} - -func recordingTranscriptGeneratedEventTime(raw string) string { - return recordingTranscriptGeneratedMillisToLocalRFC3339(raw) -} - -func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string { - if raw == "" { - return "" - } - millis, err := strconv.ParseInt(raw, 10, 64) - if err != nil { - return "" - } - return time.UnixMilli(millis).Local().Format(time.RFC3339) -} - func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput { if len(items) == 0 { return nil @@ -128,8 +84,8 @@ func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordin return VCRecordingTranscriptItemOutput{ SpeakerName: recordingSpeakerName(item.Speaker), Text: item.Text, - StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()), - EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()), + StartTime: millisToLocalRFC3339(item.StartTimeMs.String()), + EndTime: millisToLocalRFC3339(item.EndTimeMs.String()), SentenceID: item.SentenceID, } } diff --git a/events/vc/test_helpers_test.go b/events/vc/test_helpers_test.go index 4d69d8e3af..b197bda322 100644 --- a/events/vc/test_helpers_test.go +++ b/events/vc/test_helpers_test.go @@ -8,8 +8,34 @@ import ( "encoding/json" "reflect" "testing" + + "github.com/larksuite/cli/internal/event" ) +// fillCanonicalFromHeader copies the payload envelope header metadata onto +// the RawEvent canonical fields. Process handlers read event_id and +// create_time from the RawEvent, which the consume pipeline fills from the +// envelope header before dispatch; tests that hand-build a RawEvent must +// mirror that so both views agree. +func fillCanonicalFromHeader(t *testing.T, raw *event.RawEvent) { + t.Helper() + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + t.Fatalf("parse envelope header: %v", err) + } + raw.EventID = envelope.Header.EventID + if envelope.Header.EventType != "" { + raw.EventType = envelope.Header.EventType + } + raw.SourceTime = envelope.Header.CreateTime +} + type stubAPIClient struct { callFn func(ctx context.Context, method, path string, body any) (json.RawMessage, error) } diff --git a/events/whiteboard/match.go b/events/whiteboard/match.go new file mode 100644 index 0000000000..bbb4b233ec --- /dev/null +++ b/events/whiteboard/match.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whiteboard + +import ( + "encoding/json" + + event "github.com/larksuite/cli/internal/event" +) + +// whiteboardIDMatch keeps a consumer's stream to the whiteboard it asked for. +// +// The server-side subscription is registered per whiteboard, but the local bus +// fans out by event type: once two whiteboards are subscribed, every consumer +// of this key receives both boards' events off the wire. Filtering here is +// what makes "subscription is per-whiteboard" true for the consumer's stdout +// as well; routing per whiteboard on the bus would additionally save the IPC +// hop, at the cost of teaching the bus about payload contents. +// +// An event whose whiteboard cannot be read — absent, not a string, or an +// undecodable payload — is dropped rather than delivered: a consumer that +// asked for one whiteboard must not be handed an event that cannot be +// attributed to it. Match has no diagnostic channel, so these drops are +// silent. +func whiteboardIDMatch(raw *event.RawEvent, params map[string]string) bool { + want := params["whiteboard_id"] + if want == "" { + return false + } + var envelope struct { + Event struct { + WhiteboardID string `json:"whiteboard_id"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return false + } + return envelope.Event.WhiteboardID == want +} diff --git a/events/whiteboard/match_test.go b/events/whiteboard/match_test.go new file mode 100644 index 0000000000..31c2bf21da --- /dev/null +++ b/events/whiteboard/match_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whiteboard + +import ( + "encoding/json" + "testing" + + event "github.com/larksuite/cli/internal/event" +) + +func whiteboardEvent(payload string) *event.RawEvent { + return &event.RawEvent{ + EventID: "evt-1", + EventType: eventTypeWhiteboardUpdated, + Payload: json.RawMessage(payload), + } +} + +func envelopeFor(whiteboardID string) string { + return `{"schema":"2.0","header":{"event_type":"` + eventTypeWhiteboardUpdated + + `"},"event":{"whiteboard_id":"` + whiteboardID + `","operator_ids":[]}}` +} + +// The filter must exist on the key, not just as a function: the server-side +// subscription is per whiteboard but the local bus fans out by event type, so +// a key without this filter hands every subscribed whiteboard's events to +// every consumer. +func TestWhiteboardKey_DeclaresTheBoardFilter(t *testing.T) { + defs := Keys() + if len(defs) != 1 { + t.Fatalf("expected exactly one whiteboard key, got %d", len(defs)) + } + if defs[0].Match == nil { + t.Fatal("the whiteboard key must declare Match; without it consumers of different boards see each other's events") + } +} + +// The pollution this closes: two consumers of different boards on one bus. +func TestWhiteboardMatch_KeepsEachConsumerToItsOwnBoard(t *testing.T) { + params := map[string]string{"whiteboard_id": "board-A"} + + if !whiteboardIDMatch(whiteboardEvent(envelopeFor("board-A")), params) { + t.Error("an event for the requested board must be delivered") + } + if whiteboardIDMatch(whiteboardEvent(envelopeFor("board-B")), params) { + t.Error("an event for another board must be dropped; delivering it is the cross-board pollution") + } +} + +// An event whose board cannot be read is dropped rather than delivered: a +// consumer that asked for one board must not be handed an event that cannot +// be attributed to it. +func TestWhiteboardMatch_DropsUnattributableEvents(t *testing.T) { + params := map[string]string{"whiteboard_id": "board-A"} + cases := map[string]string{ + "absent id": `{"schema":"2.0","event":{"operator_ids":[]}}`, + "id is not string": `{"schema":"2.0","event":{"whiteboard_id":42}}`, + "event not object": `{"schema":"2.0","event":"board-A"}`, + "payload not json": `definitely not json {{{`, + "payload not object": `["board-A"]`, + } + for name, payload := range cases { + t.Run(name, func(t *testing.T) { + if whiteboardIDMatch(whiteboardEvent(payload), params) { + t.Error("an event whose board cannot be read must be dropped") + } + }) + } +} + +// Defensive: the parameter is declared required and validated upstream, but an +// empty request must not degrade into "deliver everything". +func TestWhiteboardMatch_DropsWhenNoBoardWasRequested(t *testing.T) { + if whiteboardIDMatch(whiteboardEvent(envelopeFor("board-A")), map[string]string{}) { + t.Error("without a requested board there is nothing to attribute an event to; it must be dropped") + } +} diff --git a/events/whiteboard/preconsume.go b/events/whiteboard/preconsume.go index 02c27fe6be..d5ee2c08b0 100644 --- a/events/whiteboard/preconsume.go +++ b/events/whiteboard/preconsume.go @@ -6,17 +6,13 @@ package whiteboard import ( "context" "fmt" - "time" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/events/internal/subscribeprep" "github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/validate" ) -// cleanupTimeout bounds how long the unsubscribe call has to finish during -// PreConsume cleanup so a stuck OAPI cannot block process shutdown. -const cleanupTimeout = 5 * time.Second - // whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI // and returns a cleanup that invokes the matching unsubscribe. // @@ -39,18 +35,6 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded) unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded) - body := map[string]string{"event_type": eventType} - if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil { - return nil, err - } - - return func() error { - cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) - defer cancel() - if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { - return err - } - return nil - }, nil + return subscribeprep.SubscribeWithCleanup(ctx, rt, eventType, subscribePath, unsubscribePath) } } diff --git a/events/whiteboard/register.go b/events/whiteboard/register.go index 7e81ec053b..7dc508151a 100644 --- a/events/whiteboard/register.go +++ b/events/whiteboard/register.go @@ -24,10 +24,15 @@ func Keys() []event.KeyDefinition { EventType: eventTypeWhiteboardUpdated, Params: []event.ParamDef{ { - Name: "whiteboard_id", - Type: event.ParamString, - Required: true, - Description: "Whiteboard id to subscribe; subscription is per-whiteboard.", + Name: "whiteboard_id", + Type: event.ParamString, + Required: true, + // The server-side subscription is keyed per whiteboard, so + // the id must be part of the consumer's subscription + // identity: consumers of different whiteboards get their + // own setup/cleanup lifecycle instead of sharing one. + SubscriptionKey: true, + Description: "Whiteboard id to subscribe; subscription is per-whiteboard.", }, }, Schema: event.SchemaDef{ @@ -39,6 +44,7 @@ func Keys() []event.KeyDefinition { "/event/operator_ids/*/user_id": {Kind: "user_id"}, }, }, + Match: whiteboardIDMatch, PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated), Scopes: []string{"board:whiteboard:node:read"}, AuthTypes: []string{"user", "bot"}, diff --git a/events/whiteboard/subscription_scope_test.go b/events/whiteboard/subscription_scope_test.go new file mode 100644 index 0000000000..40c6f39772 --- /dev/null +++ b/events/whiteboard/subscription_scope_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whiteboard + +import ( + "testing" + + event "github.com/larksuite/cli/internal/event" +) + +// The whiteboard subscription is registered per whiteboard on the server, so +// the whiteboard id must take part in the consumer's subscription identity. +// Without it, two consumers of different whiteboards share one scope: the +// second consumer's setup never runs (its whiteboard is never subscribed) and +// whichever exits last unsubscribes the other one's still-active board. +func TestWhiteboardID_IsPartOfSubscriptionIdentity(t *testing.T) { + defs := Keys() + if len(defs) != 1 { + t.Fatalf("expected exactly one whiteboard key, got %d", len(defs)) + } + def := defs[0] + + var found *event.ParamDef + for i := range def.Params { + if def.Params[i].Name == "whiteboard_id" { + found = &def.Params[i] + } + } + if found == nil { + t.Fatal("whiteboard_id param is missing") + } + if !found.SubscriptionKey { + t.Error("whiteboard_id must be a subscription key: the server-side subscription is per-whiteboard") + } +} diff --git a/internal/event/source/feishu.go b/internal/event/adapter/lark/websocket/feishu.go similarity index 85% rename from internal/event/source/feishu.go rename to internal/event/adapter/lark/websocket/feishu.go index db0c8a1c60..3c92c7afca 100644 --- a/internal/event/source/feishu.go +++ b/internal/event/adapter/lark/websocket/feishu.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" @@ -18,7 +18,6 @@ import ( larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" ) const maxEventBodyBytes = 1 << 20 // bound per-subscriber sendCh memory under runaway payloads @@ -51,7 +50,7 @@ func (s *FeishuSource) Start(ctx context.Context, eventTypes []string, emit func } if notify != nil { - notify(protocol.SourceStateConnecting, "") + notify(sourceStateConnecting, "") } cli := larkws.NewClient(s.AppID, s.AppSecret, opts...) @@ -83,6 +82,8 @@ func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context. EventID string `json:"event_id"` EventType string `json:"event_type"` CreateTime string `json:"create_time"` + AppID string `json:"app_id"` + TenantKey string `json:"tenant_key"` } `json:"header"` } if err := json.Unmarshal(e.Body, &envelope); err != nil { @@ -106,6 +107,8 @@ func (s *FeishuSource) buildRawHandler(emit func(*event.RawEvent)) func(context. EventID: envelope.Header.EventID, EventType: envelope.Header.EventType, SourceTime: envelope.Header.CreateTime, + AppID: envelope.Header.AppID, + TenantKey: envelope.Header.TenantKey, Payload: json.RawMessage(e.Body), Timestamp: time.Now(), }) @@ -157,12 +160,22 @@ func (a *sdkLogger) tryNotify(msg, errDetail string) { if m := reconnectAttemptRe.FindStringSubmatch(lower); len(m) == 2 { detail = "attempt " + m[1] } - a.notify(protocol.SourceStateReconnecting, detail) + a.notify(sourceStateReconnecting, detail) case strings.HasPrefix(lower, sdkLogDisconnected): - a.notify(protocol.SourceStateDisconnected, errDetail) + a.notify(sourceStateDisconnected, errDetail) case strings.HasPrefix(lower, sdkLogConnected): - a.notify(protocol.SourceStateConnected, "") + a.notify(sourceStateConnected, "") } } var _ larkcore.Logger = (*sdkLogger)(nil) + +// Source lifecycle states as this adapter reports them. The values are the +// wire vocabulary of the bus's source_status frames; a pinning test keeps +// them equal to the IPC constants without importing the IPC package here. +const ( + sourceStateConnecting = "connecting" + sourceStateConnected = "connected" + sourceStateDisconnected = "disconnected" + sourceStateReconnecting = "reconnecting" +) diff --git a/internal/event/adapter/lark/websocket/feishu_ingress_test.go b/internal/event/adapter/lark/websocket/feishu_ingress_test.go new file mode 100644 index 0000000000..49c9e4ce6c --- /dev/null +++ b/internal/event/adapter/lark/websocket/feishu_ingress_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "context" + "testing" + + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" + + event "github.com/larksuite/cli/internal/event" +) + +// The websocket ingress is the only place that parses the envelope header; +// every canonical fact consumers rely on must be captured here, once. +func TestBuildRawHandler_ParsesCanonicalHeaderOnce(t *testing.T) { + s := &FeishuSource{} + var got *event.RawEvent + handler := s.buildRawHandler(func(ev *event.RawEvent) { got = ev }) + + body := []byte(`{"schema":"2.0","header":{"event_id":"evt-1","event_type":"im.message.receive_v1",` + + `"create_time":"1700000000000","app_id":"cli_test_app","tenant_key":"tenant_test"},"event":{}}`) + if err := handler(context.Background(), &larkevent.EventReq{Body: body}); err != nil { + t.Fatalf("handler: %v", err) + } + if got == nil { + t.Fatal("event was not emitted") + } + if got.EventID != "evt-1" || got.EventType != "im.message.receive_v1" { + t.Errorf("identity facts wrong: id=%q type=%q", got.EventID, got.EventType) + } + if got.SourceTime != "1700000000000" { + t.Errorf("SourceTime = %q, want upstream create_time", got.SourceTime) + } + if got.AppID != "cli_test_app" || got.TenantKey != "tenant_test" { + t.Errorf("tenant identity not captured: app_id=%q tenant_key=%q", got.AppID, got.TenantKey) + } + if got.Timestamp.IsZero() { + t.Error("local observation Timestamp must be set at ingress") + } +} + +// A header that omits optional facts leaves them visibly empty — the ingress +// never substitutes local configuration for missing upstream facts. +func TestBuildRawHandler_MissingOptionalFactsStayEmpty(t *testing.T) { + s := &FeishuSource{} + var got *event.RawEvent + handler := s.buildRawHandler(func(ev *event.RawEvent) { got = ev }) + + body := []byte(`{"schema":"2.0","header":{"event_id":"evt-2","event_type":"im.message.receive_v1"},"event":{}}`) + if err := handler(context.Background(), &larkevent.EventReq{Body: body}); err != nil { + t.Fatalf("handler: %v", err) + } + if got == nil { + t.Fatal("event was not emitted") + } + if got.SourceTime != "" || got.AppID != "" || got.TenantKey != "" { + t.Errorf("missing facts must stay empty: source_time=%q app_id=%q tenant_key=%q", + got.SourceTime, got.AppID, got.TenantKey) + } +} diff --git a/internal/event/source/feishu_log_test.go b/internal/event/adapter/lark/websocket/feishu_log_test.go similarity index 99% rename from internal/event/source/feishu_log_test.go rename to internal/event/adapter/lark/websocket/feishu_log_test.go index 3ddffeb960..b2c6443149 100644 --- a/internal/event/source/feishu_log_test.go +++ b/internal/event/adapter/lark/websocket/feishu_log_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "bytes" diff --git a/internal/event/source/feishu_test.go b/internal/event/adapter/lark/websocket/feishu_test.go similarity index 97% rename from internal/event/source/feishu_test.go rename to internal/event/adapter/lark/websocket/feishu_test.go index dab62ff06c..2e9d3f266f 100644 --- a/internal/event/source/feishu_test.go +++ b/internal/event/adapter/lark/websocket/feishu_test.go @@ -1,12 +1,12 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "testing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // "disconnected to " contains "connected to ws" — must use HasPrefix to avoid misclassifying as connect. diff --git a/internal/event/adapter/lark/websocket/payload_budget_test.go b/internal/event/adapter/lark/websocket/payload_budget_test.go new file mode 100644 index 0000000000..58bd01d6fe --- /dev/null +++ b/internal/event/adapter/lark/websocket/payload_budget_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "testing" + + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" +) + +// The ingress cap and the local wire format's payload budget have to agree. +// They are declared separately on purpose — the platform ingress does not +// depend on how events are framed locally, so swapping the local transport +// leaves it untouched — which means nothing but this test keeps them in step. +// +// When they drifted apart, the ingress accepted payloads the bus could frame +// but the consumer refused to read, and the event was lost with a warning. +func TestPayloadBudget_MatchesTheWireFormat(t *testing.T) { + if maxEventBodyBytes > protocol.MaxEventPayloadBytes { + t.Errorf("the ingress accepts up to %d bytes but the wire format promises only %d; every event in between is accepted and then dropped", + maxEventBodyBytes, protocol.MaxEventPayloadBytes) + } + if maxEventBodyBytes < protocol.MaxEventPayloadBytes { + t.Errorf("the ingress accepts only %d bytes while the wire format allows %d; the difference is capacity thrown away silently", + maxEventBodyBytes, protocol.MaxEventPayloadBytes) + } +} diff --git a/internal/event/source/sdk_log_patterns.go b/internal/event/adapter/lark/websocket/sdk_log_patterns.go similarity index 94% rename from internal/event/source/sdk_log_patterns.go rename to internal/event/adapter/lark/websocket/sdk_log_patterns.go index ae54f859dd..6fcb2638ca 100644 --- a/internal/event/source/sdk_log_patterns.go +++ b/internal/event/adapter/lark/websocket/sdk_log_patterns.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket // DO NOT trim trailing spaces — the HasPrefix disambiguator depends on them. const ( diff --git a/internal/event/source/sdk_log_patterns_test.go b/internal/event/adapter/lark/websocket/sdk_log_patterns_test.go similarity index 97% rename from internal/event/source/sdk_log_patterns_test.go rename to internal/event/adapter/lark/websocket/sdk_log_patterns_test.go index 069ce35861..05b83f05f4 100644 --- a/internal/event/source/sdk_log_patterns_test.go +++ b/internal/event/adapter/lark/websocket/sdk_log_patterns_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" @@ -9,7 +9,7 @@ import ( "sync" "testing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // Samples preserve the real SDK shape (" to [conn_id=...]" — no space before bracket). diff --git a/internal/event/adapter/lark/websocket/source.go b/internal/event/adapter/lark/websocket/source.go new file mode 100644 index 0000000000..dabeb490aa --- /dev/null +++ b/internal/event/adapter/lark/websocket/source.go @@ -0,0 +1,12 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package websocket adapts the platform WebSocket connection into a pluggable +// event source (separate package to keep business registrations free of SDK +// transitive deps). +package websocket + +// StatusNotifier surfaces source lifecycle states; detail is free-form +// context. A function alias so implementations structurally satisfy the +// bus-side Source port without importing it. +type StatusNotifier = func(state, detail string) diff --git a/internal/event/source/source_test.go b/internal/event/adapter/lark/websocket/source_test.go similarity index 73% rename from internal/event/source/source_test.go rename to internal/event/adapter/lark/websocket/source_test.go index 37c41c3084..7ba0cb557a 100644 --- a/internal/event/source/source_test.go +++ b/internal/event/adapter/lark/websocket/source_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package source +package websocket import ( "context" @@ -12,11 +12,9 @@ import ( ) type mockSource struct { - name string events []*event.RawEvent } -func (s *mockSource) Name() string { return s.name } func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ StatusNotifier) error { for _, e := range s.events { emit(e) @@ -25,21 +23,8 @@ func (s *mockSource) Start(ctx context.Context, _ []string, emit func(*event.Raw return nil } -func TestRegister(t *testing.T) { - ResetForTest() - - src := &mockSource{name: "test-source"} - Register(src) - - sources := All() - if len(sources) != 1 || sources[0].Name() != "test-source" { - t.Errorf("unexpected sources: %v", sources) - } -} - func TestMockSource_EmitsEvents(t *testing.T) { src := &mockSource{ - name: "test", events: []*event.RawEvent{ {EventID: "1", EventType: "im.message.receive_v1"}, {EventID: "2", EventType: "im.message.receive_v1"}, diff --git a/internal/event/adapter/lark/websocket/state_pinning_test.go b/internal/event/adapter/lark/websocket/state_pinning_test.go new file mode 100644 index 0000000000..c925d6e658 --- /dev/null +++ b/internal/event/adapter/lark/websocket/state_pinning_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package websocket + +import ( + "testing" + + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/bus" +) + +// The adapter reports source states with its own constants so it never +// imports the IPC package; this test pins all three vocabularies (adapter, +// bus port, IPC frame) to the same wire values. +func TestSourceStates_MatchTheWireVocabulary(t *testing.T) { + pins := []struct{ adapter, port, wire string }{ + {sourceStateConnecting, bus.SourceStateConnecting, protocol.SourceStateConnecting}, + {sourceStateConnected, bus.SourceStateConnected, protocol.SourceStateConnected}, + {sourceStateDisconnected, bus.SourceStateDisconnected, protocol.SourceStateDisconnected}, + {sourceStateReconnecting, bus.SourceStateReconnecting, protocol.SourceStateReconnecting}, + } + for _, pin := range pins { + if pin.adapter != pin.port || pin.port != pin.wire { + t.Errorf("state vocabulary drifted: adapter=%q port=%q wire=%q", pin.adapter, pin.port, pin.wire) + } + } +} diff --git a/internal/event/busctl/busctl.go b/internal/event/adapter/localbus/busctl/busctl.go similarity index 90% rename from internal/event/busctl/busctl.go rename to internal/event/adapter/localbus/busctl/busctl.go index 6c4271a94b..45d27e4104 100644 --- a/internal/event/busctl/busctl.go +++ b/internal/event/adapter/localbus/busctl/busctl.go @@ -10,8 +10,8 @@ import ( "fmt" "time" - "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) const readTimeout = 5 * time.Second // matches protocol.WriteTimeout diff --git a/internal/event/busdiscover/busdiscover.go b/internal/event/adapter/localbus/busdiscover/busdiscover.go similarity index 100% rename from internal/event/busdiscover/busdiscover.go rename to internal/event/adapter/localbus/busdiscover/busdiscover.go diff --git a/internal/event/busdiscover/pidfile.go b/internal/event/adapter/localbus/busdiscover/pidfile.go similarity index 100% rename from internal/event/busdiscover/pidfile.go rename to internal/event/adapter/localbus/busdiscover/pidfile.go diff --git a/internal/event/busdiscover/pidfile_test.go b/internal/event/adapter/localbus/busdiscover/pidfile_test.go similarity index 100% rename from internal/event/busdiscover/pidfile_test.go rename to internal/event/adapter/localbus/busdiscover/pidfile_test.go diff --git a/internal/event/adapter/localbus/protocol/canonical_fields_test.go b/internal/event/adapter/localbus/protocol/canonical_fields_test.go new file mode 100644 index 0000000000..18b983aa97 --- /dev/null +++ b/internal/event/adapter/localbus/protocol/canonical_fields_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package protocol + +import ( + "bufio" + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/larksuite/cli/internal/event/model" +) + +// Every canonical fact the ingress parsed must survive the wire round trip +// verbatim — the consumer restores the event from this frame instead of +// re-deriving anything from the payload. +func TestEventFrame_CarriesCanonicalFactsVerbatim(t *testing.T) { + observed := time.Date(2023, 11, 14, 22, 13, 20, 123456789, time.UTC) + ev := &model.Event{ + EventID: "evt-42", + EventType: "im.message.receive_v1", + SourceTime: "1700000000000", + AppID: "cli_test_app", + TenantKey: "tenant_test", + Payload: json.RawMessage(`{"schema":"2.0"}`), + Timestamp: observed, + } + + var buf bytes.Buffer + if err := Encode(&buf, NewEvent(ev, 7)); err != nil { + t.Fatalf("encode: %v", err) + } + line, err := ReadFrame(bufio.NewReader(&buf)) + if err != nil { + t.Fatalf("read frame: %v", err) + } + decoded, err := Decode(line) + if err != nil { + t.Fatalf("decode: %v", err) + } + frame, ok := decoded.(*Event) + if !ok { + t.Fatalf("decoded %T, want *Event", decoded) + } + + if frame.EventID != ev.EventID || frame.EventType != ev.EventType || + frame.SourceTime != ev.SourceTime || frame.AppID != ev.AppID || + frame.TenantKey != ev.TenantKey || frame.Seq != 7 { + t.Errorf("canonical facts drifted across the wire: %+v", frame) + } + if !bytes.Equal(frame.Payload, ev.Payload) { + t.Errorf("payload drifted across the wire: got %s, want %s", frame.Payload, ev.Payload) + } + // observed_at is a fixed RFC3339Nano string contract, not an incidental + // time.Time marshal shape. + parsed, err := time.Parse(time.RFC3339Nano, frame.ObservedAt) + if err != nil { + t.Fatalf("observed_at %q is not RFC3339Nano: %v", frame.ObservedAt, err) + } + if !parsed.Equal(observed) { + t.Errorf("observed_at: got %v, want %v", parsed, observed) + } +} + +// Facts the upstream omitted stay omitted on the wire: the frame never invents +// values, and absent facts must not even appear as empty strings. +func TestEventFrame_MissingFactsStayAbsent(t *testing.T) { + ev := &model.Event{ + EventType: "im.message.receive_v1", + EventID: "evt-1", + Payload: json.RawMessage(`{}`), + } + + raw, err := json.Marshal(NewEvent(ev, 1)) + if err != nil { + t.Fatal(err) + } + var asMap map[string]json.RawMessage + if err := json.Unmarshal(raw, &asMap); err != nil { + t.Fatal(err) + } + for _, absent := range []string{"source_time", "app_id", "tenant_key", "observed_at"} { + if _, present := asMap[absent]; present { + t.Errorf("field %q must be omitted when the fact is missing, frame: %s", absent, raw) + } + } +} diff --git a/internal/event/protocol/codec.go b/internal/event/adapter/localbus/protocol/codec.go similarity index 72% rename from internal/event/protocol/codec.go rename to internal/event/adapter/localbus/protocol/codec.go index 7afb65c0bb..be792802de 100644 --- a/internal/event/protocol/codec.go +++ b/internal/event/adapter/localbus/protocol/codec.go @@ -14,7 +14,24 @@ import ( "time" ) -const MaxFrameBytes = 1 << 20 // reject larger frames to bound reader buffer growth +// MaxEventPayloadBytes is the largest event body this wire format promises to +// relay. The ingress caps what it accepts at the same number; a boundary test +// keeps the two in step, since the ingress deliberately does not import this +// package. +const MaxEventPayloadBytes = 1 << 20 + +// maxFrameOverheadBytes is the room a frame gets on top of its payload for the +// canonical metadata and JSON punctuation around it. Worst-case realistic +// metadata measures a few hundred bytes, so this is generous on purpose: a +// frame limit that merely equalled the payload limit would make the top of the +// accepted payload range undeliverable, which is how an accepted event turned +// into a dropped one. +const maxFrameOverheadBytes = 4 << 10 + +// MaxFrameBytes bounds reader buffer growth. It must stay above +// MaxEventPayloadBytes, or the bus can frame an event the consumer then refuses +// to read. +const MaxFrameBytes = MaxEventPayloadBytes + maxFrameOverheadBytes // ErrFrameTooLarge is returned by ReadFrame when a single frame exceeds MaxFrameBytes. var ErrFrameTooLarge = errors.New("protocol: frame exceeds MaxFrameBytes") diff --git a/internal/event/protocol/codec_test.go b/internal/event/adapter/localbus/protocol/codec_test.go similarity index 100% rename from internal/event/protocol/codec_test.go rename to internal/event/adapter/localbus/protocol/codec_test.go diff --git a/internal/event/adapter/localbus/protocol/frame_budget_test.go b/internal/event/adapter/localbus/protocol/frame_budget_test.go new file mode 100644 index 0000000000..a22f0529c1 --- /dev/null +++ b/internal/event/adapter/localbus/protocol/frame_budget_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package protocol + +import ( + "bufio" + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/event/model" +) + +// A payload the ingress accepts must survive framing. The two limits used to be +// the same number, which left the top of the accepted payload range framing +// into something the consumer refused to read: the bus wrote it, the consumer +// dropped it, and the event was lost with only a warning to show for it. +func TestFrameBudget_LargestAcceptedPayloadStillFits(t *testing.T) { + if MaxFrameBytes <= MaxEventPayloadBytes { + t.Fatalf("MaxFrameBytes (%d) must exceed MaxEventPayloadBytes (%d), otherwise a full-size payload cannot be framed", + MaxFrameBytes, MaxEventPayloadBytes) + } + + // A payload of exactly the accepted maximum, carrying long but realistic + // canonical metadata: a long event type, a long event id, a tenant key and + // the widest possible seq. + body := `{"text":"` + strings.Repeat("x", MaxEventPayloadBytes-len(`{"text":""}`)) + `"}` + if len(body) != MaxEventPayloadBytes { + t.Fatalf("test payload is %d bytes, want exactly %d", len(body), MaxEventPayloadBytes) + } + + ev := &model.Event{ + EventID: "c2f8a1e0-3b4d-4f6a-9c8e-7d5b1a2f3e4c-0123456789", + EventType: "vc.recording.recording_transcript_generated_v1", + SourceTime: "1700000000000", + AppID: "cli_FAKEFAKEFAKEFAKE", + TenantKey: "TENANT_FAKE_FOR_FRAME_SIZE_TESTS", + Payload: json.RawMessage(body), + Timestamp: time.Date(2026, 8, 4, 12, 34, 56, 123456789, time.UTC), + } + + var buf bytes.Buffer + if err := Encode(&buf, NewEvent(ev, 1<<64-1)); err != nil { + t.Fatalf("encode: %v", err) + } + // Encode appends the delimiter, so the buffer is the whole frame as it goes + // on the wire — which is what the reader measures. + if buf.Len() > MaxFrameBytes { + t.Errorf("a full-size payload framed to %d bytes, over the %d limit; raise maxFrameOverheadBytes", + buf.Len(), MaxFrameBytes) + } + + // The frame must also come back out, not just fit: this is the read path the + // consumer uses. + line, err := ReadFrame(bufio.NewReaderSize(bytes.NewReader(buf.Bytes()), MaxFrameBytes+1)) + if err != nil { + t.Fatalf("a full-size frame must be readable, got: %v", err) + } + decoded, err := Decode(bytes.TrimRight(line, "\n")) + if err != nil { + t.Fatalf("decode a full-size frame: %v", err) + } + frame, ok := decoded.(*Event) + if !ok { + t.Fatalf("decoded %T, want *Event", decoded) + } + if !bytes.Equal(frame.Payload, json.RawMessage(body)) { + t.Error("a full-size payload must round trip unchanged") + } +} + +// The overhead allowance has to be more than decoration: it must cover the +// metadata a frame actually carries, with the payload budget left over. +func TestFrameBudget_OverheadCoversCanonicalMetadata(t *testing.T) { + ev := &model.Event{ + EventID: "c2f8a1e0-3b4d-4f6a-9c8e-7d5b1a2f3e4c-0123456789", + EventType: "vc.recording.recording_transcript_generated_v1", + SourceTime: "1700000000000", + AppID: "cli_FAKEFAKEFAKEFAKE", + TenantKey: "TENANT_FAKE_FOR_FRAME_SIZE_TESTS", + Payload: json.RawMessage(`{}`), + Timestamp: time.Date(2026, 8, 4, 12, 34, 56, 123456789, time.UTC), + } + var buf bytes.Buffer + if err := Encode(&buf, NewEvent(ev, 1<<64-1)); err != nil { + t.Fatalf("encode: %v", err) + } + overhead := buf.Len() - len(`{}`) + if overhead > maxFrameOverheadBytes { + t.Errorf("frame metadata measures %d bytes, over the %d allowance", overhead, maxFrameOverheadBytes) + } +} diff --git a/internal/event/protocol/messages.go b/internal/event/adapter/localbus/protocol/messages.go similarity index 64% rename from internal/event/protocol/messages.go rename to internal/event/adapter/localbus/protocol/messages.go index afab7bbe91..afc7f78396 100644 --- a/internal/event/protocol/messages.go +++ b/internal/event/adapter/localbus/protocol/messages.go @@ -3,7 +3,12 @@ package protocol -import "encoding/json" +import ( + "encoding/json" + "time" + + "github.com/larksuite/cli/internal/event/model" +) const ( MsgTypeHello = "hello" @@ -42,20 +47,38 @@ type Hello struct { SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side } +// CapabilityCanonicalMetadataV1 declares that every event frame this bus +// publishes carries the full canonical metadata set (event id, source time, +// tenant identity, observation time). Consumers that depend on those facts +// verify the capability on the delivery connection's ack and refuse to attach +// to a bus that cannot provide them. +const CapabilityCanonicalMetadataV1 = "canonical_metadata_v1" + 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"` + // Capabilities is additive: an older bus simply never sends it, which is + // exactly the signal consumers use to reject the attach. + Capabilities []string `json:"capabilities,omitempty"` } // Event: Seq is per-conn monotonic; gaps signal bus drop-oldest backpressure loss. +// The frame carries every canonical fact the ingress parsed — consumers restore +// them verbatim instead of re-deriving anything from the payload. All fields +// beyond the original set are additive so older peers ignore them. type Event struct { - Type string `json:"type"` - EventType string `json:"event_type"` - EventID string `json:"event_id,omitempty"` - SourceTime string `json:"source_time,omitempty"` // ms-precision unix timestamp, stringified + Type string `json:"type"` + EventType string `json:"event_type"` + EventID string `json:"event_id,omitempty"` + SourceTime string `json:"source_time,omitempty"` // upstream create_time verbatim; empty when the upstream omitted it + AppID string `json:"app_id,omitempty"` + TenantKey string `json:"tenant_key,omitempty"` + // ObservedAt is the ingress observation clock in RFC3339Nano — a fixed + // string contract on the wire, not whatever time.Time happens to marshal to. + ObservedAt string `json:"observed_at,omitempty"` Seq uint64 `json:"seq,omitempty"` Payload json.RawMessage `json:"payload"` } @@ -111,11 +134,12 @@ func NewHello(pid int, eventKey string, eventTypes []string, version string, sub } } -func NewHelloAck(busVersion string, firstForKey bool) *HelloAck { +func NewHelloAck(busVersion string, firstForKey bool, capabilities ...string) *HelloAck { return &HelloAck{ - Type: MsgTypeHelloAck, - BusVersion: busVersion, - FirstForKey: firstForKey, + Type: MsgTypeHelloAck, + BusVersion: busVersion, + FirstForKey: firstForKey, + Capabilities: capabilities, } } @@ -130,14 +154,27 @@ func NewHelloAckRejected(busVersion, reason string) *HelloAck { } } -func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) *Event { +// NewEvent projects the canonical event onto the wire frame verbatim. It is +// the only Event constructor on purpose: every fact travels or is visibly +// absent — nothing is defaulted, substituted, or dropped here. +func NewEvent(ev *model.Event, seq uint64) *Event { + observedAt := "" + if !ev.Timestamp.IsZero() { + // UTC-normalized so the wire never carries the emitting host's local + // offset; consumers parse RFC3339Nano either way, but the frame bytes + // should not depend on where the bus happens to run. + observedAt = ev.Timestamp.UTC().Format(time.RFC3339Nano) + } return &Event{ Type: MsgTypeEvent, - EventType: eventType, - EventID: eventID, - SourceTime: sourceTime, + EventType: ev.EventType, + EventID: ev.EventID, + SourceTime: ev.SourceTime, + AppID: ev.AppID, + TenantKey: ev.TenantKey, + ObservedAt: observedAt, Seq: seq, - Payload: payload, + Payload: ev.Payload, } } diff --git a/internal/event/protocol/messages_test.go b/internal/event/adapter/localbus/protocol/messages_test.go similarity index 95% rename from internal/event/protocol/messages_test.go rename to internal/event/adapter/localbus/protocol/messages_test.go index 29d00d02a1..ea090ac0fc 100644 --- a/internal/event/protocol/messages_test.go +++ b/internal/event/adapter/localbus/protocol/messages_test.go @@ -13,6 +13,8 @@ import ( "net" "testing" "time" + + "github.com/larksuite/cli/internal/event/model" ) // Every NewXxx helper must set the Type discriminator (Decode rejects messages without it). @@ -23,7 +25,7 @@ func TestConstructors_PinTypeField(t *testing.T) { if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey { t.Errorf("NewHelloAck mismatch: %+v", got) } - if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 { + if got := NewEvent(&model.Event{EventType: "im.msg", EventID: "e1", Payload: json.RawMessage(`{}`)}, 7); got.Type != MsgTypeEvent || got.Seq != 7 { t.Errorf("NewEvent mismatch: %+v", got) } if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" { diff --git a/internal/event/transport/transport.go b/internal/event/adapter/localbus/transport/transport.go similarity index 100% rename from internal/event/transport/transport.go rename to internal/event/adapter/localbus/transport/transport.go diff --git a/internal/event/transport/transport_test.go b/internal/event/adapter/localbus/transport/transport_test.go similarity index 100% rename from internal/event/transport/transport_test.go rename to internal/event/adapter/localbus/transport/transport_test.go diff --git a/internal/event/transport/transport_unix.go b/internal/event/adapter/localbus/transport/transport_unix.go similarity index 100% rename from internal/event/transport/transport_unix.go rename to internal/event/adapter/localbus/transport/transport_unix.go diff --git a/internal/event/transport/transport_windows.go b/internal/event/adapter/localbus/transport/transport_windows.go similarity index 100% rename from internal/event/transport/transport_windows.go rename to internal/event/adapter/localbus/transport/transport_windows.go diff --git a/internal/event/application/consume/decision.go b/internal/event/application/consume/decision.go new file mode 100644 index 0000000000..1aa0654913 --- /dev/null +++ b/internal/event/application/consume/decision.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package consume is the consume use case: it turns a request plus a compiled +// catalog entry into one immutable decision, renders that decision for +// dry-run, and executes the very same decision for a real run. Deciding is +// free of external writes; every write happens behind Execute. +package consume + +import ( + "errors" + "maps" + "slices" + "time" + + "github.com/larksuite/cli/errs" +) + +// Request carries the caller's consume inputs, already parsed from flags. +type Request struct { + EventKey string + Params map[string]string + JQExpr string + OutputDir string + DryRun bool + MaxEvents int + Timeout time.Duration + IsTTY bool +} + +type PreconditionStatus string + +const ( + // PreconditionOK: the read-only check passed. + PreconditionOK PreconditionStatus = "ok" + // PreconditionUnknown: a weak dependency could not answer. Real execution + // proceeds (matching the long-standing degrade-and-continue behavior); + // dry-run reports the fact instead of pretending readiness. + PreconditionUnknown PreconditionStatus = "unknown" + // PreconditionBlocked: the check found a state that makes a real run + // refuse to start. Execution returns the blocking error; dry-run renders it. + PreconditionBlocked PreconditionStatus = "blocked" +) + +// Precondition is one read-only preflight finding. BlockErr carries the exact +// error a real run would return, so the refusal is identical whether or not a +// decision was rendered first. +type Precondition struct { + Name string + Status PreconditionStatus + Detail string + BlockErr error +} + +// Decision is the single-step consume decision: the classified result of one +// request against one compiled entry. Fields are unexported and deep-copied +// at construction; renderers read it through View. +type Decision struct { + eventKey string + domain string + identity string + status string + params map[string]string + scope string + preconditions []Precondition + preparation *PreparationDecision + wouldRead []string + wouldWrite []string + blockErr error +} + +const ( + StatusReady = "ready" + StatusUnknown = "unknown" + StatusBlocked = "blocked" +) + +// View returns a deep-copied, exported view of the decision — the only way +// renderers and other packages read it. Mutating the view never touches the +// decision. +func (d *Decision) View() DecisionView { + v := DecisionView{ + EventKey: d.eventKey, + Domain: d.domain, + Identity: d.identity, + Status: d.status, + Params: maps.Clone(d.params), + Scope: d.scope, + WouldRead: slices.Clone(d.wouldRead), + WouldWrite: slices.Clone(d.wouldWrite), + } + for _, p := range d.preconditions { + pv := PreconditionView{Name: p.Name, Status: string(p.Status), Detail: p.Detail} + // A blocking error already carries the classification and the recovery + // action; a preview that dropped them would be the one surface with no + // way forward, even though it exists to be read before acting. + if problem, ok := errs.ProblemOf(p.BlockErr); ok { + pv.Subtype = string(problem.Subtype) + pv.Hint = problem.Hint + } + var permission *errs.PermissionError + if errors.As(p.BlockErr, &permission) { + pv.MissingScopes = slices.Clone(permission.MissingScopes) + } + v.Preconditions = append(v.Preconditions, pv) + } + if d.preparation != nil { + v.Preparation = &PreparationView{ + Strategy: string(d.preparation.Strategy), + Condition: d.preparation.Condition, + Action: d.preparation.Action, + } + } + return v +} + +// DecisionView is the exported render model of a Decision. +type DecisionView struct { + EventKey string + Domain string + Identity string + Status string + Params map[string]string + Scope string + Preconditions []PreconditionView + Preparation *PreparationView + WouldRead []string + WouldWrite []string +} + +// PreconditionView is the render model of one precondition. Beyond the human +// sentence in Detail it carries the machine-readable half of the failure, so a +// caller previewing a consume gets the same recovery information a real run +// would put in its error envelope instead of having to parse prose. +type PreconditionView struct { + Name string + Status string + Detail string + // Subtype classifies the failure the way the error envelope does, which is + // what callers are told to branch on. + Subtype string + // Hint is the recovery action, verbatim from the error that blocked. + Hint string + // MissingScopes lists the scopes to grant, when that is what is missing. + MissingScopes []string +} + +type PreparationView struct { + Strategy string + Condition string + Action string +} diff --git a/internal/event/application/consume/service.go b/internal/event/application/consume/service.go new file mode 100644 index 0000000000..779ff3c632 --- /dev/null +++ b/internal/event/application/consume/service.go @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "context" + "maps" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/event/catalog" +) + +// IdentityResolver resolves the effective identity for a run and verifies its +// credentials are usable. Implementations live with the command wiring. +type IdentityResolver interface { + Resolve(ctx context.Context, entry *catalog.Entry) (string, error) +} + +// PreflightReader performs the read-only preflight checks and reports each as +// a precondition. It never mutates remote or local state. +type PreflightReader interface { + Read(ctx context.Context, entry *catalog.Entry, identity string) ([]Precondition, error) +} + +// PrepareFunc is what Execute hands the stream host: invoked exactly when the +// delivery handshake says this consumer is first for its scope. +type PrepareFunc = func(ctx context.Context) (Cleanup, error) + +// StreamRunner runs the delivery stream for an already-decided consume. The +// production implementation wraps the runtime host; tests substitute spies. +type StreamRunner interface { + Run(ctx context.Context, prepare PrepareFunc) error +} + +// Service orchestrates the consume use case in a fixed order: decide first, +// render or execute the same decision second. +type Service struct { + Strategies *Registry + Identity IdentityResolver + Preflight PreflightReader +} + +// Decide classifies one request against one compiled entry. It performs no +// external writes: parameter normalization works on a copy, and every remote +// interaction is a read-only preflight. +func (s *Service) Decide(ctx context.Context, entry *catalog.Entry, req Request, api ExecutionContext) (*Decision, error) { + def := entry.Definition() + + params := maps.Clone(req.Params) + if params == nil { + params = map[string]string{} + } + if err := catalog.ValidateParams(def, params); err != nil { + return nil, err + } + if normalize := entry.Binding().NormalizeParams; normalize != nil { + if err := normalize(ctx, api.API, params); err != nil { + if _, ok := errs.ProblemOf(err); ok { + return nil, err + } + return nil, errs.NewInternalError(errs.SubtypeUnknown, + "normalize params for %s: %s", def.Key, err).WithCause(err) + } + } + + identity, err := s.Identity.Resolve(ctx, entry) + if err != nil { + return nil, err + } + + preconditions, err := s.Preflight.Read(ctx, entry, identity) + if err != nil { + return nil, err + } + + strategyRef := entry.Capability().Preparation + strategy, err := s.Strategies.get(strategyRef) + if err != nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, "%s", err).WithCause(err) + } + prep, err := strategy.Decide(ctx, PreparedConsume{Entry: entry, Params: params}) + if err != nil { + return nil, err + } + + d := &Decision{ + eventKey: def.Key, + domain: entry.Descriptor().Domain, + identity: identity, + params: params, + scope: catalog.SubscriptionScope(def, params), + preconditions: preconditions, + wouldRead: []string{"local_bus_probe", "app_metadata_preflight"}, + wouldWrite: []string{"start_or_reuse_local_bus", "register_consumer"}, + } + if strategyRef != catalog.StrategyNone { + d.preparation = &prep + d.wouldWrite = append(d.wouldWrite, "run_preparation_when_first") + } + d.wouldWrite = append(d.wouldWrite, "open_event_stream") + if req.OutputDir != "" { + d.wouldWrite = append(d.wouldWrite, "create_output_dir") + } + + d.status = StatusReady + blockedName := "" + for i := range preconditions { + switch preconditions[i].Status { + case PreconditionBlocked: + d.status = StatusBlocked + if blockedName == "" { + blockedName = preconditions[i].Name + } + if d.blockErr == nil { + d.blockErr = preconditions[i].BlockErr + } + case PreconditionUnknown: + if d.status == StatusReady { + d.status = StatusUnknown + } + } + } + // A blocked decision must carry the error Execute returns; a preflight + // reader that reports blocked without one would otherwise make Execute a + // silent nil no-op. + if d.status == StatusBlocked && d.blockErr == nil { + d.blockErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, + "precondition %s blocks consuming %s", blockedName, def.Key) + } + return d, nil +} + +// Execute runs the decision for real. A blocked decision returns the exact +// error its preflight produced; an unknown decision proceeds — weak +// dependencies degrade with a stderr note, they do not block, matching the +// behavior consumers have always had. +func (s *Service) Execute(ctx context.Context, entry *catalog.Entry, d *Decision, runner StreamRunner, ec ExecutionContext) error { + // The decision must be the one decided for this entry: executing a + // mismatched pair would apply one key's preparation to another's stream. + if got := entry.Descriptor().Key; d.eventKey != got { + return errs.NewInternalError(errs.SubtypeUnknown, + "decision for %q cannot execute against entry %q", d.eventKey, got) + } + if d.status == StatusBlocked { + return d.blockErr + } + var prepare PrepareFunc + if ref := entry.Capability().Preparation; ref != catalog.StrategyNone { + strategy, err := s.Strategies.get(ref) + if err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "%s", err).WithCause(err) + } + if d.preparation == nil { + return errs.NewInternalError(errs.SubtypeUnknown, + "decision for %q carries no preparation but the entry requires strategy %q", d.eventKey, ref) + } + prep := *d.preparation + in := PreparedConsume{Entry: entry, Params: maps.Clone(d.params)} + prepare = func(ctx context.Context) (Cleanup, error) { + return strategy.Apply(ctx, prep, in, ec) + } + } + return runner.Run(ctx, prepare) +} + +// NormalizedParams returns a copy of the decision's validated, normalized +// parameters — what a real run must consume so normalization stays a +// once-per-consumer event. +func (d *Decision) NormalizedParams() map[string]string { + return maps.Clone(d.params) +} diff --git a/internal/event/application/consume/service_test.go b/internal/event/application/consume/service_test.go new file mode 100644 index 0000000000..5fbf94b88b --- /dev/null +++ b/internal/event/application/consume/service_test.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + + "github.com/larksuite/cli/events" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/processing" +) + +type spyAPIClient struct{ calls atomic.Int64 } + +func (s *spyAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) { + s.calls.Add(1) + return nil, errors.New("no API access expected on this path") +} + +type spyRunner struct{ runs atomic.Int64 } + +func (s *spyRunner) Run(_ context.Context, prepare PrepareFunc) error { + s.runs.Add(1) + if prepare != nil { + if _, err := prepare(context.Background()); err != nil { + return err + } + } + return nil +} + +func fixedIdentity(id string) IdentityResolver { + return identityFunc(func(context.Context, *catalog.Entry) (string, error) { return id, nil }) +} + +type identityFunc func(ctx context.Context, entry *catalog.Entry) (string, error) + +func (f identityFunc) Resolve(ctx context.Context, entry *catalog.Entry) (string, error) { + return f(ctx, entry) +} + +type preflightFunc func(ctx context.Context, entry *catalog.Entry, identity string) ([]Precondition, error) + +func (f preflightFunc) Read(ctx context.Context, entry *catalog.Entry, identity string) ([]Precondition, error) { + return f(ctx, entry, identity) +} + +func okPreflight() PreflightReader { + return preflightFunc(func(context.Context, *catalog.Entry, string) ([]Precondition, error) { + return []Precondition{{Name: "console_event_published", Status: PreconditionOK}}, nil + }) +} + +func serviceForTest(pf PreflightReader) *Service { + return &Service{Strategies: DefaultRegistry(), Identity: fixedIdentity("user"), Preflight: pf} +} + +func realCatalog(t *testing.T) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(events.All(), DefaultRegistry()) + if err != nil { + t.Fatal(err) + } + return snap +} + +// requiredParamsFor fabricates a value for every required parameter so a +// decision can be made for any shipped key. +func requiredParamsFor(def *catalog.KeyDefinition) map[string]string { + params := map[string]string{} + for _, p := range def.Params { + if p.Required { + params[p.Name] = "decide-gate-value" + } + } + return params +} + +// Deciding is the dry-run: for every shipped key it must complete without a +// single API call, stream start, or preparation apply. The spies are proven +// live by the control test below, so an all-zero count means the decide path +// genuinely performs nothing. +func TestDecide_PerformsNoSideEffectForAnyKey(t *testing.T) { + snap := realCatalog(t) + api := &spyAPIClient{} + svc := serviceForTest(okPreflight()) + + decided := 0 + for _, entry := range snap.Entries() { + def := entry.Definition() + req := Request{EventKey: def.Key, Params: requiredParamsFor(def), DryRun: true, OutputDir: "events-out"} + d, err := svc.Decide(context.Background(), entry, req, ExecutionContext{API: api}) + if err != nil { + t.Fatalf("%s: decide failed: %v", def.Key, err) + } + decided++ + + v := d.View() + if v.Status != StatusReady { + t.Errorf("%s: want ready with all-ok preconditions, got %s", def.Key, v.Status) + } + if v.Domain == "" || v.Scope == "" { + t.Errorf("%s: view must resolve domain and scope, got %+v", def.Key, v) + } + wantPrep := entry.Capability().Preparation != catalog.StrategyNone + if (v.Preparation != nil) != wantPrep { + t.Errorf("%s: preparation view presence = %v, want %v", def.Key, v.Preparation != nil, wantPrep) + } + if last := v.WouldWrite[len(v.WouldWrite)-1]; last != "create_output_dir" { + t.Errorf("%s: an output dir was requested; would_write must state it, got %v", def.Key, v.WouldWrite) + } + } + if decided != snap.Len() || decided == 0 { + t.Fatalf("decided %d keys, want all %d; the gate scanned too little", decided, snap.Len()) + } + if got := api.calls.Load(); got != 0 { + t.Errorf("deciding made %d API call(s); the decide path must not touch the API", got) + } +} + +// Control group: the same spies must fire on a real execution — an all-zero +// dry-run count proves nothing if the spies were never wired to anything. +func TestExecute_SpiesBiteOnTheRealPath(t *testing.T) { + var setup atomic.Int64 + def := catalog.KeyDefinition{ + Key: "demo.spy.check_v1", + EventType: "demo.spy.check_v1", + Schema: catalog.SchemaDef{Native: &catalog.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(ctx context.Context, rt processing.APIClient, params map[string]string) (func() error, error) { + setup.Add(1) + return nil, nil + }, + } + snap, err := catalog.Compile([]catalog.KeyDefinition{def}, DefaultRegistry()) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + + svc := serviceForTest(okPreflight()) + d, err := svc.Decide(context.Background(), entry, Request{EventKey: def.Key}, ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + if setup.Load() != 0 { + t.Fatal("deciding ran the preparation hook; decide must stay side-effect free") + } + + runner := &spyRunner{} + if err := svc.Execute(context.Background(), entry, d, runner, ExecutionContext{API: &spyAPIClient{}}); err != nil { + t.Fatalf("execute: %v", err) + } + if runner.runs.Load() != 1 { + t.Errorf("the stream runner must run exactly once, got %d", runner.runs.Load()) + } + if setup.Load() != 1 { + t.Errorf("the preparation hook must fire on the real path, got %d", setup.Load()) + } +} + +// A blocked decision refuses execution with the exact error its preflight +// produced — identical to what a direct run would have returned. +func TestExecute_BlockedReturnsThePreflightError(t *testing.T) { + blockErr := errors.New("console switch is off") + pf := preflightFunc(func(context.Context, *catalog.Entry, string) ([]Precondition, error) { + return []Precondition{{Name: "console_event_published", Status: PreconditionBlocked, Detail: blockErr.Error(), BlockErr: blockErr}}, nil + }) + svc := serviceForTest(pf) + snap := realCatalog(t) + entry, _ := snap.Resolve("im.message.receive_v1") + + d, err := svc.Decide(context.Background(), entry, Request{EventKey: "im.message.receive_v1"}, ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + if d.View().Status != StatusBlocked { + t.Fatalf("want blocked status, got %s", d.View().Status) + } + runner := &spyRunner{} + if got := svc.Execute(context.Background(), entry, d, runner, ExecutionContext{API: &spyAPIClient{}}); !errors.Is(got, blockErr) { + t.Errorf("execute must return the preflight's own error, got %v", got) + } + if runner.runs.Load() != 0 { + t.Error("a blocked decision must never reach the stream runner") + } +} + +// Weak dependencies degrade, they do not block: unknown preconditions render +// as unknown but a real run still proceeds. +func TestExecute_UnknownProceeds(t *testing.T) { + pf := preflightFunc(func(context.Context, *catalog.Entry, string) ([]Precondition, error) { + return []Precondition{{Name: "console_event_published", Status: PreconditionUnknown, Detail: "ledger unavailable"}}, nil + }) + svc := serviceForTest(pf) + snap := realCatalog(t) + entry, _ := snap.Resolve("im.message.receive_v1") + + d, err := svc.Decide(context.Background(), entry, Request{EventKey: "im.message.receive_v1"}, ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + if d.View().Status != StatusUnknown { + t.Fatalf("want unknown status, got %s", d.View().Status) + } + runner := &spyRunner{} + if err := svc.Execute(context.Background(), entry, d, runner, ExecutionContext{API: &spyAPIClient{}}); err != nil { + t.Fatalf("unknown must not block execution: %v", err) + } + if runner.runs.Load() != 1 { + t.Error("execution must proceed under unknown preconditions") + } +} + +// Mutating a view must never reach the decision it came from. +func TestDecisionView_IsACopy(t *testing.T) { + svc := serviceForTest(okPreflight()) + snap := realCatalog(t) + entry, _ := snap.Resolve("board.whiteboard.updated_v1") + + d, err := svc.Decide(context.Background(), entry, + Request{EventKey: "board.whiteboard.updated_v1", Params: map[string]string{"whiteboard_id": "wb-1"}}, + ExecutionContext{API: &spyAPIClient{}}) + if err != nil { + t.Fatal(err) + } + v := d.View() + v.Params["whiteboard_id"] = "tampered" + v.WouldWrite[0] = "tampered" + v.Preconditions[0].Status = "tampered" + + fresh := d.View() + if fresh.Params["whiteboard_id"] == "tampered" || fresh.WouldWrite[0] == "tampered" || fresh.Preconditions[0].Status == "tampered" { + t.Error("mutating a view leaked into the decision") + } +} diff --git a/internal/event/application/consume/strategy.go b/internal/event/application/consume/strategy.go new file mode 100644 index 0000000000..bbc583c0ab --- /dev/null +++ b/internal/event/application/consume/strategy.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "context" + "fmt" + "maps" + + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/processing" +) + +// PreparedConsume is the classified input a strategy decides and applies for. +type PreparedConsume struct { + Entry *catalog.Entry + Params map[string]string +} + +// PreparationDecision is the serializable preview of what preparation would +// do. It is conditional by design: whether it actually runs is decided by the +// delivery handshake (first consumer for the scope), never at decide time. +type PreparationDecision struct { + Strategy catalog.StrategyRef + Condition string + Action string +} + +// Cleanup undoes a strategy's Apply; the runtime host invokes it when this +// consumer is the last one for its scope. +type Cleanup = func() error + +// ExecutionContext carries the per-request dependencies a strategy may use +// during Apply. Strategies hold no clients of their own: the caller resolves +// identity first and injects exactly one API surface for this run. +type ExecutionContext struct { + API processing.APIClient +} + +// PreparationStrategy separates deciding what preparation would do (no +// external writes) from doing it (the only write entry point). +type PreparationStrategy interface { + Decide(ctx context.Context, in PreparedConsume) (PreparationDecision, error) + Apply(ctx context.Context, d PreparationDecision, in PreparedConsume, ec ExecutionContext) (Cleanup, error) +} + +// Registry holds the executable strategies and doubles as the catalog's +// StrategySet, so the compiler validates references against exactly the set +// that will execute. +type Registry struct { + strategies map[catalog.StrategyRef]PreparationStrategy +} + +func (r *Registry) Has(ref catalog.StrategyRef) bool { + _, ok := r.strategies[ref] + return ok +} + +func (r *Registry) get(ref catalog.StrategyRef) (PreparationStrategy, error) { + s, ok := r.strategies[ref] + if !ok { + return nil, fmt.Errorf("preparation strategy %q is not registered", ref) + } + return s, nil +} + +// DefaultRegistry returns the strategies this build ships: no preparation, +// and the wrapper over a declaration's PreConsume hook. +func DefaultRegistry() *Registry { + return &Registry{strategies: map[catalog.StrategyRef]PreparationStrategy{ + catalog.StrategyNone: noneStrategy{}, + catalog.StrategyLegacyPreConsume: legacyPreConsumeStrategy{}, + }} +} + +// noneStrategy: the key needs nothing before consuming. +type noneStrategy struct{} + +func (noneStrategy) Decide(context.Context, PreparedConsume) (PreparationDecision, error) { + return PreparationDecision{Strategy: catalog.StrategyNone}, nil +} + +func (noneStrategy) Apply(context.Context, PreparationDecision, PreparedConsume, ExecutionContext) (Cleanup, error) { + return nil, nil +} + +// legacyPreConsumeStrategy wraps a declaration's PreConsume hook. Decide never +// invokes the hook — it only states the conditional action — so a decision +// (and therefore a dry-run) provably performs none of the hook's writes. +type legacyPreConsumeStrategy struct{} + +func (legacyPreConsumeStrategy) Decide(_ context.Context, in PreparedConsume) (PreparationDecision, error) { + return PreparationDecision{ + Strategy: catalog.StrategyLegacyPreConsume, + Condition: "first_consumer_for_scope", + Action: "register_event_delivery", + }, nil +} + +func (legacyPreConsumeStrategy) Apply(ctx context.Context, _ PreparationDecision, in PreparedConsume, ec ExecutionContext) (Cleanup, error) { + hook := in.Entry.Binding().PreConsume + if hook == nil { + return nil, nil + } + return hook(ctx, ec.API, maps.Clone(in.Params)) +} diff --git a/internal/event/arch_layering_test.go b/internal/event/arch_layering_test.go new file mode 100644 index 0000000000..4b00f9fb59 --- /dev/null +++ b/internal/event/arch_layering_test.go @@ -0,0 +1,459 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Architecture layering gates for the event tree. +// +// Layout under internal/event: +// +// kernel — the root package plus model, catalog, processing, schemas, +// application/...: pure event semantics, no I/O, no framework. +// hosts — bus, consume: long-running processes that may drive exactly +// the adapters pinned in hostAdapterAllowlist. +// adapters — adapter/...: concrete transports (lark websocket, localbus). +// +// Dependencies must point inward: adapters and hosts may use the kernel, the +// kernel must never reach outward. Once a kernel package touches an adapter, +// a host, the platform SDK, or the network, every consumer of the value +// model silently links transports it never asked for, and the composition +// root loses the ability to swap them. These tests turn that drift into a +// build break. +package event_test + +import ( + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + archModulePath = "github.com/larksuite/cli" + archAdapterImportPrefix = archModulePath + "/internal/event/adapter" + + // archKernelFileFloor is an idle detector, not a target: the kernel had + // 19 production files when the floor was set. If the walker, the + // outwardFacing set, or a path constant rots so the gate scans (almost) + // nothing, a green run would be meaningless — fail hard instead. + archKernelFileFloor = 15 +) + +// outwardFacing lists the internal/event subtrees exempt from kernel purity, +// each with the reason it may face outward. Everything else — including any +// directory added later — is kernel by default and governed by +// TestArchKernelPurity without anyone remembering to opt in. +var outwardFacing = map[string]string{ + "adapter": "concrete transports (lark websocket, localbus); the outermost ring", + "bus": "host process owning the local bus lifecycle", + "consume": "host process owning the consumer loop", + "testutil": "shared test fakes; never linked into production binaries", +} + +// archForbiddenKernelImport reports why importPath is banned inside the +// kernel, if it is. TestArchKernelImportDetectorSelfCheck exercises this +// function on synthetic sources so a drifted matcher cannot keep reporting +// green. +func archForbiddenKernelImport(importPath string) (reason string, banned bool) { + switch { + case importPath == "github.com/spf13/cobra": + return "CLI framework; command wiring lives in cmd, not in event semantics", true + case strings.HasPrefix(importPath, "github.com/larksuite/oapi-sdk-go"): + return "platform SDK; only adapters may speak the platform wire format", true + case importPath == "net" || strings.HasPrefix(importPath, "net/"): + return "networking (any net or net/* package, net/url included); kernel logic must stay I/O-free so any host can embed it", true + case importPath == archModulePath+"/internal/event/bus", + importPath == archModulePath+"/internal/event/consume": + return "host package; the kernel calling its host inverts the dependency direction", true + case importPath == archAdapterImportPrefix || strings.HasPrefix(importPath, archAdapterImportPrefix+"/"): + return "concrete adapter; depend on a port and let the composition root inject the implementation", true + } + return "", false +} + +// archKernelDirs derives the kernel directory set from the tree itself: +// every directory under internal/event (the root included) minus the +// outwardFacing subtrees and testdata. Deriving instead of enumerating means +// a newly added package is governed by default — the gate can only lose +// coverage through an explicit outwardFacing edit, never through forgetting. +func archKernelDirs(t *testing.T) []string { + t.Helper() + var dirs []string + err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + rel := filepath.ToSlash(path) + if rel == "." { + dirs = append(dirs, rel) + return nil + } + if d.Name() == "testdata" { + return fs.SkipDir + } + top, _, _ := strings.Cut(rel, "/") + if _, outward := outwardFacing[top]; outward { + return fs.SkipDir + } + dirs = append(dirs, rel) + return nil + }) + if err != nil { + t.Fatalf("walk internal/event: %v", err) + } + sort.Strings(dirs) + return dirs +} + +// archProductionFilesUnder returns every non-test .go file under root, +// skipping testdata directories. +func archProductionFilesUnder(t *testing.T, root string) []string { + t.Helper() + var files []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "testdata" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + sort.Strings(files) + return files +} + +// archImports parses one file with ImportsOnly and returns its import paths. +func archImports(t *testing.T, fset *token.FileSet, file string) []string { + t.Helper() + f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + paths := make([]string, 0, len(f.Imports)) + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote import in %s: %v", file, err) + } + paths = append(paths, path) + } + return paths +} + +// TestArchKernelPurity fails when any kernel production file imports the CLI +// framework, the platform SDK, the network, a host, or an adapter. The +// kernel set is derived from the directory tree, with two tripwires against +// the gate itself being hollowed out. +func TestArchKernelPurity(t *testing.T) { + // Tripwire: this gate proves the kernel/adapter boundary holds. If the + // adapter tree is gone the boundary does not exist and a green run + // proves nothing — someone moved the transports without moving the gate. + if fi, err := os.Stat("adapter"); err != nil || !fi.IsDir() { + t.Fatalf("internal/event/adapter does not exist (%v) — the boundary this gate guards is gone; relocate the gate together with the adapters", err) + } + + kernelDirs := archKernelDirs(t) + + // Tripwire: deriving the kernel set means moving a package out of the + // tree silently shrinks coverage. Pin the named kernel packages so + // "make the gate green by relocating the offender" fails loudly. + for _, required := range []string{"application", "catalog", "model", "processing", "schemas"} { + if !slices.Contains(kernelDirs, required) { + t.Fatalf("kernel package %q missing from derived gate set %v — if it genuinely moved, update this gate deliberately instead of letting coverage shrink", required, kernelDirs) + } + } + + fset := token.NewFileSet() + parsed := 0 + for _, dir := range kernelDirs { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read %s: %v", dir, err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file := filepath.Join(dir, name) + parsed++ + for _, path := range archImports(t, fset, file) { + if reason, banned := archForbiddenKernelImport(path); banned { + t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason) + } + } + } + } + if parsed < archKernelFileFloor { + t.Fatalf("parsed only %d kernel production files (floor %d) — the walker or the outwardFacing set is eating the tree; a gate that scans nothing reports green forever", parsed, archKernelFileFloor) + } +} + +// archForbiddenHostImport reports why importPath is banned inside a host +// production file, if it is. Hosts are deliberately held to a looser standard +// than the kernel: they own sockets, so the standard library net packages are +// legitimate there (the local bus is IPC over a socket) and only the platform +// SDK is banned — a host speaking the platform wire format directly would +// bypass the bus and its adapters, which is the seam the architecture exists +// to keep. Matching is segment-boundary safe so a lookalike module name +// cannot trip it. +func archForbiddenHostImport(importPath string) (reason string, banned bool) { + const sdk = "github.com/larksuite/oapi-sdk-go" + if importPath == sdk || strings.HasPrefix(importPath, sdk+"/") { + return "platform SDK; hosts reach the platform only through the bus and its adapters, never by speaking the platform wire format themselves", true + } + return "", false +} + +// TestArchHostPlatformSDKBan fails when any host production file imports the +// platform SDK. The host set is pinned to the two long-running processes; a +// new host must be added here deliberately. +func TestArchHostPlatformSDKBan(t *testing.T) { + hosts := []string{"bus", "consume"} + + sawNetImport := false + for _, host := range hosts { + if _, ok := outwardFacing[host]; !ok { + t.Fatalf("host %q is not in the outwardFacing set — the host moved and this gate is scanning a ghost", host) + } + files := archProductionFilesUnder(t, host) + if len(files) == 0 { + t.Fatalf("no production files found under %s — the walker is idling or the host is gone; fix that before trusting this gate", host) + } + fset := token.NewFileSet() + for _, file := range files { + for _, path := range archImports(t, fset, file) { + if path == "net" || strings.HasPrefix(path, "net/") { + sawNetImport = true + } + if reason, banned := archForbiddenHostImport(path); banned { + t.Errorf("%s imports %q: %s", filepath.ToSlash(file), path, reason) + } + } + } + } + + // Tripwire: hosts do socket IPC, so the scan of real host files must have + // seen a net import. Seeing none means the import walk is not reading what + // the hosts actually import, and a green run would prove nothing. (It also + // pins the reason net is absent from the ban set: hosts need it.) + if !sawNetImport { + t.Fatal("scanned every host production file and found no net import — hosts are socket-IPC processes, so the import scan cannot be trusted; if the hosts genuinely dropped net, update this tripwire deliberately") + } +} + +// TestArchHostImportDetectorSelfCheck runs the host forbidden-import matcher +// on synthetic sources with known outcomes, so a drifted matcher cannot keep +// TestArchHostPlatformSDKBan green on a violating tree. +func TestArchHostImportDetectorSelfCheck(t *testing.T) { + scan := func(src string) []string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "synthetic.go", src, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse synthetic source: %v", err) + } + var flagged []string + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote synthetic import: %v", err) + } + if _, banned := archForbiddenHostImport(path); banned { + flagged = append(flagged, path) + } + } + sort.Strings(flagged) + return flagged + } + + const violating = `package fakehost + +import ( + _ "github.com/larksuite/oapi-sdk-go/v3" + _ "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + _ "net" +) +` + want := []string{ + "github.com/larksuite/oapi-sdk-go/v3", + "github.com/larksuite/oapi-sdk-go/v3/service/im/v1", + } + if got := scan(violating); !slices.Equal(got, want) { + t.Fatalf("detector self-check: flagged %v, want exactly %v — the matcher drifted and the host gate cannot be trusted", got, want) + } + + // net stays legal for hosts (socket IPC), and prefix matching must respect + // path segment boundaries. + const clean = `package fakehost + +import ( + _ "github.com/larksuite/oapi-sdk-golike" + _ "net" + _ "net/http" +) +` + if got := scan(clean); len(got) != 0 { + t.Fatalf("detector self-check: clean synthetic source flagged %v — the matcher over-triggers", got) + } +} + +// hostAdapterAllowlist pins, per host, the exact adapter import paths its +// production code uses today. +// +// This is a ceiling, not a design: deleting an entry is always welcome; +// adding one means a host wired in another adapter, and the question to ask +// is whether that capability should instead be a port owned by the host and +// injected by the cmd/event composition root. +var hostAdapterAllowlist = map[string][]string{ + "bus": { + archAdapterImportPrefix + "/localbus/busdiscover", + archAdapterImportPrefix + "/localbus/protocol", + archAdapterImportPrefix + "/localbus/transport", + }, + "consume": { + archAdapterImportPrefix + "/localbus/protocol", + archAdapterImportPrefix + "/localbus/transport", + }, +} + +// TestArchHostAdapterAllowlist checks the host→adapter edges in both +// directions: an adapter import outside the allowlist is a new coupling that +// skipped review, and an allowlist entry no adapter import matches is a +// ceiling wider than reality — it would let a removed dependency come back +// without anyone looking. +func TestArchHostAdapterAllowlist(t *testing.T) { + hosts := make([]string, 0, len(hostAdapterAllowlist)) + for host := range hostAdapterAllowlist { + hosts = append(hosts, host) + } + sort.Strings(hosts) + + for _, host := range hosts { + // An allowlist key that is not an outward-facing package is dead + // text: the kernel purity gate already bans those dirs from touching + // adapters, so the entry would silently grant nothing to no one. + if _, ok := outwardFacing[host]; !ok { + t.Errorf("hostAdapterAllowlist key %q is not in the outwardFacing set — the entry is dead text; either the host moved or the entry must go", host) + continue + } + + files := archProductionFilesUnder(t, host) + if len(files) == 0 { + t.Errorf("no production files found under %s — the walker is idling or the host is gone; fix that before trusting this gate", host) + continue + } + + allowed := make(map[string]bool, len(hostAdapterAllowlist[host])) + for _, path := range hostAdapterAllowlist[host] { + allowed[path] = true + } + used := make(map[string]bool) + + fset := token.NewFileSet() + for _, file := range files { + for _, path := range archImports(t, fset, file) { + if path != archAdapterImportPrefix && !strings.HasPrefix(path, archAdapterImportPrefix+"/") { + continue + } + if allowed[path] { + used[path] = true + continue + } + msg := "declare the needed capability as a port owned by this package and let the cmd/event composition root inject the implementation" + if strings.HasPrefix(path, archAdapterImportPrefix+"/lark") { + msg += " — a host wired straight to the platform adapter bypasses the bus, which is the reason the bus exists" + } + t.Errorf("%s imports %q outside hostAdapterAllowlist[%q]: %s", filepath.ToSlash(file), path, host, msg) + } + } + + for _, path := range hostAdapterAllowlist[host] { + if !used[path] { + t.Errorf("stale hostAdapterAllowlist entry %q for host %q: no production file imports it — delete the entry so a re-introduction has to pass review", path, host) + } + } + } +} + +// TestArchKernelImportDetectorSelfCheck runs the kernel forbidden-import +// matcher on synthetic sources with known outcomes. If a path constant +// drifts from the real package layout, TestArchKernelPurity would keep +// reporting green on a violating tree; this test makes that failure mode a +// build break of its own. +func TestArchKernelImportDetectorSelfCheck(t *testing.T) { + scan := func(src string) []string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "synthetic.go", src, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse synthetic source: %v", err) + } + var flagged []string + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + t.Fatalf("unquote synthetic import: %v", err) + } + if _, banned := archForbiddenKernelImport(path); banned { + flagged = append(flagged, path) + } + } + sort.Strings(flagged) + return flagged + } + + const violating = `package fakekernel + +import ( + _ "context" + _ "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + _ "github.com/larksuite/cli/internal/event/bus" + _ "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + _ "github.com/spf13/cobra" + _ "net/http" +) +` + want := []string{ + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol", + "github.com/larksuite/cli/internal/event/bus", + "github.com/larksuite/oapi-sdk-go/v3/service/im/v1", + "github.com/spf13/cobra", + "net/http", + } + if got := scan(violating); !slices.Equal(got, want) { + t.Fatalf("detector self-check: flagged %v, want exactly %v — the matcher drifted from the package layout and the purity gate cannot be trusted", got, want) + } + + const clean = `package fakekernel + +import ( + _ "context" + _ "encoding/json" + _ "github.com/larksuite/cli/internal/event/adapterlike" + _ "github.com/larksuite/cli/internal/event/model" + _ "network" +) +` + if got := scan(clean); len(got) != 0 { + t.Fatalf("detector self-check: clean synthetic source flagged %v — the matcher over-triggers (prefix matching must respect path segment boundaries)", got) + } +} diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 849285694d..4e163d891a 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -19,10 +19,10 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/busdiscover" - "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/source" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/busdiscover" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/lockfile" ) @@ -49,10 +49,19 @@ type Bus struct { // pidHandle pins the alive.lock fd to the bus lifetime; OS releases on exit. pidHandle *busdiscover.Handle + + // snapshot is the compiled catalog this daemon serves: it decides which + // upstream event types to subscribe and which keys are single-consumer. + snapshot *catalog.Snapshot + + // sources are injected by the composition root; the daemon runs whatever + // it was handed and never constructs an ingress itself. + sources []Source } -func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger) *Bus { +func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logger, snap *catalog.Snapshot, sources ...Source) *Bus { return &Bus{ + sources: sources, appID: appID, appSecret: appSecret, domain: domain, @@ -60,6 +69,7 @@ func NewBus(appID, appSecret, domain string, tr transport.IPC, logger *log.Logge hub: NewHub(), dedup: event.NewDedupFilter(), logger: logger, + snapshot: snap, startTime: time.Now(), conns: make(map[*Conn]struct{}), // Buffered so shutdown and source-exit paths never drop the signal. @@ -155,21 +165,15 @@ func shutdownConns(b *Bus) { } } -// startSources launches registered sources (or a default FeishuSource); any source exit triggers full bus shutdown. +// startSources launches the injected sources; any source exit triggers full bus shutdown. func (b *Bus) startSources(ctx context.Context) { - sources := source.All() - if len(sources) == 0 { - sources = []source.Source{&source.FeishuSource{ - AppID: b.appID, - AppSecret: b.appSecret, - Domain: b.domain, - Logger: b.logger, - }} + if len(b.sources) == 0 { + b.logger.Printf("WARN: no event sources injected; the bus will idle until shutdown") } - eventTypes := subscribedEventTypes() + eventTypes := b.snapshot.EventTypes() b.hub.SetLogger(b.logger) - for _, src := range sources { - go func(s source.Source) { + for _, src := range b.sources { + go func(s Source) { b.logger.Printf("Starting source: %s", s.Name()) err := s.Start(ctx, eventTypes, func(raw *event.RawEvent) { b.logger.Printf("Event received: type=%s id=%s", raw.EventType, raw.EventID) @@ -197,20 +201,6 @@ func (b *Bus) startSources(ctx context.Context) { } } -// subscribedEventTypes returns the deduplicated union of EventTypes from every registered EventKey. -func subscribedEventTypes() []string { - seen := make(map[string]struct{}) - var types []string - for _, def := range event.ListAll() { - if _, ok := seen[def.EventType]; ok { - continue - } - seen[def.EventType] = struct{}{} - types = append(types, def.EventType) - } - return types -} - // acceptLoop accepts IPC connections until the listener is closed. func (b *Bus) acceptLoop(ctx context.Context) { for { @@ -271,8 +261,8 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H // 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 + if entry, ok := b.snapshot.Resolve(hello.EventKey); ok { + exclusive = entry.Capability().SingleConsumer } var firstForKey bool if exclusive { @@ -325,7 +315,7 @@ func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.H } b.mu.Unlock() - ack := protocol.NewHelloAck("v1", firstForKey) + ack := protocol.NewHelloAck("v1", firstForKey, protocol.CapabilityCanonicalMetadataV1) // writeFrame shares writeMu with every other write; bc.Close on failure unwinds hub+bus registration via onClose. if err := bc.writeFrame(ack); err != nil { b.logger.Printf("WARN: hello_ack write to pid=%d key=%q failed: %v (rejecting connection)", diff --git a/internal/event/bus/bus_shutdown_test.go b/internal/event/bus/bus_shutdown_test.go index 98ff07401c..deeb12de2b 100644 --- a/internal/event/bus/bus_shutdown_test.go +++ b/internal/event/bus/bus_shutdown_test.go @@ -74,7 +74,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) { // shutdownCh must be buffered so a signal sent before Run's select loop is still delivered. func TestShutdownSignalNotDroppedBeforeRunSelects(t *testing.T) { - b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0)) + b := NewBus("test-app", "test-secret", "", nil, log.New(io.Discard, "", 0), nil) select { case b.shutdownCh <- struct{}{}: diff --git a/internal/event/bus/conn.go b/internal/event/bus/conn.go index c827d80081..40c522ba93 100644 --- a/internal/event/bus/conn.go +++ b/internal/event/bus/conn.go @@ -12,7 +12,7 @@ import ( "sync/atomic" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const ( diff --git a/internal/event/bus/conn_test.go b/internal/event/bus/conn_test.go index 1beaf3489a..3c5f1b31c8 100644 --- a/internal/event/bus/conn_test.go +++ b/internal/event/bus/conn_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestConn_SenderWritesEvents(t *testing.T) { diff --git a/internal/event/bus/handle_hello_test.go b/internal/event/bus/handle_hello_test.go index b8b1e36fff..e8975dd4e1 100644 --- a/internal/event/bus/handle_hello_test.go +++ b/internal/event/bus/handle_hello_test.go @@ -14,9 +14,24 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/catalog" ) +// compileBusTestSnapshot compiles synthetic declarations into the snapshot a +// bus under test serves, replacing the removed global registry. +func compileBusTestSnapshot(t *testing.T, defs ...event.KeyDefinition) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(defs, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test catalog: %v", err) + } + return snap +} + // HelloAck write failure must unregister the conn from hub and bus before returning. func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) { logger := log.New(io.Discard, "", 0) @@ -27,6 +42,7 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: compileBusTestSnapshot(t), } server, client := net.Pipe() @@ -78,6 +94,7 @@ func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: compileBusTestSnapshot(t), } server, client := net.Pipe() @@ -143,6 +160,7 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: compileBusTestSnapshot(t), } server, client := net.Pipe() @@ -202,13 +220,12 @@ func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) { // 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{ + snap := compileBusTestSnapshot(t, 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() @@ -218,6 +235,7 @@ func TestHandleHello_SingleConsumerRejectsSecond(t *testing.T) { conns: make(map[*Conn]struct{}), idleTimer: time.NewTimer(30 * time.Second), shutdownCh: make(chan struct{}, 1), + snapshot: snap, } readAck := func(t *testing.T, pid int) *protocol.HelloAck { diff --git a/internal/event/bus/hub.go b/internal/event/bus/hub.go index a53fc1554b..42c50ef283 100644 --- a/internal/event/bus/hub.go +++ b/internal/event/bus/hub.go @@ -12,7 +12,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // exclusiveCleanupWaitTimeout bounds how long TryRegisterExclusive waits for an @@ -222,24 +222,12 @@ func (h *Hub) Publish(raw *event.RawEvent) { } h.mu.RUnlock() - // Resolve source time once per Publish (not per subscriber) — same value - // across the fan-out. Prefer the upstream header create_time - // (raw.SourceTime) over the local arrival timestamp so consumers see - // original publisher intent; fall back to Timestamp when SourceTime - // wasn't populated (e.g. test-only sources, pre-4.4 RawEvent producers). - sourceTime := raw.SourceTime - if sourceTime == "" && !raw.Timestamp.IsZero() { - sourceTime = fmt.Sprintf("%d", raw.Timestamp.UnixMilli()) - } - + // SourceTime travels verbatim: when the upstream omitted create_time it + // stays empty on the wire. Substituting the local arrival clock here would + // disguise a local observation as an upstream fact — consumers that need + // the arrival time have the frame's observed_at. for _, s := range matches { - msg := protocol.NewEvent( - raw.EventType, - raw.EventID, - sourceTime, - s.NextSeq(), - raw.Payload, - ) + msg := protocol.NewEvent(raw, s.NextSeq()) enqueued, dropped := s.PushDropOldest(msg) if dropped { diff --git a/internal/event/bus/hub_observability_test.go b/internal/event/bus/hub_observability_test.go index 0134fe2a58..4e18c84ef3 100644 --- a/internal/event/bus/hub_observability_test.go +++ b/internal/event/bus/hub_observability_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestHubDroppedCountIncrements(t *testing.T) { @@ -65,10 +65,11 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) { h.RegisterAndIsFirst(c) const eid = "test-event-id-123" + observed := time.UnixMilli(1234567890123) h.Publish(&event.RawEvent{ EventID: eid, EventType: "t", - Timestamp: time.UnixMilli(1234567890123), + Timestamp: observed, }) msg := <-c.SendCh() @@ -76,8 +77,15 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) { if ev.EventID != eid { t.Errorf("expected EventID %q, got %q", eid, ev.EventID) } - if ev.SourceTime != "1234567890123" { - t.Errorf("expected SourceTime \"1234567890123\", got %q", ev.SourceTime) + // The upstream never sent create_time, so source_time must stay empty on + // the wire — the local arrival clock travels separately as observed_at. + if ev.SourceTime != "" { + t.Errorf("SourceTime must stay empty without upstream create_time, got %q", ev.SourceTime) + } + // UTC-normalized on the wire, so the frame bytes do not depend on the + // emitting host's local timezone. + if want := observed.UTC().Format(time.RFC3339Nano); ev.ObservedAt != want { + t.Errorf("ObservedAt: got %q, want %q", ev.ObservedAt, want) } } @@ -106,7 +114,9 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) { } } -func TestPublishSourceTimeFallback(t *testing.T) { +// A missing upstream create_time is a fact worth preserving: substituting the +// local clock would let a local observation masquerade as upstream intent. +func TestPublishMissingSourceTimeStaysEmpty(t *testing.T) { h := NewHub() server, client := testNetPipe(t) defer server.Close() @@ -123,8 +133,8 @@ func TestPublishSourceTimeFallback(t *testing.T) { msg := <-c.SendCh() ev := msg.(*protocol.Event) - if ev.SourceTime != "42" { - t.Errorf("SourceTime fallback: got %q, want %q", ev.SourceTime, "42") + if ev.SourceTime != "" { + t.Errorf("SourceTime: got %q, want empty when upstream omitted create_time", ev.SourceTime) } } diff --git a/internal/event/bus/hub_test.go b/internal/event/bus/hub_test.go index 7956b4b18d..45ba8ab98a 100644 --- a/internal/event/bus/hub_test.go +++ b/internal/event/bus/hub_test.go @@ -13,7 +13,7 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestHub_Subscribe(t *testing.T) { diff --git a/internal/event/bus/source_port.go b/internal/event/bus/source_port.go new file mode 100644 index 0000000000..b541393429 --- /dev/null +++ b/internal/event/bus/source_port.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package bus + +import ( + "context" + + "github.com/larksuite/cli/internal/event" +) + +// StatusNotifier surfaces source lifecycle states; detail is free-form +// context. It is a function alias (not a named type) so implementations +// satisfy the Source interface without importing this package. +type StatusNotifier = func(state, detail string) + +// Source produces events for the bus; emit MUST return quickly (anything slow +// stalls the source's read loop). The bus owns this port and the composition +// root injects implementations — the daemon never constructs one itself. +type Source interface { + Name() string + Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error +} + +// Source lifecycle states. The wire values mirror the IPC frame constants — +// the bus forwards them verbatim into source_status frames; a pinning test +// keeps the two vocabularies equal. +const ( + SourceStateConnecting = "connecting" + SourceStateConnected = "connected" + SourceStateDisconnected = "disconnected" + SourceStateReconnecting = "reconnecting" +) diff --git a/internal/event/catalog/canonicalize.go b/internal/event/catalog/canonicalize.go new file mode 100644 index 0000000000..c2444730f2 --- /dev/null +++ b/internal/event/catalog/canonicalize.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "slices" + "strings" + + "github.com/larksuite/cli/internal/event/schemas" +) + +// Canonicalize returns the normalized copy of a declaration: the defaults +// that used to be applied at registration time, made explicit. Projections +// and the compatibility view are both built from the canonical form, so +// round-trip checks compare against Canonicalize(input), never the raw input. +func Canonicalize(def KeyDefinition) KeyDefinition { + out := deepCopyDefinition(def) + if out.SubscriptionType == "" { + out.SubscriptionType = SubTypeEvent + } + if out.BufferSize > MaxBufferSize { + out.BufferSize = MaxBufferSize + } + if out.BufferSize <= 0 { + out.BufferSize = DefaultBufferSize + } + if out.Workers <= 0 { + out.Workers = 1 + } + return out +} + +// DerivedDomain returns the declaration's explicit Domain, or the key's first +// dot segment. The derived value feeds the Descriptor only — it is never +// written back into the compatibility view, so a declaration that said +// nothing keeps saying nothing in legacy JSON output. +func DerivedDomain(def *KeyDefinition) string { + if def.Domain != "" { + return def.Domain + } + domain, _, found := strings.Cut(def.Key, ".") + if !found || domain == "" { + return def.Key + } + return domain +} + +// deepCopyDefinition clones every mutable member so neither the caller's +// declaration nor a compiled entry can be changed through the other. +func deepCopyDefinition(def KeyDefinition) KeyDefinition { + out := def + out.Params = slices.Clone(def.Params) + for i := range out.Params { + out.Params[i].Values = slices.Clone(def.Params[i].Values) + } + out.Scopes = slices.Clone(def.Scopes) + out.AuthTypes = slices.Clone(def.AuthTypes) + out.RequiredConsoleEvents = slices.Clone(def.RequiredConsoleEvents) + if def.Schema.Native != nil { + spec := *def.Schema.Native + spec.Raw = slices.Clone(def.Schema.Native.Raw) + out.Schema.Native = &spec + } + if def.Schema.Custom != nil { + spec := *def.Schema.Custom + spec.Raw = slices.Clone(def.Schema.Custom.Raw) + out.Schema.Custom = &spec + } + // A plain map clone is not enough here: FieldMeta.Enum is a slice, so the + // cloned map's values would still share the Enum backing arrays with the + // source. Copy each entry and clone its slice members. + if def.Schema.FieldOverrides != nil { + overrides := make(map[string]schemas.FieldMeta, len(def.Schema.FieldOverrides)) + for path, meta := range def.Schema.FieldOverrides { + meta.Enum = slices.Clone(meta.Enum) + overrides[path] = meta + } + out.Schema.FieldOverrides = overrides + } + return out +} diff --git a/internal/event/catalog/compile.go b/internal/event/catalog/compile.go new file mode 100644 index 0000000000..9019bb9509 --- /dev/null +++ b/internal/event/catalog/compile.go @@ -0,0 +1,291 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/larksuite/cli/internal/event/schemas" +) + +// Compile canonicalizes and validates every declaration, resolves each key's +// output schema, and projects the result into an immutable snapshot. It is +// the only way to obtain a Snapshot; invalid declarations produce an error +// and never a live snapshot. +func Compile(defs []KeyDefinition, strategies StrategySet) (*Snapshot, error) { + var problems []string + fail := func(format string, args ...any) { + problems = append(problems, fmt.Sprintf(format, args...)) + } + + entries := make(map[string]*Entry, len(defs)) + keys := make([]string, 0, len(defs)) + + for i := range defs { + def := Canonicalize(defs[i]) + + if def.Key == "" { + fail("declaration %d: Key must not be empty", i) + continue + } + if _, dup := entries[def.Key]; dup { + fail("duplicate EventKey: %s", def.Key) + continue + } + if errs := validateDefinition(&def); len(errs) > 0 { + problems = append(problems, errs...) + continue + } + + schemaJSON, orphans, err := resolveSchemaJSON(&def) + if err != nil { + fail("EventKey %s: resolve output schema: %v", def.Key, err) + continue + } + if len(orphans) > 0 { + fail("EventKey %s: field overrides point at paths the schema does not have: %s", + def.Key, strings.Join(orphans, ", ")) + continue + } + if isPlaceholderSchema(schemaJSON) { + fail("EventKey %s: declared schema resolves to an empty placeholder; declare the real output shape", def.Key) + continue + } + + preparation := StrategyNone + if def.PreConsume != nil { + preparation = StrategyLegacyPreConsume + } + if strategies == nil || !strategies.Has(preparation) { + fail("EventKey %s: preparation strategy %q is not provided by the strategy set", def.Key, preparation) + continue + } + + mode := OutputProcessed + if def.Schema.Native != nil { + mode = OutputNative + } + entries[def.Key] = &Entry{ + descriptor: Descriptor{ + Key: def.Key, + Domain: DerivedDomain(&def), + DisplayName: def.DisplayName, + Description: def.Description, + EventType: def.EventType, + SubscriptionType: def.SubscriptionType, + Params: cloneParams(def.Params), + Scopes: append([]string(nil), def.Scopes...), + AuthTypes: append([]string(nil), def.AuthTypes...), + RequiredConsoleEvents: append([]string(nil), def.RequiredConsoleEvents...), + }, + output: OutputContract{ + Mode: mode, + SchemaJSON: schemaJSON, + JQRootPath: jqRootPath(mode), + }, + capability: Capability{ + Preparation: preparation, + BufferSize: def.BufferSize, + Workers: def.Workers, + SingleConsumer: def.SingleConsumer, + }, + binding: RuntimeBinding{ + NormalizeParams: def.NormalizeParams, + Match: def.Match, + Process: def.Process, + PreConsume: def.PreConsume, + }, + canonical: def, + } + keys = append(keys, def.Key) + } + + if len(problems) > 0 { + return nil, errors.New("event catalog rejected:\n " + strings.Join(problems, "\n ")) + } + + sort.Strings(keys) + return &Snapshot{entries: entries, keys: keys}, nil +} + +// jqRootPath states where consumers address output fields from: native keys +// deliver the V2 envelope (fields under .event), processed keys deliver the +// processor's flat shape (fields at the root). +func jqRootPath(mode OutputMode) string { + if mode == OutputNative { + return ".event" + } + return "." +} + +// validateDefinition runs the per-declaration contract checks. It reports +// every violation instead of stopping at the first. +func validateDefinition(def *KeyDefinition) []string { + var out []string + fail := func(format string, args ...any) { + out = append(out, fmt.Sprintf(format, args...)) + } + + if def.EventType == "" { + fail("EventKey %s: EventType must not be empty", def.Key) + } + if def.SubscriptionType != SubTypeEvent && def.SubscriptionType != SubTypeCallback { + fail("EventKey %s: SubscriptionType must be %q or %q; got %q", + def.Key, SubTypeEvent, SubTypeCallback, def.SubscriptionType) + } + if def.Domain != "" && def.Domain != DerivedDomain(&KeyDefinition{Key: def.Key}) { + fail("EventKey %s: explicit Domain %q does not match the key's first segment", def.Key, def.Domain) + } + + nativeSet := def.Schema.Native != nil + customSet := def.Schema.Custom != nil + switch { + case nativeSet && customSet: + fail("EventKey %s: Schema.Native and Schema.Custom are mutually exclusive", def.Key) + case !nativeSet && !customSet: + fail("EventKey %s: Schema requires either Native or Custom", def.Key) + } + if nativeSet && def.Process != nil { + fail("EventKey %s: Schema.Native forbids Process (Process produces a complete shape — use Schema.Custom)", def.Key) + } + // The inverse also holds: a custom schema promises a processed output + // shape, and only a Process can produce it. Without one, the runtime + // would pass the raw envelope through — a shape the schema never + // described. + if customSet && def.Process == nil { + fail("EventKey %s: Schema.Custom requires Process (without it the raw envelope would pass through, outside the declared schema)", def.Key) + } + if spec := def.Schema.Native; spec != nil { + out = append(out, validateSpec(def.Key, "Schema.Native", spec)...) + } + if spec := def.Schema.Custom; spec != nil { + out = append(out, validateSpec(def.Key, "Schema.Custom", spec)...) + } + + for _, p := range def.Params { + switch p.Type { + case "", ParamString, ParamBool, ParamInt: + case ParamEnum, ParamMulti: + if len(p.Values) == 0 { + fail("EventKey %s: param %q type %q requires Values", def.Key, p.Name, p.Type) + } + for _, v := range p.Values { + if v.Desc == "" { + fail("EventKey %s: param %q value %q requires non-empty Desc", def.Key, p.Name, v.Value) + } + } + default: + fail("EventKey %s: param %q has unknown type %q", def.Key, p.Name, p.Type) + } + } + + for _, t := range def.AuthTypes { + if t != "user" && t != "bot" { + fail("EventKey %s: AuthTypes elements must be \"user\" or \"bot\"; got %q", def.Key, t) + } + } + return out +} + +func validateSpec(key, field string, s *SchemaSpec) []string { + typeSet := s.Type != nil + rawSet := len(s.Raw) > 0 + if typeSet == rawSet { + return []string{fmt.Sprintf("EventKey %s: %s requires exactly one of Type or Raw", key, field)} + } + // A raw schema must be a decodable JSON object; the placeholder check + // downstream only sees schemas that decoded, so garbage bytes have to be + // rejected here. + if rawSet { + var asObject map[string]json.RawMessage + if err := json.Unmarshal(s.Raw, &asObject); err != nil { + return []string{fmt.Sprintf("EventKey %s: %s.Raw is not a JSON object: %v", key, field, err)} + } + } + return nil +} + +// resolveSchemaJSON returns the final JSON Schema for a declaration +// (reflected base, V2-wrapped for native keys, field overlay applied); +// orphans lists override pointers that resolved to nothing. +func resolveSchemaJSON(def *KeyDefinition) (json.RawMessage, []string, error) { + spec, isNative := pickSpec(def.Schema) + if spec == nil { + return nil, nil, nil + } + + base, err := renderSpec(spec) + if err != nil { + return nil, nil, err + } + if base == nil { + return nil, nil, nil + } + + if isNative { + base = schemas.WrapV2Envelope(base) + } + + if len(def.Schema.FieldOverrides) > 0 { + var parsed map[string]any + if err := json.Unmarshal(base, &parsed); err != nil { + return nil, nil, fmt.Errorf("parse base schema for field overrides: %w", err) + } + orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides) + out, err := json.Marshal(parsed) + if err != nil { + return nil, nil, fmt.Errorf("serialize schema with field overrides: %w", err) + } + return out, orphans, nil + } + + return base, nil, nil +} + +// pickSpec returns the non-nil spec and whether it is native (V2-wrapped). +func pickSpec(s SchemaDef) (*SchemaSpec, bool) { + if s.Native != nil { + return s.Native, true + } + if s.Custom != nil { + return s.Custom, false + } + return nil, false +} + +// renderSpec produces a JSON Schema from Type (reflected) or Raw (copied). +func renderSpec(s *SchemaSpec) (json.RawMessage, error) { + if s.Type != nil { + return schemas.FromType(s.Type), nil + } + if len(s.Raw) > 0 { + buf := make(json.RawMessage, len(s.Raw)) + copy(buf, s.Raw) + return buf, nil + } + return nil, errors.New("schema spec has neither Type nor Raw") +} + +// isPlaceholderSchema rejects declarations whose schema decodes but describes +// nothing: an empty document, empty object, or null. Per-declaration checks +// only see "raw bytes are non-empty" — this closes that gap. +func isPlaceholderSchema(schema json.RawMessage) bool { + trimmed := bytes.TrimSpace(schema) + if len(trimmed) == 0 { + return true + } + if bytes.Equal(trimmed, []byte("null")) || bytes.Equal(trimmed, []byte("{}")) { + return true + } + var asMap map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &asMap); err == nil && len(asMap) == 0 { + return true + } + return false +} diff --git a/internal/event/catalog/compile_test.go b/internal/event/catalog/compile_test.go new file mode 100644 index 0000000000..3fd4261d85 --- /dev/null +++ b/internal/event/catalog/compile_test.go @@ -0,0 +1,249 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/schemas" +) + +var testStrategies = StrategyRefs{StrategyNone, StrategyLegacyPreConsume} + +func validDef() KeyDefinition { + return KeyDefinition{ + Key: "demo.thing.updated_v1", + EventType: "demo.thing.updated_v1", + Schema: SchemaDef{Custom: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)}}, + Process: func(context.Context, processing.APIClient, *model.Event, map[string]string) (json.RawMessage, error) { + return json.RawMessage(`{}`), nil + }, + } +} + +// The rejection side: every contract violation must fail the compile, and a +// failed compile must never hand back a live snapshot. +func TestCompile_RejectsContractViolations(t *testing.T) { + cases := map[string]struct { + mutate func(*KeyDefinition) + wantMsg string + }{ + "empty event type": { + func(d *KeyDefinition) { d.EventType = "" }, + "EventType must not be empty", + }, + "bad subscription type": { + func(d *KeyDefinition) { d.SubscriptionType = "webhook" }, + "SubscriptionType must be", + }, + "native and custom together": { + func(d *KeyDefinition) { + d.Schema.Native = &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"x":{}}}`)} + }, + "mutually exclusive", + }, + "neither native nor custom": { + func(d *KeyDefinition) { d.Schema = SchemaDef{} }, + "requires either Native or Custom", + }, + "native with process": { + func(d *KeyDefinition) { + d.Schema = SchemaDef{Native: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"x":{}}}`)}} + d.Process = func(context.Context, processing.APIClient, *model.Event, map[string]string) (json.RawMessage, error) { + return nil, nil + } + }, + "forbids Process", + }, + "spec with both type and raw": { + func(d *KeyDefinition) { + d.Schema.Custom.Type = jsonObjectType() + }, + "exactly one of Type or Raw", + }, + "enum param without values": { + func(d *KeyDefinition) { + d.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "mode"}} + }, + "requires Values", + }, + "enum value without desc": { + func(d *KeyDefinition) { + d.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "mode", Values: []ParamValue{{Value: "a"}}}} + }, + "requires non-empty Desc", + }, + "unknown param type": { + func(d *KeyDefinition) { + d.Params = []ParamDef{{Name: "x", Type: "float", Description: "x"}} + }, + "unknown type", + }, + "bad auth type": { + func(d *KeyDefinition) { d.AuthTypes = []string{"tenant"} }, + `must be "user" or "bot"`, + }, + "explicit domain mismatch": { + func(d *KeyDefinition) { d.Domain = "gadget" }, + "does not match the key's first segment", + }, + "custom schema without process": { + func(d *KeyDefinition) { d.Process = nil }, + "Schema.Custom requires Process", + }, + "raw schema with garbage bytes": { + func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`this is {{{ not json`) }, + "is not a JSON object", + }, + "placeholder object schema": { + func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`{}`) }, + "empty placeholder", + }, + "placeholder null schema": { + func(d *KeyDefinition) { d.Schema.Custom.Raw = json.RawMessage(`null`) }, + "empty placeholder", + }, + "orphan field override": { + func(d *KeyDefinition) { + d.Schema.FieldOverrides = map[string]schemas.FieldMeta{ + "/no/such/path": {Description: "dangling"}, + } + }, + "paths the schema does not have", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + def := validDef() + tc.mutate(&def) + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err == nil { + t.Fatalf("compile must reject this declaration") + } + if !strings.Contains(err.Error(), tc.wantMsg) { + t.Errorf("error should mention %q, got: %v", tc.wantMsg, err) + } + if snap != nil { + t.Error("a failed compile must never produce a live snapshot") + } + }) + } +} + +func TestCompile_RejectsDuplicateKeys(t *testing.T) { + snap, err := Compile([]KeyDefinition{validDef(), validDef()}, testStrategies) + if err == nil || !strings.Contains(err.Error(), "duplicate EventKey") { + t.Fatalf("want duplicate-key rejection, got err=%v", err) + } + if snap != nil { + t.Error("a failed compile must never produce a live snapshot") + } +} + +func TestCompile_RejectsUnknownStrategy(t *testing.T) { + def := validDef() + def.PreConsume = func(context.Context, processing.APIClient, map[string]string) (func() error, error) { + return nil, nil + } + // A strategy set without legacy_preconsume cannot host a PreConsume key. + snap, err := Compile([]KeyDefinition{def}, StrategyRefs{StrategyNone}) + if err == nil || !strings.Contains(err.Error(), "strategy") { + t.Fatalf("want strategy rejection, got err=%v", err) + } + if snap != nil { + t.Error("a failed compile must never produce a live snapshot") + } +} + +// The acceptance side: a compile that rejects everything would be just as +// broken as one that accepts everything. +func TestCompile_AcceptsWellFormedDeclarations(t *testing.T) { + withPrep := validDef() + withPrep.Key = "demo.other.created_v1" + withPrep.EventType = withPrep.Key + withPrep.PreConsume = func(context.Context, processing.APIClient, map[string]string) (func() error, error) { + return nil, nil + } + + snap, err := Compile([]KeyDefinition{validDef(), withPrep}, testStrategies) + if err != nil { + t.Fatalf("well-formed declarations must compile: %v", err) + } + if snap.Len() != 2 { + t.Fatalf("compiled %d keys, want 2", snap.Len()) + } + + plain, _ := snap.Resolve("demo.thing.updated_v1") + if got := plain.Capability().Preparation; got != StrategyNone { + t.Errorf("key without PreConsume must project strategy %q, got %q", StrategyNone, got) + } + prepared, _ := snap.Resolve("demo.other.created_v1") + if got := prepared.Capability().Preparation; got != StrategyLegacyPreConsume { + t.Errorf("key with PreConsume must project strategy %q, got %q", StrategyLegacyPreConsume, got) + } +} + +func TestCompile_CanonicalizesDefaults(t *testing.T) { + def := validDef() + def.BufferSize = 5000 // above the cap + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + got := entry.Definition() + if got.SubscriptionType != SubTypeEvent { + t.Errorf("empty SubscriptionType must canonicalize to %q, got %q", SubTypeEvent, got.SubscriptionType) + } + if got.BufferSize != MaxBufferSize { + t.Errorf("BufferSize must clamp to %d, got %d", MaxBufferSize, got.BufferSize) + } + if got.Workers != 1 { + t.Errorf("Workers must default to 1, got %d", got.Workers) + } + cap := entry.Capability() + if cap.BufferSize != MaxBufferSize || cap.Workers != 1 { + t.Errorf("capability must carry canonicalized delivery values, got %+v", cap) + } +} + +func TestCompile_ProjectsOutputContract(t *testing.T) { + custom := validDef() + native := KeyDefinition{ + Key: "demo.native.updated_v1", + EventType: "demo.native.updated_v1", + Schema: SchemaDef{Native: &SchemaSpec{Raw: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`)}}, + } + snap, err := Compile([]KeyDefinition{custom, native}, testStrategies) + if err != nil { + t.Fatal(err) + } + + c, _ := snap.Resolve(custom.Key) + if out := c.Output(); out.Mode != OutputProcessed || out.JQRootPath != "." || len(out.SchemaJSON) == 0 { + t.Errorf("custom key contract wrong: %+v", out) + } + n, _ := snap.Resolve(native.Key) + if out := n.Output(); out.Mode != OutputNative || out.JQRootPath != ".event" || len(out.SchemaJSON) == 0 { + t.Errorf("native key contract wrong: %+v", out) + } + // Native schemas are delivered inside the V2 envelope; the resolved + // schema must describe the envelope, not the bare body. + if !strings.Contains(string(n.Output().SchemaJSON), `"header"`) { + t.Error("native schema must be wrapped in the V2 envelope shape") + } +} + +func jsonObjectType() reflect.Type { + return reflect.TypeOf(struct { + ID string `json:"id"` + }{}) +} diff --git a/internal/event/catalog/definition.go b/internal/event/catalog/definition.go new file mode 100644 index 0000000000..5e6aac1faa --- /dev/null +++ b/internal/event/catalog/definition.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package catalog owns the EventKey declaration types and compiles them into +// a validated, immutable snapshot. Declarations are compile-time inputs: they +// are aggregated explicitly (see events.All), checked as a whole, and +// projected into read-only entries — there is no runtime registration. +package catalog + +import ( + "context" + "encoding/json" + "reflect" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/schemas" +) + +const ( + DefaultBufferSize = 100 + MaxBufferSize = 1000 +) + +type ParamType string + +const ( + ParamString ParamType = "string" + ParamEnum ParamType = "enum" + ParamMulti ParamType = "multi" + ParamBool ParamType = "bool" + 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"` + Desc string `json:"desc"` +} + +type ParamDef struct { + Name string `json:"name"` + Type ParamType `json:"type"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` + Description string `json:"description"` + Values []ParamValue `json:"values,omitempty"` + + // SubscriptionKey marks this param as part of the subscription identity. + // Two consumers of the same EventKey but different values for any + // SubscriptionKey-marked param are treated as DISTINCT subscriptions: + // PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per + // (EventKey, SubscriptionID). + // + // CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side + // subscribe/unsubscribe API is itself scoped to that resource. Lark keys the + // subscription record by (app, user, event_type) and overwrites it rather + // than reference-counting, so for a non-per-resource API the cleanup of one + // resource's last consumer unsubscribes the shared record and silently cuts + // off every other resource sharing that event_type. + // + // Default false = the param is a filter / formatting / metadata param + // and does not affect subscription identity. + SubscriptionKey bool `json:"subscription_key,omitempty"` +} + +type ProcessFunc = func(ctx context.Context, rt processing.APIClient, raw *model.Event, params map[string]string) (json.RawMessage, error) + +// SchemaDef: exactly one of Native or Custom must be set. +// Native auto-wraps the SDK type in the V2 envelope; Custom passes through verbatim. +type SchemaDef struct { + Native *SchemaSpec `json:"native,omitempty"` + Custom *SchemaSpec `json:"custom,omitempty"` + FieldOverrides map[string]schemas.FieldMeta `json:"field_overrides,omitempty"` +} + +// SchemaSpec: exactly one of Type or Raw. +type SchemaSpec struct { + Type reflect.Type `json:"-"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +type KeyDefinition struct { + Key string `json:"key"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + EventType string `json:"event_type"` + + // Domain is optional in declarations: when empty it is derived from the + // key's first dot segment. When set it must match that segment — the + // compiler rejects a mismatch. The derived value lives on the Descriptor + // only; this field keeps whatever the declaration said (usually nothing), + // so legacy JSON output is unchanged. + Domain string `json:"domain,omitempty"` + + // SubscriptionType selects which console "底账" the precheck reads. + // Empty is normalized to SubTypeEvent by Canonicalize. + SubscriptionType SubscriptionType `json:"subscription_type,omitempty"` + + Params []ParamDef `json:"params,omitempty"` + + Schema SchemaDef `json:"schema"` + + // NormalizeParams canonicalizes param values BEFORE fingerprint compute, + // PreConsume, Match, and Process. Mutates the params map in place. + // May call OAPI; runs once per consumer at startup — the deciding layer + // runs it and hands the normalized values to the stream host, which then + // skips the hook (Options.ParamsNormalized). + // + // Use cases: resolve aliases ("me" -> real email, a name -> an ID), + // trim whitespace. On error, consume fails (no retry); caller gets the + // wrapped error. + // + // Default nil = no normalization, params pass through unchanged. + NormalizeParams func(ctx context.Context, rt processing.APIClient, params map[string]string) error `json:"-"` + + // Process required when Schema.Custom is Processed output; must be nil when Native is used. + // + // Outcome convention: a non-nil result is emitted; (nil, nil) drops the + // event silently; processing.DropMalformed drops it with a malformed + // diagnostic; any other error drops it with a process-error diagnostic. + // Nothing a Process returns may fall outside the declared schema. + Process ProcessFunc `json:"-"` + + // Match is a synchronous payload filter run on every received event + // BEFORE Process. Return false to drop the event without further work. + // + // Signature deliberately omits ctx/rt to physically enforce "no OAPI + // calls in Match". For filters that need a metadata fetch first, use + // Process and return nil to drop. + // + // Default nil = accept all events. + Match func(raw *model.Event, params map[string]string) bool `json:"-"` + + // PreConsume runs once per (EventKey, SubscriptionID) when this consumer + // is first for that scope. Returns a cleanup function that the framework + // invokes when this consumer is the last for its scope. + // + // The cleanup's error return is honored: on nil the framework prints + // "[event] cleanup done."; on non-nil it prints a WARN with an + // idempotency note. + PreConsume func(ctx context.Context, rt processing.APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"` + + Scopes []string `json:"scopes,omitempty"` + + // AuthTypes: whitelist of identities the EventKey accepts. Empty = no identity required. + AuthTypes []string `json:"auth_types,omitempty"` + + RequiredConsoleEvents []string `json:"required_console_events,omitempty"` + + 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"` +} diff --git a/internal/event/catalog/params.go b/internal/event/catalog/params.go new file mode 100644 index 0000000000..13d47a113a --- /dev/null +++ b/internal/event/catalog/params.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "sort" + "strings" + + "github.com/larksuite/cli/errs" +) + +// ValidateParams applies declared defaults into params, then rejects missing +// required and undeclared parameters. It is the single implementation every +// layer validates against, so a decision can never accept parameters the +// runtime host would refuse. +func ValidateParams(def *KeyDefinition, params map[string]string) error { + for _, p := range def.Params { + if _, ok := params[p.Name]; !ok && p.Default != "" { + params[p.Name] = p.Default + } + } + for _, p := range def.Params { + if p.Required { + if _, ok := params[p.Name]; !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "required param %q missing for EventKey %s", p.Name, def.Key). + WithParam("--param"). + WithHint("pass it as --param %s=; run `lark-cli event schema %s` for details", p.Name, def.Key) + } + } + } + known := make(map[string]bool, len(def.Params)) + validNames := make([]string, 0, len(def.Params)) + for _, p := range def.Params { + known[p.Name] = true + validNames = append(validNames, p.Name) + } + sort.Strings(validNames) + unknown := make([]string, 0, len(params)) + for k := range params { + if !known[k] { + unknown = append(unknown, k) + } + } + if len(unknown) > 0 { + // Sorted so the reported name does not vary with map iteration order. + sort.Strings(unknown) + k := unknown[0] + if len(validNames) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown param %q: EventKey %s accepts no params", k, def.Key). + WithParam("--param"). + WithHint("run `lark-cli event schema %s` for details", def.Key) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")). + WithParam("--param"). + WithHint("run `lark-cli event schema %s` for details", def.Key) + } + return nil +} diff --git a/internal/event/catalog/scope.go b/internal/event/catalog/scope.go new file mode 100644 index 0000000000..c1f9faa2d4 --- /dev/null +++ b/internal/event/catalog/scope.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "sort" +) + +// SubscriptionScope returns a stable identifier scoped to (EventKey, values +// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup +// preparation/cleanup gates and key per-subscription accounting. No +// SubscriptionKey params -> returns def.Key verbatim (legacy one-dimensional +// behavior). +// +// Stability contract: same EventKey + same normalized param values -> same ID +// across CLI versions; changing the encoding requires a wire-format bump. +func SubscriptionScope(def *KeyDefinition, params map[string]string) string { + type kv struct { + Name string `json:"name"` + Value string `json:"value"` + } + var subParams []kv + for _, p := range def.Params { + if !p.SubscriptionKey { + continue + } + subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]}) + } + if len(subParams) == 0 { + return def.Key + } + sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name }) + raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields + sum := sha256.Sum256(raw) + return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12]) +} diff --git a/internal/event/catalog/snapshot.go b/internal/event/catalog/snapshot.go new file mode 100644 index 0000000000..c3d42084cd --- /dev/null +++ b/internal/event/catalog/snapshot.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "context" + "encoding/json" + "slices" + "sort" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" +) + +// Descriptor holds the declaration's display facts: everything list/schema +// render, nothing that executes. Domain is always resolved here even when the +// declaration left it empty. +type Descriptor struct { + Key string + Domain string + DisplayName string + Description string + EventType string + SubscriptionType SubscriptionType + Params []ParamDef + Scopes []string + AuthTypes []string + RequiredConsoleEvents []string +} + +// OutputMode states which side of the output contract a key lives on. +type OutputMode string + +const ( + // OutputNative delivers the raw V2 envelope verbatim. + OutputNative OutputMode = "native" + // OutputProcessed delivers what the key's processor emits — and only that. + OutputProcessed OutputMode = "processed" +) + +// OutputContract is the compiled promise about a key's stdout: the fully +// resolved schema and the jq root consumers address fields from. Resolving at +// compile time means an unresolvable schema or a dangling field override is a +// startup failure, not a silently degraded rendering. +type OutputContract struct { + Mode OutputMode + SchemaJSON json.RawMessage + JQRootPath string +} + +// Capability describes how a key's delivery is provisioned and bounded, in +// serializable form: which preparation strategy readies it, how deliveries +// are buffered, and whether a second consumer is rejected. +type Capability struct { + Preparation StrategyRef + BufferSize int + Workers int + SingleConsumer bool +} + +// RuntimeBinding carries the declaration's executable hooks. It has no JSON +// tags on purpose: behavior never travels through a rendering path. +type RuntimeBinding struct { + NormalizeParams func(ctx context.Context, rt processing.APIClient, params map[string]string) error + Match func(raw *model.Event, params map[string]string) bool + Process ProcessFunc + PreConsume func(ctx context.Context, rt processing.APIClient, params map[string]string) (cleanup func() error, err error) +} + +// Entry is one compiled key: four projections composed read-only. Definition +// reassembles the canonical compatibility view from them, which doubles as +// the proof that the projection lost nothing. +type Entry struct { + descriptor Descriptor + output OutputContract + capability Capability + binding RuntimeBinding + // canonical is the Canonicalize'd declaration the entry was compiled + // from; Definition returns deep copies of it. + canonical KeyDefinition +} + +func (e *Entry) Descriptor() Descriptor { + d := e.descriptor + d.Params = cloneParams(e.descriptor.Params) + d.Scopes = slices.Clone(e.descriptor.Scopes) + d.AuthTypes = slices.Clone(e.descriptor.AuthTypes) + d.RequiredConsoleEvents = slices.Clone(e.descriptor.RequiredConsoleEvents) + return d +} + +func (e *Entry) Output() OutputContract { + o := e.output + o.SchemaJSON = slices.Clone(e.output.SchemaJSON) + return o +} + +func (e *Entry) Capability() Capability { return e.capability } + +func (e *Entry) Binding() RuntimeBinding { return e.binding } + +// Definition returns the canonical compatibility view of the declaration this +// entry was compiled from. Callers may mutate the returned value freely. +func (e *Entry) Definition() *KeyDefinition { + def := deepCopyDefinition(e.canonical) + return &def +} + +func cloneParams(params []ParamDef) []ParamDef { + out := slices.Clone(params) + for i := range out { + out[i].Values = slices.Clone(params[i].Values) + } + return out +} + +// Snapshot is the compiled, immutable catalog. Accessors return values or +// fresh copies — never pointers into the snapshot's own state. +type Snapshot struct { + entries map[string]*Entry + keys []string // pre-sorted +} + +// Keys returns every compiled key in stable sorted order. +func (s *Snapshot) Keys() []string { return slices.Clone(s.keys) } + +// Len reports how many keys were compiled. +func (s *Snapshot) Len() int { return len(s.keys) } + +// Resolve returns the compiled entry for key, with ok=false for a key the +// catalog does not have. Every projection (descriptor, output, capability, +// binding) is read off the returned entry, so facts about one key can never +// be paired with another key's. +func (s *Snapshot) Resolve(key string) (*Entry, bool) { + e, ok := s.entries[key] + return e, ok +} + +// Entries returns all compiled entries in key order. +func (s *Snapshot) Entries() []*Entry { + out := make([]*Entry, 0, len(s.keys)) + for _, k := range s.keys { + out = append(out, s.entries[k]) + } + return out +} + +// Definitions returns the canonical compatibility view of every entry in key +// order. Each element is a fresh deep copy, like Entry.Definition. +func (s *Snapshot) Definitions() []*KeyDefinition { + out := make([]*KeyDefinition, 0, len(s.keys)) + for _, k := range s.keys { + out = append(out, s.entries[k].Definition()) + } + return out +} + +// Domains returns the sorted, deduplicated domain set across all entries. +func (s *Snapshot) Domains() []string { + seen := map[string]bool{} + for _, e := range s.entries { + seen[e.descriptor.Domain] = true + } + out := make([]string, 0, len(seen)) + for d := range seen { + out = append(out, d) + } + sort.Strings(out) + return out +} + +// EventTypes returns the sorted, deduplicated set of upstream event types — +// what a bus subscribes to the platform with. +func (s *Snapshot) EventTypes() []string { + seen := map[string]bool{} + for _, e := range s.entries { + seen[e.descriptor.EventType] = true + } + out := make([]string, 0, len(seen)) + for t := range seen { + out = append(out, t) + } + sort.Strings(out) + return out +} diff --git a/internal/event/catalog/snapshot_test.go b/internal/event/catalog/snapshot_test.go new file mode 100644 index 0000000000..f4d2d0938a --- /dev/null +++ b/internal/event/catalog/snapshot_test.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" + "github.com/larksuite/cli/internal/event/schemas" +) + +func compiledFixture(t *testing.T) *Snapshot { + t.Helper() + def := validDef() + def.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "mode", + Values: []ParamValue{{Value: "a", Desc: "first"}}}} + def.Scopes = []string{"demo:read"} + def.AuthTypes = []string{"user"} + def.RequiredConsoleEvents = []string{"demo.thing.updated_v1"} + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + return snap +} + +// Mutating anything an accessor returns must not affect what the snapshot +// hands out next — otherwise one caller can silently rewrite the catalog for +// every other caller. +func TestSnapshot_IsImmutableFromOutside(t *testing.T) { + snap := compiledFixture(t) + entry, ok := snap.Resolve(validDef().Key) + if !ok { + t.Fatal("the compiled fixture does not contain its own key") + } + + d := entry.Descriptor() + d.Params[0].Name = "tampered" + d.Params[0].Values[0].Value = "tampered" + d.Scopes[0] = "tampered" + d.AuthTypes[0] = "tampered" + d.RequiredConsoleEvents[0] = "tampered" + if fresh := entry.Descriptor(); fresh.Params[0].Name == "tampered" || + fresh.Params[0].Values[0].Value == "tampered" || + fresh.Scopes[0] == "tampered" || + fresh.AuthTypes[0] == "tampered" || + fresh.RequiredConsoleEvents[0] == "tampered" { + t.Error("mutating a returned Descriptor leaked into the snapshot") + } + + out := entry.Output() + if len(out.SchemaJSON) > 0 { + out.SchemaJSON[0] = '!' + if fresh := entry.Output(); fresh.SchemaJSON[0] == '!' { + t.Error("mutating a returned schema leaked into the snapshot") + } + } + + def := entry.Definition() + def.Params[0].Name = "tampered" + def.Scopes[0] = "tampered" + if def.Schema.Custom == nil || len(def.Schema.Custom.Raw) == 0 { + t.Fatal("the fixture no longer declares raw custom schema bytes; this check needs them") + } + def.Schema.Custom.Raw[0] = '!' + if fresh := entry.Definition(); fresh.Params[0].Name == "tampered" || + fresh.Scopes[0] == "tampered" || + fresh.Schema.Custom.Raw[0] == '!' { + t.Error("mutating a returned Definition leaked into the snapshot") + } + + keys := snap.Keys() + keys[0] = "tampered" + if snap.Keys()[0] == "tampered" { + t.Error("mutating the returned key list leaked into the snapshot") + } +} + +// FieldOverrides values carry a slice-typed member (FieldMeta.Enum), so a +// shallow map clone still shares the Enum backing arrays: writing through one +// copy would rewrite the catalog for everyone. Both directions must hold — +// a returned Definition and the original compile input are equally outside. +func TestSnapshot_FieldOverrideEnumIsNotShared(t *testing.T) { + def := validDef() + def.Schema.FieldOverrides = map[string]schemas.FieldMeta{ + "/id": {Description: "the id", Enum: []string{"a", "b"}}, + } + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + + got := entry.Definition() + got.Schema.FieldOverrides["/id"].Enum[0] = "tampered-via-definition" + if fresh := entry.Definition(); fresh.Schema.FieldOverrides["/id"].Enum[0] != "a" { + t.Error("mutating a returned Definition's FieldOverrides Enum leaked into the snapshot") + } + + def.Schema.FieldOverrides["/id"].Enum[1] = "tampered-via-input" + if fresh := entry.Definition(); fresh.Schema.FieldOverrides["/id"].Enum[1] != "b" { + t.Error("mutating the compile input's FieldOverrides Enum leaked into the snapshot") + } +} + +// The compiler deep-copies its input: mutating the declaration after Compile +// must not reach the snapshot either. +func TestSnapshot_DoesNotAliasCompileInput(t *testing.T) { + def := validDef() + def.Scopes = []string{"demo:read"} + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + def.Scopes[0] = "tampered" + entry, _ := snap.Resolve(def.Key) + if entry.Definition().Scopes[0] == "tampered" { + t.Error("the snapshot aliases the caller's declaration") + } +} + +// keyDefinitionRouting states, for every KeyDefinition field, which projection +// carries it. A new field must be routed here (and actually projected) before +// it ships — this is the structural check the round-trip test cannot do, +// because an unprojected field is zero on both sides of a round trip. +var keyDefinitionRouting = map[string]string{ + "Key": "Descriptor", + "DisplayName": "Descriptor", + "Description": "Descriptor", + "EventType": "Descriptor", + "Domain": "Descriptor (derived; compat view keeps the raw declaration)", + "SubscriptionType": "Descriptor", + "Params": "Descriptor", + "Schema": "OutputContract (resolved schema + mode)", + "NormalizeParams": "RuntimeBinding", + "Process": "RuntimeBinding", + "Match": "RuntimeBinding", + "PreConsume": "RuntimeBinding + Capability.Preparation", + "Scopes": "Descriptor", + "AuthTypes": "Descriptor", + "RequiredConsoleEvents": "Descriptor", + "BufferSize": "Capability", + "Workers": "Capability", + "SingleConsumer": "Capability", +} + +func TestProjection_EveryKeyDefinitionFieldIsRouted(t *testing.T) { + typ := reflect.TypeFor[KeyDefinition]() + if typ.NumField() == 0 { + t.Fatal("KeyDefinition has no fields; the gate scanned nothing") + } + seen := map[string]bool{} + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + seen[name] = true + if _, ok := keyDefinitionRouting[name]; !ok { + t.Errorf("KeyDefinition.%s is not routed to any projection; route it in the compiler and record it here", name) + } + } + for name := range keyDefinitionRouting { + if !seen[name] { + t.Errorf("routing entry %q is stale: KeyDefinition no longer has that field", name) + } + } +} + +// The round-trip half of the projection proof: a compiled entry's +// compatibility view equals the canonicalized input, hooks included. +func TestProjection_DefinitionRoundTripsCanonicalInput(t *testing.T) { + var normalizeCalls, processCalls int + def := validDef() + def.DisplayName = "Demo thing updated" + def.Description = "fires when the demo thing changes" + def.Params = []ParamDef{{Name: "mode", Type: ParamEnum, Description: "m", + Values: []ParamValue{{Value: "a", Desc: "first"}}, SubscriptionKey: true}} + def.Scopes = []string{"demo:read"} + def.AuthTypes = []string{"user", "bot"} + def.RequiredConsoleEvents = []string{"demo.thing.updated_v1"} + def.SingleConsumer = true + def.NormalizeParams = func(context.Context, processing.APIClient, map[string]string) error { + normalizeCalls++ + return nil + } + def.Process = func(context.Context, processing.APIClient, *model.Event, map[string]string) (json.RawMessage, error) { + processCalls++ + return json.RawMessage(`{}`), nil + } + + snap, err := Compile([]KeyDefinition{def}, testStrategies) + if err != nil { + t.Fatal(err) + } + entry, _ := snap.Resolve(def.Key) + got := entry.Definition() + want := Canonicalize(def) + + // Function values cannot be compared with DeepEqual; prove identity by + // invocation, then blank them for the value comparison. + if got.NormalizeParams == nil || got.Process == nil { + t.Fatal("hooks were dropped by the projection") + } + _ = got.NormalizeParams(context.Background(), nil, nil) + _, _ = got.Process(context.Background(), nil, nil, nil) + if normalizeCalls != 1 || processCalls != 1 { + t.Errorf("projected hooks are not the declared functions: normalize=%d process=%d", normalizeCalls, processCalls) + } + got.NormalizeParams, want.NormalizeParams = nil, nil + got.Process, want.Process = nil, nil + + if !reflect.DeepEqual(*got, want) { + t.Errorf("Definition() != Canonicalize(input)\n got: %+v\nwant: %+v", *got, want) + } +} diff --git a/internal/event/catalog/strategy.go b/internal/event/catalog/strategy.go new file mode 100644 index 0000000000..89b531f28f --- /dev/null +++ b/internal/event/catalog/strategy.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package catalog + +import "slices" + +// StrategyRef is the serializable identifier of a consume preparation +// strategy. The catalog stores and validates references only; the executable +// strategies live with the consume application layer, which hands the +// compiler a StrategySet to check references against. +type StrategyRef string + +const ( + // StrategyNone marks a key that needs no preparation before consuming. + StrategyNone StrategyRef = "none" + // StrategyLegacyPreConsume wraps a declaration's PreConsume hook: the + // preparation decision is opaque until applied, exactly as the hook + // contract has always behaved. + StrategyLegacyPreConsume StrategyRef = "legacy_preconsume" +) + +// StrategySet is the narrow view the compiler needs: reference existence. +// Keeping the interface here (not in the application layer) lets the catalog +// validate without depending on strategy implementations. +type StrategySet interface { + Has(ref StrategyRef) bool +} + +// StrategyRefs is the minimal StrategySet: a fixed collection of references. +type StrategyRefs []StrategyRef + +func (s StrategyRefs) Has(ref StrategyRef) bool { + return slices.Contains(s, ref) +} diff --git a/internal/event/consume/canonical_conflict.go b/internal/event/consume/canonical_conflict.go new file mode 100644 index 0000000000..42de3e7081 --- /dev/null +++ b/internal/event/consume/canonical_conflict.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + + "github.com/larksuite/cli/internal/event/model" +) + +// payloadHeaderClaims holds the payload's header block with its values left +// undecoded. Decoding them one at a time is what keeps a single badly-typed +// field from discarding the claims beside it: encoding/json populates the +// fields it can and still reports an error, so a whole-header decode that +// bails on that error would throw away comparisons it had already resolved. +// +// It exists only for validation at the consume boundary — canonical metadata +// itself always comes from the ingress. +type payloadHeaderClaims struct { + Header map[string]json.RawMessage `json:"header"` +} + +// factComparison pairs one canonical fact with the payload-header field that +// can claim it. The comparisons live in a table, not an if-chain: adding a +// fact to model.Event means adding a row here (a reflection gate enforces it). +// +// name is both the row's identity and the header field it reads: the envelope +// spells these facts the same way the rows are named. +// +// headerDerivedInLegacy marks the facts a legacy bus never sends, which the +// compatibility path fills from the header itself. Comparing those to the +// header they came from asserts nothing, so legacy connections skip them — +// and only them. The facts a legacy frame does carry stay arbitrated. +type factComparison struct { + name string + canonical func(ev *model.Event) string + headerDerivedInLegacy bool +} + +var canonicalFactComparisons = []factComparison{ + {name: "event_id", canonical: func(ev *model.Event) string { return ev.EventID }}, + {name: "event_type", canonical: func(ev *model.Event) string { return ev.EventType }}, + {name: "create_time", canonical: func(ev *model.Event) string { return ev.SourceTime }}, + {name: "app_id", canonical: func(ev *model.Event) string { return ev.AppID }, headerDerivedInLegacy: true}, + {name: "tenant_key", canonical: func(ev *model.Event) string { return ev.TenantKey }, headerDerivedInLegacy: true}, +} + +// checkCanonicalConflict returns the name of the first canonical fact the +// payload header contradicts, or "" when the event may be delivered. +// +// Arbitration is deliberately one-sided: a silent header claims nothing, but +// once the header asserts a fact it must match the canonical value — +// including when the canonical side is empty. An asserted header fact facing +// an empty canonical value means the fact was lost between the ingress and +// this consumer; that is a delivery defect, not "nothing to compare". +// +// The envelope declares every one of these facts as a string. A header that +// asserts one with a different JSON type states something this arbiter cannot +// compare, so it counts as a conflict rather than as silence — otherwise a +// single type flip would be enough to disable arbitration for the rest of the +// header. JSON null is the exception: it is how the envelope spells "absent", +// and it asserts nothing. +// +// A payload that is not a JSON object, or whose header is not one, claims no +// fact at all and is not re-classified here — malformed handling belongs to +// the processing layer. +func checkCanonicalConflict(ev *model.Event, legacy bool) string { + var claims payloadHeaderClaims + if err := json.Unmarshal(ev.Payload, &claims); err != nil { + return "" + } + for _, c := range canonicalFactComparisons { + if legacy && c.headerDerivedInLegacy { + continue + } + raw, asserted := claims.Header[c.name] + if !asserted { + continue + } + var claimed string + if err := json.Unmarshal(raw, &claimed); err != nil { + return c.name + } + if claimed != "" && claimed != c.canonical(ev) { + return c.name + } + } + return "" +} diff --git a/internal/event/consume/canonical_conflict_test.go b/internal/event/consume/canonical_conflict_test.go new file mode 100644 index 0000000000..567f9db01f --- /dev/null +++ b/internal/event/consume/canonical_conflict_test.go @@ -0,0 +1,249 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + "time" + + "github.com/larksuite/cli/internal/event/model" +) + +// rawEventFieldsNotComparable lists the model.Event fields that legitimately +// have no payload-header counterpart, each with the reason. Every other field +// must appear as a comparison row — the reflection walk below enforces it. +var rawEventFieldsNotComparable = map[string]string{ + "Payload": "the value under validation cannot vouch for itself", + "Timestamp": "local observation clock; the upstream envelope deliberately has no counterpart", +} + +// fieldToComparison maps model.Event struct fields to their comparison rows. +var fieldToComparison = map[string]string{ + "EventID": "event_id", + "EventType": "event_type", + "SourceTime": "create_time", + "AppID": "app_id", + "TenantKey": "tenant_key", +} + +// Every canonical fact must be either compared or declared not-comparable +// with a reason. This is the structural check that keeps the comparison table +// honest when model.Event grows a field. +func TestCanonicalConflict_CoversEveryCanonicalField(t *testing.T) { + typ := reflect.TypeFor[model.Event]() + if typ.NumField() == 0 { + t.Fatal("model.Event has no fields; the gate scanned nothing") + } + rows := map[string]bool{} + for _, c := range canonicalFactComparisons { + rows[c.name] = true + } + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, exempt := rawEventFieldsNotComparable[name]; exempt { + if _, alsoCompared := fieldToComparison[name]; alsoCompared { + t.Errorf("field %s is both exempt and compared; pick one", name) + } + continue + } + rowName, ok := fieldToComparison[name] + if !ok { + t.Errorf("model.Event.%s is neither compared nor declared not-comparable; add a comparison row or an exemption with a reason", name) + continue + } + if !rows[rowName] { + t.Errorf("comparison row %q for model.Event.%s is missing from canonicalFactComparisons", rowName, name) + } + } +} + +func conflictBaseEvent() *model.Event { + return &model.Event{ + EventID: "evt-1", + EventType: "im.message.receive_v1", + SourceTime: "1700000000000", + AppID: "cli_app", + TenantKey: "tenant_a", + Timestamp: time.Unix(0, 0), + } +} + +func headerPayload(overrides map[string]string) json.RawMessage { + header := map[string]string{ + "event_id": "evt-1", + "event_type": "im.message.receive_v1", + "create_time": "1700000000000", + "app_id": "cli_app", + "tenant_key": "tenant_a", + } + for k, v := range overrides { + header[k] = v + } + raw, _ := json.Marshal(map[string]any{"schema": "2.0", "header": header, "event": map[string]any{}}) + return raw +} + +// Each row must detect a real mismatch — a row that exists but compares a +// value to itself would pass this suite's structural check yet catch nothing. +func TestCanonicalConflict_DetectsEveryFactMismatch(t *testing.T) { + for _, c := range canonicalFactComparisons { + t.Run(c.name, func(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayload(map[string]string{c.name: "tampered-value"}) + if got := checkCanonicalConflict(ev, false); got != c.name { + t.Errorf("header claiming a different %s must conflict, got %q", c.name, got) + } + }) + } +} + +// The reverse direction: the header keeps its claim but the canonical side +// lost the fact. An asserted claim facing an empty canonical value means the +// fact was dropped between ingress and this consumer — that is a conflict, +// not "nothing to compare". +func TestCanonicalConflict_CatchesLostFacts(t *testing.T) { + blank := map[string]func(*model.Event){ + "event_id": func(ev *model.Event) { ev.EventID = "" }, + "event_type": func(ev *model.Event) { ev.EventType = "" }, + "create_time": func(ev *model.Event) { ev.SourceTime = "" }, + "app_id": func(ev *model.Event) { ev.AppID = "" }, + "tenant_key": func(ev *model.Event) { ev.TenantKey = "" }, + } + if len(blank) != len(canonicalFactComparisons) { + t.Fatalf("blanking table covers %d facts, comparisons have %d; keep them in lockstep", len(blank), len(canonicalFactComparisons)) + } + for _, c := range canonicalFactComparisons { + t.Run(c.name, func(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayload(nil) + blank[c.name](ev) + if got := checkCanonicalConflict(ev, false); got != c.name { + t.Errorf("a lost canonical %s facing an asserted header claim must conflict, got %q", c.name, got) + } + }) + } +} + +// headerPayloadTyped builds a payload whose header values keep their declared +// JSON types, so a test can assert what happens when one field is not a +// string. +func headerPayloadTyped(overrides map[string]any) json.RawMessage { + header := map[string]any{ + "event_id": "evt-1", + "event_type": "im.message.receive_v1", + "create_time": "1700000000000", + "app_id": "cli_app", + "tenant_key": "tenant_a", + } + for k, v := range overrides { + header[k] = v + } + raw, _ := json.Marshal(map[string]any{"schema": "2.0", "header": header, "event": map[string]any{}}) + return raw +} + +// The envelope contract declares every header fact as a string. A header that +// asserts one with a different JSON type is not a value this arbiter can +// compare, and letting it through would disable arbitration for the whole +// header — so it is a conflict. +func TestCanonicalConflict_TypeFlippedClaimConflicts(t *testing.T) { + for _, c := range canonicalFactComparisons { + t.Run(c.name, func(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayloadTyped(map[string]any{c.name: 1700000000000}) + if got := checkCanonicalConflict(ev, false); got != c.name { + t.Errorf("a non-string %s claim must conflict, got %q", c.name, got) + } + }) + } +} + +// The attack the per-field decode closes: one type-flipped field used as a +// carrier for forged identity facts. Decoding the header into typed strings +// fails on the flipped field while still populating the forged ones, so a +// whole-header bail-out would deliver the forgery. +func TestCanonicalConflict_TypeFlipCannotSmuggleForgedIdentity(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayloadTyped(map[string]any{ + "create_time": 1700000000000, + "app_id": "cli_forged", + "tenant_key": "tenant_forged", + }) + if got := checkCanonicalConflict(ev, false); got == "" { + t.Fatal("a header carrying forged identity facts behind a type flip must not be delivered") + } + + // The same payload with every value a string is caught on the first forged + // fact, which proves the flip is the only thing standing between the + // forgery and stdout. + control := conflictBaseEvent() + control.Payload = headerPayload(map[string]string{ + "app_id": "cli_forged", + "tenant_key": "tenant_forged", + }) + if got := checkCanonicalConflict(control, false); got != "app_id" { + t.Errorf("control: forged app_id must conflict, got %q", got) + } +} + +// A null claim asserts nothing, which is the one non-string form that stays +// silent: JSON null is how the platform spells "field not present". +func TestCanonicalConflict_NullClaimStaysSilent(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = headerPayloadTyped(map[string]any{"app_id": nil}) + if got := checkCanonicalConflict(ev, false); got != "" { + t.Errorf("a null claim must deliver, got conflict on %q", got) + } +} + +// A header that is not an object cannot assert any fact, so there is nothing +// to arbitrate — the same as a missing header. +func TestCanonicalConflict_NonObjectHeaderDelivers(t *testing.T) { + ev := conflictBaseEvent() + ev.Payload = json.RawMessage(`{"schema":"2.0","header":"not-an-object","event":{}}`) + if got := checkCanonicalConflict(ev, false); got != "" { + t.Errorf("a non-object header claims nothing and must deliver, got conflict on %q", got) + } +} + +// Well-formed control cases: agreement and silence both deliver. +func TestCanonicalConflict_AgreementAndSilenceDeliver(t *testing.T) { + agree := conflictBaseEvent() + agree.Payload = headerPayload(nil) + if got := checkCanonicalConflict(agree, false); got != "" { + t.Errorf("matching header must deliver, got conflict on %q", got) + } + + silent := conflictBaseEvent() + silent.Payload = json.RawMessage(`{"schema":"2.0","event":{"text":"no header block"}}`) + if got := checkCanonicalConflict(silent, false); got != "" { + t.Errorf("a silent header claims nothing and must deliver, got conflict on %q", got) + } + + // Non-JSON payloads are the processing layer's business, not arbitration's. + malformed := conflictBaseEvent() + malformed.Payload = json.RawMessage(`this is definitely not valid json {{{`) + if got := checkCanonicalConflict(malformed, false); got != "" { + t.Errorf("non-JSON payloads are not re-classified here, got conflict on %q", got) + } +} + +// The pipeline drops conflicting events with a diagnostic naming identity +// facts only — never payload content. +func TestCanonicalConflict_PipelineDropsWithRedactedDiagnostic(t *testing.T) { + const sentinel = "SECRET-PAYLOAD-CONTENT-XYZ" + ev := conflictBaseEvent() + ev.Payload = headerPayload(map[string]string{"app_id": "attacker-" + sentinel}) + + field := checkCanonicalConflict(ev, false) + if field != "app_id" { + t.Fatalf("expected app_id conflict, got %q", field) + } + diag := fmt.Sprintf("WARN: event %s (%s) dropped: payload header conflicts with canonical metadata (field=%s)\n", + ev.EventID, ev.EventType, field) + assertNoPayloadBytes(t, diag, sentinel) +} diff --git a/internal/event/consume/capability_gate_test.go b/internal/event/consume/capability_gate_test.go new file mode 100644 index 0000000000..527bca4c87 --- /dev/null +++ b/internal/event/consume/capability_gate_test.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/larksuite/cli/errs" + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/testutil" +) + +// legacyBusAcks replays, byte for byte, the hello_ack an older bus would send: +// the capability list either absent, empty, or missing the canonical-metadata +// entry. +var legacyBusAcks = map[string]string{ + "no capabilities field": `{"type":"hello_ack","bus_version":"v1","first_for_key":true}`, + "empty capability list": `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":[]}`, + "unrelated capability": `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":["some_other_capability"]}`, +} + +// resourceScopedDef is a key whose subscription identity carries a parameter, +// the shape that cannot fall back to a legacy bus: this consumer hashes the +// scope while an old consumer of the same resource uses the bare key, so each +// acts as first and last for its own scope and unsubscribes the other. The old +// bus can never grow a guard against that, so the only safe answer is refusal. +func resourceScopedDef(t *testing.T, key string, preConsume func()) *event.KeyDefinition { + t.Helper() + return compileDefForTest(t, event.KeyDefinition{ + Key: key, + EventType: key, + Params: []event.ParamDef{ + {Name: "resource_id", Type: event.ParamString, Required: true, SubscriptionKey: true}, + }, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { + preConsume() + return nil, nil + }, + }) +} + +func TestCapabilityGate_RefusesLegacyBusForResourceScopedKeyBeforeAnySideEffect(t *testing.T) { + for name, rawAck := range legacyBusAcks { + t.Run(name, func(t *testing.T) { + const key = "test.evt_capability_gate" + var setupCalls atomic.Int64 + def := resourceScopedDef(t, key, func() { setupCalls.Add(1) }) + + tr := startLegacyBusStub(t, rawAck) + + // Bounded on purpose: refusal returns before the consume loop + // starts, so a regression that degrades instead would otherwise + // block until the package timeout and report that rather than the + // missing refusal. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := Run(ctx, tr, "cap-gate-app", "", "", Options{ + EventKey: key, + Def: def, + Params: map[string]string{"resource_id": "res-1"}, + Runtime: &fakeRT{}, + Out: io.Discard, + Quiet: true, + }) + if err == nil { + t.Fatal("a resource-scoped key must refuse a bus without canonical metadata support") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("want a failed_precondition problem, got %v", err) + } + if !strings.Contains(err.Error(), protocol.CapabilityCanonicalMetadataV1) { + t.Errorf("error must name the missing capability, got: %v", err) + } + if got := setupCalls.Load(); got != 0 { + t.Errorf("pre-consume ran %d time(s) before the refusal; it must come first", got) + } + }) + } +} + +// A key whose subscription identity is one-dimensional keeps working against an +// older bus: its scope string is byte-identical across versions, so nothing can +// desynchronize. The operator is told the mode in one line, which Run emits per +// connection rather than per event. +func TestCapabilityGate_LegacyBusDegradesForOneDimensionalKey(t *testing.T) { + const key = "test.evt_capability_degrade" + var setupCalls atomic.Int64 + def := compileDefForTest(t, event.KeyDefinition{ + Key: key, + EventType: key, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + PreConsume: func(_ context.Context, _ event.APIClient, _ map[string]string) (func() error, error) { + setupCalls.Add(1) + return nil, nil + }, + }) + + var errOut bytes.Buffer + tr := startLegacyBusStub(t, legacyBusAcks["no capabilities field"]) + + err := Run(context.Background(), tr, "cap-gate-app", "", "", Options{ + EventKey: key, + Def: def, + Runtime: &fakeRT{}, + ErrOut: &errOut, + Out: io.Discard, + Timeout: 200 * time.Millisecond, + }) + if problem, ok := errs.ProblemOf(err); ok && problem.Subtype == errs.SubtypeFailedPrecondition { + t.Fatalf("a one-dimensional key must not be refused for a legacy bus, got: %v", err) + } + if got := setupCalls.Load(); got != 1 { + t.Errorf("pre-consume ran %d time(s); degrading must not skip preparation", got) + } + notices := strings.Count(errOut.String(), "legacy compatibility mode") + if notices != 1 { + t.Errorf("the compatibility notice must appear exactly once per connection, got %d", notices) + } +} + +// The current build's own ack must select the normal path — a negotiation that +// degrades everything would be just as wrong as one that refuses everything. +func TestCapabilityGate_CurrentAckSelectsNormalPath(t *testing.T) { + ack := protocol.NewHelloAck("v1", true, protocol.CapabilityCanonicalMetadataV1) + def := compileDefForTest(t, event.KeyDefinition{ + Key: "any.key", + EventType: "any.key", + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + }) + mode, err := negotiateMetadataMode(ack, def, "cap-gate-app") + if err != nil { + t.Fatalf("current bus ack must satisfy negotiation, got: %v", err) + } + if mode.enabled { + t.Error("a capable bus must not put the connection in compatibility mode") + } +} + +// startLegacyBusStub listens on a fake transport and speaks just enough of the +// wire protocol to let a consumer attach: it answers the status probe, then +// replies to the hello with the provided raw legacy ack line. +func startLegacyBusStub(t *testing.T, rawAck string) transport.IPC { + t.Helper() + dir, err := os.MkdirTemp("", "capgate-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + + tr := testutil.NewWrappedFake(transport.New(), dir+"/bus.sock") + ln, err := tr.Listen("cap-gate-app") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go serveLegacyConn(conn, rawAck) + } + }() + return tr +} + +func serveLegacyConn(conn net.Conn, rawAck string) { + defer conn.Close() + br := bufio.NewReader(conn) + line, err := protocol.ReadFrame(br) + if err != nil { + return + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + return + } + switch msg.(type) { + case *protocol.StatusQuery: + _ = protocol.Encode(conn, protocol.NewStatusResponse(1, 1, 0, nil)) + case *protocol.Hello: + _, _ = fmt.Fprintf(conn, "%s\n", rawAck) + // Hold the conn open like a real bus would; the consumer is expected + // to walk away after inspecting the ack. + _, _ = protocol.ReadFrame(br) + } +} diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index ec8aa7f66c..8635b3df32 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -9,24 +9,36 @@ import ( "fmt" "io" "os" - "sort" - "strings" "sync/atomic" "time" "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" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/catalog" ) type Options struct { - EventKey string - Params map[string]string - JQExpr string - Quiet bool - OutputDir string - Runtime event.APIClient + EventKey string + // Def is the resolved declaration for EventKey. The caller resolves it + // from the compiled catalog; this package validates and consumes it but + // never looks anything up itself. + Def *event.KeyDefinition + Params map[string]string + // ParamsNormalized marks Params as already normalized by the declaration's + // NormalizeParams hook (the deciding layer runs it on these exact values). + // The host then skips the hook, keeping its run-once-per-consumer contract. + ParamsNormalized bool + JQExpr string + Quiet bool + OutputDir string + Runtime event.APIClient + // Prepare, when set, replaces the declaration's PreConsume hook as the + // preparation to run when this consumer is first for its scope. The + // application layer injects it so the strategy that was decided is the + // strategy that executes; when nil, the declaration's own hook runs. + Prepare func(ctx context.Context) (func() error, error) Out io.Writer // nil falls back to os.Stdout ErrOut io.Writer RemoteAPIClient APIClient // nil disables remote-connection preflight @@ -34,6 +46,10 @@ type Options struct { MaxEvents int // 0 = unlimited Timeout time.Duration // 0 = no timeout IsTTY bool + + // legacy is resolved from the handshake ack inside Run and is read-only + // afterwards; callers neither set nor see it. + legacy legacyMetadataMode } // Run ensures bus is up, performs hello handshake, runs PreConsume for first subscriber, @@ -44,12 +60,20 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin errOut = os.Stderr //nolint:forbidigo // library-caller fallback } - keyDef, ok := event.Lookup(opts.EventKey) - if !ok { + keyDef := opts.Def + if keyDef == nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown EventKey: %s", opts.EventKey). WithHint("run `lark-cli event list` to see available keys") } + // EventKey and Def travel together; a mismatch would register one key on + // the bus while subscribing another's event types. + if opts.EventKey == "" { + opts.EventKey = keyDef.Key + } else if opts.EventKey != keyDef.Key { + return errs.NewInternalError(errs.SubtypeUnknown, + "consume options disagree: EventKey %q but definition is %q", opts.EventKey, keyDef.Key) + } if err := validateParams(keyDef, opts.Params); err != nil { return err @@ -64,8 +88,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin // Normalize params (resolve aliases like "me" -> real email) before fingerprint // compute, PreConsume, Match, Process. Must happen BEFORE doHello so the - // SubscriptionID we send to bus reflects canonical values. - if keyDef.NormalizeParams != nil { + // SubscriptionID we send to bus reflects canonical values. Skipped when the + // caller already normalized (the hook runs once per consumer, wherever it runs). + if !opts.ParamsNormalized && keyDef.NormalizeParams != nil { if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil { if _, ok := errs.ProblemOf(err); ok { return err @@ -106,13 +131,32 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin if rejErr := rejectionError(ack, opts.EventKey); rejErr != nil { return rejErr } + // Capability negotiation must finish before any side effect (pre-consume + // setup, worker start): a key that cannot fall back has to be refused + // before it registers anything server-side. The decision is fixed for the + // life of this connection — see legacyMetadataMode. + legacy, capErr := negotiateMetadataMode(ack, keyDef, appID) + if capErr != nil { + return capErr + } + opts.legacy = legacy + if legacy.enabled && !opts.Quiet { + fmt.Fprintln(errOut, legacyModeNotice(opts.EventKey)) + } + + prepare := opts.Prepare + if prepare == nil && keyDef.PreConsume != nil { + prepare = func(ctx context.Context) (func() error, error) { + return keyDef.PreConsume(ctx, opts.Runtime, opts.Params) + } + } var cleanup func() error - if ack.FirstForKey && keyDef.PreConsume != nil { + if ack.FirstForKey && prepare != nil { if !opts.Quiet { fmt.Fprintf(errOut, "[event] running pre-consume setup...\n") } - cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params) + cleanup, err = prepare(ctx) if err != nil { if _, ok := errs.ProblemOf(err); ok { return err @@ -191,44 +235,7 @@ func truncateDuration(d time.Duration) time.Duration { } func validateParams(def *event.KeyDefinition, params map[string]string) error { - for _, p := range def.Params { - if _, ok := params[p.Name]; !ok && p.Default != "" { - params[p.Name] = p.Default - } - } - for _, p := range def.Params { - if p.Required { - if _, ok := params[p.Name]; !ok { - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "required param %q missing for EventKey %s", p.Name, def.Key). - WithParam("--param"). - WithHint("pass it as --param %s=; run `lark-cli event schema %s` for details", p.Name, def.Key) - } - } - } - known := make(map[string]bool, len(def.Params)) - validNames := make([]string, 0, len(def.Params)) - for _, p := range def.Params { - known[p.Name] = true - validNames = append(validNames, p.Name) - } - sort.Strings(validNames) - for k := range params { - if known[k] { - continue - } - if len(validNames) == 0 { - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "unknown param %q: EventKey %s accepts no params", k, def.Key). - WithParam("--param"). - WithHint("run `lark-cli event schema %s` for details", def.Key) - } - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")). - WithParam("--param"). - WithHint("run `lark-cli event schema %s` for details", def.Key) - } - return nil + return catalog.ValidateParams(def, params) } func checkMaxEvents(opts Options, emitted *atomic.Int64) bool { diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go index a082e71f67..92007d4cf2 100644 --- a/internal/event/consume/consume_test.go +++ b/internal/event/consume/consume_test.go @@ -14,8 +14,9 @@ import ( "testing" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/catalog" ) // fakeRT is a minimal event.APIClient mock. @@ -27,23 +28,42 @@ func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.Ra return nil, f.err } +// compileDefForTest runs a synthetic declaration through catalog compilation +// and returns the canonical definition — what the CLI entry point hands +// Run as Options.Def. +func compileDefForTest(t *testing.T, def event.KeyDefinition) *event.KeyDefinition { + t.Helper() + snap, err := catalog.Compile([]catalog.KeyDefinition{def}, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test declaration: %v", err) + } + entry, ok := snap.Resolve(def.Key) + if !ok { + t.Fatalf("compiled snapshot has no entry for %s", def.Key) + } + return entry.Definition() +} + func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) { // Drives the real Run() path: NormalizeParams fails before EnsureBus, so no // bus is contacted, yet the production error-wrapping is exercised — if Run() // ever stops wrapping, this test fails. const key = "test.evt_normalize_fail" - event.RegisterKey(event.KeyDefinition{ + def := compileDefForTest(t, event.KeyDefinition{ Key: key, EventType: key, - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error { return errors.New("simulated normalize failure") }, }) - defer event.UnregisterKeyForTest(key) err := Run(context.Background(), transport.New(), "app", "", "", Options{ EventKey: key, + Def: def, Runtime: &fakeRT{}, Quiet: true, }) diff --git a/internal/event/consume/diagnostics_redaction_test.go b/internal/event/consume/diagnostics_redaction_test.go new file mode 100644 index 0000000000..5206317776 --- /dev/null +++ b/internal/event/consume/diagnostics_redaction_test.go @@ -0,0 +1,197 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" +) + +// assertNoPayloadBytes fails when a diagnostic line carries payload content. +// Diagnostics may name identity facts (event id, type, field names) but the +// payload itself must never reach stderr — it can hold user content, tokens, +// or anything else the upstream put there. +func assertNoPayloadBytes(t *testing.T, diagnostic, sentinel string) { + t.Helper() + if strings.Contains(diagnostic, sentinel) { + t.Errorf("diagnostic leaks payload content: %s", diagnostic) + } +} + +// The detector itself must bite: a deliberately leaky diagnostic has to be +// caught, otherwise a green redaction suite proves nothing. +func TestRedactionDetector_CatchesALeak(t *testing.T) { + const sentinel = "SENSITIVE-VALUE-123" + leaky := "WARN: dropped event, payload was: {\"token\":\"" + sentinel + "\"}" + if !strings.Contains(leaky, sentinel) { + t.Fatal("control diagnostic lost its sentinel; the detector cannot be trusted") + } +} + +// A malformed payload is dropped through the real pipeline with a diagnostic +// that names the event, not its content. +func TestMalformedDrop_DiagnosticNamesIdentityOnly(t *testing.T) { + const sentinel = "API-KEY-LIKE-CONTENT-abcdef123456" + keyDef := &event.KeyDefinition{ + Key: "test.evt_redaction", + EventType: "test.evt_redaction", + Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + return nil, processing.DropMalformed(raw.EventType) + }, + } + var stderr bytes.Buffer + var stdout bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &stdout} + + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-redact-1", + EventType: "test.evt_redaction", + Payload: json.RawMessage(`{"garbage": "` + sentinel + `"`), + }, 1) + + wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil) + if wrote || err != nil { + t.Fatalf("malformed event must be dropped silently from stdout: wrote=%v err=%v", wrote, err) + } + if stdout.Len() != 0 { + t.Errorf("nothing may reach stdout for a dropped event, got: %s", stdout.String()) + } + diag := stderr.String() + if !strings.Contains(diag, "dropped: malformed payload") { + t.Errorf("expected a malformed-drop diagnostic, got: %q", diag) + } + if !strings.Contains(diag, "evt-redact-1") { + t.Errorf("diagnostic must anchor on the event id, got: %q", diag) + } + assertNoPayloadBytes(t, diag, sentinel) +} + +// A Process error is reported on stderr, and error text routinely embeds +// input fragments (a parse error quoting the payload, an API response echo). +// The diagnostic must therefore carry only a bounded prefix of the error, so +// content sitting past the cap never reaches stderr. +func TestProcessErrorDiagnostic_TruncatesLongErrorText(t *testing.T) { + const sentinel = "PAYLOAD-FRAGMENT-IN-ERROR-zyx987" + // The sentinel sits entirely beyond the truncation cap. + longErr := strings.Repeat("x", diagnosticErrMaxLen+50) + sentinel + + // Control: an untruncated diagnostic would contain the sentinel, so the + // leak assertion below is able to detect a regression. + if !strings.Contains("WARN: Process error: "+longErr, sentinel) { + t.Fatal("control failed: the sentinel is not in the raw error; the test cannot prove truncation") + } + + keyDef := &event.KeyDefinition{ + Key: "test.evt_process_error", + EventType: "test.evt_process_error", + Process: func(context.Context, event.APIClient, *event.RawEvent, map[string]string) (json.RawMessage, error) { + return nil, errors.New(longErr) + }, + } + var stderr bytes.Buffer + var stdout bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &stdout} + + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-process-err-1", + EventType: "test.evt_process_error", + Payload: json.RawMessage(`{}`), + }, 1) + + wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil) + if wrote || err != nil { + t.Fatalf("a Process error must drop the event without a sink error: wrote=%v err=%v", wrote, err) + } + if stdout.Len() != 0 { + t.Errorf("nothing may reach stdout for a dropped event, got: %s", stdout.String()) + } + diag := stderr.String() + if !strings.Contains(diag, "WARN: Process error:") { + t.Errorf("expected a process-error diagnostic, got: %q", diag) + } + if !strings.Contains(diag, "...(truncated)") { + t.Errorf("a long error must be marked as truncated, got: %q", diag) + } + assertNoPayloadBytes(t, diag, sentinel) +} + +// A short Process error passes through whole — truncation only engages past +// the cap, so ordinary diagnostics stay fully readable. +func TestProcessErrorDiagnostic_KeepsShortErrorIntact(t *testing.T) { + const shortErr = "decode meeting id: unexpected end of JSON input" + keyDef := &event.KeyDefinition{ + Key: "test.evt_process_error_short", + EventType: "test.evt_process_error_short", + Process: func(context.Context, event.APIClient, *event.RawEvent, map[string]string) (json.RawMessage, error) { + return nil, errors.New(shortErr) + }, + } + var stderr bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &bytes.Buffer{}} + + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-process-err-2", + EventType: "test.evt_process_error_short", + Payload: json.RawMessage(`{}`), + }, 1) + + if wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil); wrote || err != nil { + t.Fatalf("a Process error must drop the event without a sink error: wrote=%v err=%v", wrote, err) + } + diag := stderr.String() + if !strings.Contains(diag, "WARN: Process error: "+shortErr) { + t.Errorf("a short error must be reported verbatim, got: %q", diag) + } + if strings.Contains(diag, "...(truncated)") { + t.Errorf("a short error must not be marked as truncated, got: %q", diag) + } +} + +// A metadata conflict is dropped through the real pipeline the same way. +func TestConflictDrop_DiagnosticNamesIdentityOnly(t *testing.T) { + const sentinel = "PRIVATE-MESSAGE-TEXT-qwerty" + keyDef := &event.KeyDefinition{ + Key: "test.evt_conflict_redaction", + EventType: "test.evt_conflict_redaction", + } + var stderr bytes.Buffer + var stdout bytes.Buffer + opts := Options{ErrOut: &stderr, Params: map[string]string{}} + sink := &WriterSink{W: &stdout} + + payload, _ := json.Marshal(map[string]any{ + "header": map[string]string{"event_id": "evt-forged"}, + "event": map[string]string{"text": sentinel}, + }) + evt := protocol.NewEvent(&model.Event{ + EventID: "evt-real", + EventType: "test.evt_conflict_redaction", + Payload: payload, + }, 1) + + wrote, err := processAndOutput(context.Background(), keyDef, evt, opts, sink, nil) + if wrote || err != nil { + t.Fatalf("conflicting event must be dropped: wrote=%v err=%v", wrote, err) + } + if stdout.Len() != 0 { + t.Errorf("nothing may reach stdout for a dropped event, got: %s", stdout.String()) + } + diag := stderr.String() + if !strings.Contains(diag, "conflicts with canonical metadata (field=event_id)") { + t.Errorf("expected a conflict diagnostic naming the field, got: %q", diag) + } + assertNoPayloadBytes(t, diag, sentinel) +} diff --git a/internal/event/consume/fingerprint.go b/internal/event/consume/fingerprint.go index a452f1aadb..5bd546846a 100644 --- a/internal/event/consume/fingerprint.go +++ b/internal/event/consume/fingerprint.go @@ -4,38 +4,13 @@ package consume import ( - "crypto/sha256" - "encoding/base64" - "encoding/json" - "sort" - "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/catalog" ) -// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values -// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup -// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey -// params -> returns def.Key verbatim (legacy one-dimensional behavior). -// -// Stability contract: same EventKey + same normalized param values -> same ID -// across CLI versions; changing the encoding requires a wire-format bump. +// ComputeSubscriptionID delegates to the catalog's scope derivation so every +// layer (application decision, bus accounting, this host) computes the same +// identity from the same declaration. func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string { - type kv struct { - Name string `json:"name"` - Value string `json:"value"` - } - var subParams []kv - for _, p := range def.Params { - if !p.SubscriptionKey { - continue - } - subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]}) - } - if len(subParams) == 0 { - return def.Key - } - sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name }) - raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields - sum := sha256.Sum256(raw) - return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12]) + return catalog.SubscriptionScope(def, params) } diff --git a/internal/event/consume/fingerprint_scope_test.go b/internal/event/consume/fingerprint_scope_test.go new file mode 100644 index 0000000000..478a792a04 --- /dev/null +++ b/internal/event/consume/fingerprint_scope_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + "testing" + + event "github.com/larksuite/cli/internal/event" +) + +// A key whose server-side subscription is parameter-scoped must derive +// distinct subscription identities per parameter value: consumers of +// different resources own independent setup/cleanup lifecycles, while +// consumers of the same resource share one. +func TestSubscriptionKeyParam_SplitsScopes(t *testing.T) { + def := &event.KeyDefinition{ + Key: "test.evt_scoped", + EventType: "test.evt_scoped", + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + Params: []event.ParamDef{ + {Name: "resource_id", Type: event.ParamString, Required: true, SubscriptionKey: true}, + }, + } + + a1 := ComputeSubscriptionID(def, map[string]string{"resource_id": "board-A"}) + a2 := ComputeSubscriptionID(def, map[string]string{"resource_id": "board-A"}) + b := ComputeSubscriptionID(def, map[string]string{"resource_id": "board-B"}) + + if a1 != a2 { + t.Errorf("same resource must share one scope: %q vs %q", a1, a2) + } + if a1 == b { + t.Error("different resources must not share a subscription scope") + } + if a1 == def.Key || b == def.Key { + t.Error("scoped identities must not degenerate to the bare key") + } +} diff --git a/internal/event/consume/handshake.go b/internal/event/consume/handshake.go index 18aeac959e..11bf6fa61f 100644 --- a/internal/event/consume/handshake.go +++ b/internal/event/consume/handshake.go @@ -11,7 +11,7 @@ import ( "os" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read deadline diff --git a/internal/event/consume/legacy_metadata.go b/internal/event/consume/legacy_metadata.go new file mode 100644 index 0000000000..35435ed4a1 --- /dev/null +++ b/internal/event/consume/legacy_metadata.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "encoding/json" + "slices" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" +) + +// legacyMetadataMode is the compatibility decision for one connection. It is +// resolved once from the handshake ack and never changes while that connection +// lives: a per-event fallback would let a forged header override canonical +// facts on a bus that does supply them, which is exactly what arbitration +// exists to prevent. +// +// TRANSITIONAL: the whole legacy path goes away when the bus protocol next +// bumps, at which point the capability check returns to selecting a restore +// strategy rather than tolerating a missing one. +type legacyMetadataMode struct { + enabled bool + // appID is the app this consumer is configured for. A legacy frame carries + // no app_id, so the payload header is the only place to read it from — but + // it is checked against this value instead of being trusted. + appID string +} + +// negotiateMetadataMode decides how this connection restores canonical facts. +// +// A bus that advertises canonical metadata needs nothing. An older bus is +// tolerated for keys whose subscription identity is one-dimensional, because +// their scope string is byte-identical across versions. Keys with a +// SubscriptionKey param are refused: their scope is hashed here and bare on +// the old bus, so old and new consumers of the same resource would each act as +// first and last for their own scope and unsubscribe one another — and the old +// bus can never grow a guard against that. +func negotiateMetadataMode(ack *protocol.HelloAck, def *event.KeyDefinition, appID string) (legacyMetadataMode, error) { + if ack != nil && slices.Contains(ack.Capabilities, protocol.CapabilityCanonicalMetadataV1) { + return legacyMetadataMode{}, nil + } + if hasSubscriptionKeyParam(def) { + return legacyMetadataMode{}, capabilityError(def.Key) + } + return legacyMetadataMode{enabled: true, appID: appID}, nil +} + +func hasSubscriptionKeyParam(def *event.KeyDefinition) bool { + return slices.ContainsFunc(def.Params, func(p event.ParamDef) bool { return p.SubscriptionKey }) +} + +// restoreLegacyMetadata fills the canonical facts a legacy bus never sent, +// reading them from the payload header the old ingress parsed out of these +// same bytes. Facts the frame did carry are left alone: the frame stays the +// authority for everything it can speak to. +// +// It returns the name of the fact that cannot be honoured, or "" when the +// event may be delivered. Both facts are declared strings in the envelope, so +// a non-string assertion is refused rather than coerced; an absent claim +// leaves the fact empty, which is what the old consumer rendered too. +func restoreLegacyMetadata(ev *event.RawEvent, configuredAppID string) string { + var claims payloadHeaderClaims + if err := json.Unmarshal(ev.Payload, &claims); err != nil { + // Nothing to restore from. The facts stay empty, exactly as an old + // consumer would have left them. + return "" + } + if ev.AppID == "" { + claimed, ok := legacyHeaderString(claims, "app_id") + if !ok { + return "app_id" + } + // The configured app is an independent source for this one fact, so + // the header does not get to name an app this consumer is not running + // as — that is the forgery the arbiter would otherwise have caught. + if claimed != "" && claimed != configuredAppID { + return "app_id" + } + ev.AppID = claimed + } + if ev.TenantKey == "" { + claimed, ok := legacyHeaderString(claims, "tenant_key") + if !ok { + return "tenant_key" + } + // Accepted cost of compatibility: nothing else on a legacy connection + // knows the tenant, so this claim cannot be cross-checked. + ev.TenantKey = claimed + } + return "" +} + +// legacyHeaderString reads one header field as a string. ok is false only when +// the field is present and is not a string; an absent field and a JSON null +// both read as an empty string, which is how the envelope spells "not set". +func legacyHeaderString(claims payloadHeaderClaims, field string) (string, bool) { + raw, asserted := claims.Header[field] + if !asserted { + return "", true + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", false + } + return value, true +} + +// capabilityError refuses a bus that cannot deliver full canonical metadata +// for a key whose subscription identity depends on it. +func capabilityError(eventKey string) error { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, + "the running local event bus does not support %s, and %s subscribes per resource so it cannot fall back", + protocol.CapabilityCanonicalMetadataV1, eventKey). + WithHint("stop the consumers still attached to the old bus, run `lark-cli event stop` (add --force to override active consumers at the cost of dropping them), then retry `lark-cli event consume %s`", eventKey) +} + +// legacyModeNotice is the one-time, per-connection line telling the operator +// which facts are being derived from the payload instead of the frame. +func legacyModeNotice(eventKey string) string { + return "[event] legacy compatibility mode for " + eventKey + + ": the running bus predates " + protocol.CapabilityCanonicalMetadataV1 + + ", so app_id and tenant_key are read from the event payload; restart the bus (`lark-cli event stop`) once its consumers are done to leave this mode" +} diff --git a/internal/event/consume/legacy_metadata_test.go b/internal/event/consume/legacy_metadata_test.go new file mode 100644 index 0000000000..f694dced75 --- /dev/null +++ b/internal/event/consume/legacy_metadata_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/larksuite/cli/events" + event "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/catalog" +) + +const legacyConfiguredAppID = "cli_configured_app" + +// legacyFrame builds the event frame an older bus produced: it carries the +// facts that version knew about and nothing else. The absent fields are the +// whole reason the compatibility path exists. +func legacyFrame(payload json.RawMessage) *protocol.Event { + return &protocol.Event{ + Type: protocol.MsgTypeEvent, + EventType: "im.message.receive_v1", + EventID: "evt-legacy-1", + SourceTime: "1700000000000", + Seq: 1, + Payload: payload, + } +} + +func legacyEnvelope(header map[string]any) json.RawMessage { + full := map[string]any{ + "event_id": "evt-legacy-1", + "event_type": "im.message.receive_v1", + "create_time": "1700000000000", + "app_id": legacyConfiguredAppID, + "tenant_key": "tenant-legacy", + } + for k, v := range header { + full[k] = v + } + raw, _ := json.Marshal(map[string]any{"schema": "2.0", "header": full, "event": map[string]any{}}) + return raw +} + +// The frame stays the authority for everything it carries; only the facts it +// cannot speak to come from the header. +func TestLegacyRestore_FillsOnlyWhatTheFrameLacks(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(nil)), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("a well-formed legacy event must be deliverable, got conflict on %q", field) + } + + if raw.AppID != legacyConfiguredAppID { + t.Errorf("app_id = %q, want it restored from the header", raw.AppID) + } + if raw.TenantKey != "tenant-legacy" { + t.Errorf("tenant_key = %q, want it restored from the header", raw.TenantKey) + } + // Frame-supplied facts must be untouched even though the header repeats them. + if raw.EventID != "evt-legacy-1" || raw.EventType != "im.message.receive_v1" || raw.SourceTime != "1700000000000" { + t.Errorf("the frame must stay authoritative for the facts it carries, got %+v", raw) + } + // The legacy frame has no observation clock and nothing reads one. + if !raw.Timestamp.IsZero() { + t.Errorf("Timestamp = %v, want the zero value on a legacy frame", raw.Timestamp) + } +} + +// The configured app is an independent source for app_id, so a header naming a +// different app is a forgery rather than a fact to restore. +func TestLegacyRestore_RefusesAnAppIDTheConsumerIsNotRunningAs(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(map[string]any{"app_id": "cli_forged_app"})), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "app_id" { + t.Errorf("a header naming another app must conflict on app_id, got %q", field) + } +} + +// Both restored facts are declared strings; a non-string assertion is refused +// rather than coerced, matching how arbitration treats a type flip. +func TestLegacyRestore_RefusesNonStringClaims(t *testing.T) { + for _, field := range []string{"app_id", "tenant_key"} { + t.Run(field, func(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(map[string]any{field: 42})), nil, true) + if got := restoreLegacyMetadata(raw, legacyConfiguredAppID); got != field { + t.Errorf("a non-string %s claim must conflict, got %q", field, got) + } + }) + } +} + +// An absent claim leaves the fact empty, which is what the old consumer +// rendered — restoring must not invent a value. +func TestLegacyRestore_AbsentClaimLeavesTheFactEmpty(t *testing.T) { + payload := json.RawMessage(`{"schema":"2.0","header":{"event_id":"evt-legacy-1"},"event":{}}`) + raw := restoreCanonicalEvent(legacyFrame(payload), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("an absent claim is not a conflict, got %q", field) + } + if raw.AppID != "" || raw.TenantKey != "" { + t.Errorf("absent claims must stay empty, got app_id=%q tenant_key=%q", raw.AppID, raw.TenantKey) + } +} + +// Compatibility narrows arbitration, it does not switch it off: the facts the +// legacy frame carries itself are still checked against the header. +func TestLegacyRestore_StillArbitratesFrameSuppliedFacts(t *testing.T) { + forgedEventID := legacyEnvelope(map[string]any{"event_id": "evt-forged"}) + raw := restoreCanonicalEvent(legacyFrame(forgedEventID), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("restore itself must not object here, got %q", field) + } + if got := checkCanonicalConflict(raw, true); got != "event_id" { + t.Errorf("a forged event_id must still conflict on a legacy connection, got %q", got) + } +} + +// The accepted cost, recorded deliberately: tenant_key has no second source on +// a legacy connection, so a header claiming a different tenant is honoured. +// This is the one fact compatibility cannot protect, and it is why the mode is +// transitional. +func TestLegacyRestore_TenantKeyIsTheAcceptedCost(t *testing.T) { + raw := restoreCanonicalEvent(legacyFrame(legacyEnvelope(map[string]any{"tenant_key": "tenant-other"})), nil, true) + if field := restoreLegacyMetadata(raw, legacyConfiguredAppID); field != "" { + t.Fatalf("tenant_key cannot be cross-checked, so it must not conflict, got %q", field) + } + if got := checkCanonicalConflict(raw, true); got != "" { + t.Fatalf("a header-derived fact must not be arbitrated against the header it came from, got %q", got) + } + if raw.TenantKey != "tenant-other" { + t.Errorf("tenant_key = %q, want the header's claim", raw.TenantKey) + } +} + +// Per-key matrix over the real catalog: every shipped key must have a defined +// answer for a legacy bus, and the answer must follow from its subscription +// shape rather than from a hand-maintained list. +// +// This pins the restore at field level and states the one place a legacy +// canonical event legitimately differs from a current one. What actually gets +// printed is covered by TestLegacyBusReplay_RendersTheFrozenOutput, which +// drives the real pipeline off an old-format frame and compares stdout with +// the frozen baseline — field-level equality alone would not prove the output +// matches, precisely because of the difference recorded below. +func TestLegacyBus_EveryShippedKeyHasADefinedAnswer(t *testing.T) { + snap, err := catalog.Compile(allShippedDefs(t), catalog.StrategyRefs{catalog.StrategyNone, catalog.StrategyLegacyPreConsume}) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + legacyAck := &protocol.HelloAck{Type: protocol.MsgTypeHelloAck, BusVersion: "v1", FirstForKey: true} + + refused, degraded := 0, 0 + for _, def := range snap.Definitions() { + t.Run(def.Key, func(t *testing.T) { + mode, err := negotiateMetadataMode(legacyAck, def, legacyConfiguredAppID) + + if hasSubscriptionKeyParam(def) { + refused++ + if err == nil { + t.Fatal("a resource-scoped key must be refused on a legacy bus: its scope is hashed here and bare there, so the two would unsubscribe each other") + } + if mode.enabled { + t.Error("a refused key must not also be put in compatibility mode") + } + return + } + + degraded++ + if err != nil { + t.Fatalf("a one-dimensional key must degrade rather than fail, got: %v", err) + } + if !mode.enabled { + t.Fatal("a legacy ack must put the connection in compatibility mode") + } + + // Compare against what a current bus actually puts on the wire, + // observation clock included — not against another legacy frame + // with two fields filled in, which would agree trivially on the + // one field the two formats cannot agree on. + payload := legacyEnvelope(map[string]any{"event_type": def.EventType}) + old := legacyFrame(payload) + old.EventType = def.EventType + restored := restoreCanonicalEvent(old, nil, true) + if field := restoreLegacyMetadata(restored, legacyConfiguredAppID); field != "" { + t.Fatalf("legacy restore rejected a well-formed event on %q", field) + } + + current := legacyFrame(payload) + current.EventType = def.EventType + current.AppID = legacyConfiguredAppID + current.TenantKey = "tenant-legacy" + current.ObservedAt = "2023-11-14T22:13:20.5Z" + want := restoreCanonicalEvent(current, nil, true) + + if restored.EventID != want.EventID || restored.EventType != want.EventType || + restored.SourceTime != want.SourceTime || restored.AppID != want.AppID || + restored.TenantKey != want.TenantKey || + !bytes.Equal(restored.Payload, want.Payload) { + t.Errorf("legacy restore diverged from the current frame:\n got %+v\nwant %+v", restored, want) + } + + // The single recorded difference: a legacy frame has no + // observation clock. Nothing under events/ reads it today, and the + // replay test is what would catch it if something started to. + if !restored.Timestamp.IsZero() { + t.Errorf("Timestamp = %v, want the zero value: a legacy frame carries no observation clock", restored.Timestamp) + } + if want.Timestamp.IsZero() { + t.Error("the current-frame control must carry an observation clock, otherwise this comparison hides the one field the formats disagree on") + } + }) + } + if refused == 0 || degraded == 0 { + t.Fatalf("the matrix must exercise both answers, got %d refused and %d degraded", refused, degraded) + } +} + +func allShippedDefs(t *testing.T) []event.KeyDefinition { + t.Helper() + defs := events.All() + if len(defs) == 0 { + t.Fatal("no shipped EventKeys found; the matrix scanned nothing") + } + return defs +} diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index 849caddd8a..518f6e6cb6 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -9,16 +9,19 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "net" "sync" "sync/atomic" "syscall" "time" + "unicode/utf8" "github.com/itchyny/gojq" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/processing" ) // consumeLoop reads events and dispatches to workers; cancels on terminal sink errors. @@ -197,11 +200,51 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e return nil } +// diagnosticErrMaxLen caps how much of a Process error text reaches stderr. +// Error strings routinely embed input fragments (a parse error quoting the +// payload, an API response echo), so the diagnostic keeps only a bounded +// prefix of them. +const diagnosticErrMaxLen = 200 + +// truncateDiagnostic bounds s to diagnosticErrMaxLen bytes, backing off to +// the previous rune boundary so the cut never emits invalid UTF-8, and marks +// the cut explicitly. +func truncateDiagnostic(s string) string { + if len(s) <= diagnosticErrMaxLen { + return s + } + cut := diagnosticErrMaxLen + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "...(truncated)" +} + // processAndOutput returns (wrote, err); err non-nil only for sink.Write failures. func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { - raw := &event.RawEvent{ - EventType: evt.EventType, - Payload: evt.Payload, + raw := restoreCanonicalEvent(evt, opts.ErrOut, opts.Quiet) + + // On a legacy connection the frame cannot speak to every canonical fact, + // so the missing ones are derived from the payload header first — a fact + // the header cannot legitimately claim is a conflict like any other. + conflict := "" + if opts.legacy.enabled { + conflict = restoreLegacyMetadata(raw, opts.legacy.appID) + } + + // Validate before any domain work: a payload header that contradicts the + // canonical metadata means the two sources of truth diverged somewhere on + // the delivery path — deliver neither. The diagnostic names identity + // facts only; payload content never reaches stderr. + if conflict == "" { + conflict = checkCanonicalConflict(raw, opts.legacy.enabled) + } + if conflict != "" { + if !opts.Quiet { + fmt.Fprintf(opts.ErrOut, "WARN: event %s (%s) dropped: payload header conflicts with canonical metadata (field=%s)\n", + raw.EventID, raw.EventType, conflict) + } + return false, nil } // Synchronous Match filter runs before any work (Process / sink write). @@ -216,7 +259,12 @@ func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *pro result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params) if err != nil { if !opts.Quiet { - fmt.Fprintf(opts.ErrOut, "WARN: Process error: %v\n", err) + if processing.IsDropMalformed(err) { + fmt.Fprintf(opts.ErrOut, "WARN: event %s (%s) dropped: malformed payload\n", + raw.EventID, raw.EventType) + } else { + fmt.Fprintf(opts.ErrOut, "WARN: Process error: %s\n", truncateDiagnostic(err.Error())) + } } return false, nil } @@ -247,6 +295,35 @@ func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *pro return true, nil } +// restoreCanonicalEvent rebuilds the canonical event from the wire frame in +// full. Every fact the ingress parsed must survive into the domain hooks — +// restoring only a subset is how processors historically ended up re-parsing +// the payload header as a second source of truth. +func restoreCanonicalEvent(evt *protocol.Event, errOut io.Writer, quiet bool) *event.RawEvent { + var observed time.Time + if evt.ObservedAt != "" { + if parsed, err := time.Parse(time.RFC3339Nano, evt.ObservedAt); err == nil { + observed = parsed + } else if !quiet { + // A non-empty observed_at that fails to parse is a delivery + // defect of the same class as a canonical-metadata conflict: + // surface it, keep the event (empty means "missing upstream + // timestamp" and stays silent by design). + fmt.Fprintf(errOut, "WARN: event %s (%s): malformed observed_at %q ignored: %v\n", + evt.EventID, evt.EventType, evt.ObservedAt, err) + } + } + return &event.RawEvent{ + EventID: evt.EventID, + EventType: evt.EventType, + SourceTime: evt.SourceTime, + AppID: evt.AppID, + TenantKey: evt.TenantKey, + Payload: evt.Payload, + Timestamp: observed, + } +} + // isTerminalSinkError reports if the output channel is permanently broken (EPIPE/ErrClosed). func isTerminalSinkError(err error) bool { if err == nil { diff --git a/internal/event/consume/loop_seq_test.go b/internal/event/consume/loop_seq_test.go index 578ef9d6b7..14c7c1c038 100644 --- a/internal/event/consume/loop_seq_test.go +++ b/internal/event/consume/loop_seq_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) // Mirrors the inline gap-detection logic from consumeLoop's reader; keep in sync with loop.go. diff --git a/internal/event/consume/loop_test.go b/internal/event/consume/loop_test.go index ace554fd73..35b01a9cd6 100644 --- a/internal/event/consume/loop_test.go +++ b/internal/event/consume/loop_test.go @@ -19,7 +19,8 @@ import ( "time" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/model" ) func echoKeyDef(key string) *event.KeyDefinition { @@ -70,8 +71,8 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) { defer server.Close() events := []*protocol.Event{ - protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)), - protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e1", Payload: json.RawMessage(`{"n":1}`)}, 1), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e2", Payload: json.RawMessage(`{"n":2}`)}, 2), } go busSide(t, server, events, true) @@ -113,8 +114,8 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) { defer server.Close() events := []*protocol.Event{ - protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)), - protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e1", Payload: json.RawMessage(`{"n":1}`)}, 1), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e5", Payload: json.RawMessage(`{"n":5}`)}, 5), } go busSide(t, server, events, true) @@ -149,8 +150,8 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) { defer server.Close() events := []*protocol.Event{ - protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)), - protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e1", Payload: json.RawMessage(`{"keep":true,"n":1}`)}, 1), + protocol.NewEvent(&model.Event{EventType: "test.evt", EventID: "e2", Payload: json.RawMessage(`{"keep":false,"n":2}`)}, 2), } go busSide(t, server, events, true) diff --git a/internal/event/consume/reject_test.go b/internal/event/consume/reject_test.go index 5ad59b6939..b9c7fce9fe 100644 --- a/internal/event/consume/reject_test.go +++ b/internal/event/consume/reject_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) func TestRejectionError_Rejected(t *testing.T) { diff --git a/internal/event/consume/shutdown.go b/internal/event/consume/shutdown.go index 3c07f9869c..de0d01819a 100644 --- a/internal/event/consume/shutdown.go +++ b/internal/event/consume/shutdown.go @@ -9,7 +9,7 @@ import ( "net" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) const preShutdownAckTimeout = 2 * time.Second diff --git a/internal/event/consume/shutdown_test.go b/internal/event/consume/shutdown_test.go index 76e28ad668..7ceaff3e25 100644 --- a/internal/event/consume/shutdown_test.go +++ b/internal/event/consume/shutdown_test.go @@ -12,7 +12,8 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/model" ) // checkLastForKey must skip non-ack frames buffered before PreShutdownAck. @@ -28,7 +29,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) { errs <- err return } - evt := protocol.NewEvent("im.msg", "evt_1", "", 1, json.RawMessage(`{}`)) + evt := protocol.NewEvent(&model.Event{EventType: "im.msg", EventID: "evt_1", Payload: json.RawMessage(`{}`)}, 1) if err := protocol.Encode(server, evt); err != nil { errs <- err return diff --git a/internal/event/consume/startup.go b/internal/event/consume/startup.go index 890e1c7723..ae31af7d89 100644 --- a/internal/event/consume/startup.go +++ b/internal/event/consume/startup.go @@ -19,8 +19,8 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/event" - "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/lockfile" "github.com/larksuite/cli/internal/vfs" ) diff --git a/internal/event/consume/startup_guard_test.go b/internal/event/consume/startup_guard_test.go index 2173837725..6b98c1a38e 100644 --- a/internal/event/consume/startup_guard_test.go +++ b/internal/event/consume/startup_guard_test.go @@ -84,13 +84,14 @@ func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) { } func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) { - event.RegisterKey(event.KeyDefinition{ + def := compileDefForTest(t, event.KeyDefinition{ Key: "consume.runtest.jq", EventType: "consume.runtest.jq_v1", - Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}}, + Schema: event.SchemaDef{Native: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, }) err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{ EventKey: "consume.runtest.jq", + Def: def, JQExpr: "[invalid{{{", ErrOut: io.Discard, }) diff --git a/internal/event/consume/startup_probe_test.go b/internal/event/consume/startup_probe_test.go index 20c436c5ca..1a29ec43e0 100644 --- a/internal/event/consume/startup_probe_test.go +++ b/internal/event/consume/startup_probe_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" ) type probeMockTransport struct { diff --git a/internal/event/execution_chain_test.go b/internal/event/execution_chain_test.go new file mode 100644 index 0000000000..781d2b7a0c --- /dev/null +++ b/internal/event/execution_chain_test.go @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Integration pins for the seams between deciding a consume and running it. +// Both properties below are invisible to the unit tests on either side: each +// layer looks correct on its own, and only the pair is wrong. +package event_test + +import ( + "context" + "encoding/json" + "io" + "sync/atomic" + "testing" + "time" + + "github.com/larksuite/cli/events" + eventlib "github.com/larksuite/cli/internal/event" + appconsume "github.com/larksuite/cli/internal/event/application/consume" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/consume" + "github.com/larksuite/cli/internal/event/testutil" +) + +const chainAppID = "cli_chain_app" + +type chainAPIClient struct{} + +func (chainAPIClient) CallAPI(context.Context, string, string, any) (json.RawMessage, error) { + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil +} + +type chainIdentity struct{} + +func (chainIdentity) Resolve(context.Context, *catalog.Entry) (string, error) { return "bot", nil } + +type chainPreflight struct{} + +func (chainPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) { + return []appconsume.Precondition{{Name: "credentials_available", Status: appconsume.PreconditionOK}}, nil +} + +// chainKey is a synthetic key carrying both hooks the seams involve: a +// normalizer that records how often it ran and rewrites its input, and a +// preparation that records the same. +func chainKey(normalizeCalls, prepareCalls *atomic.Int64) eventlib.KeyDefinition { + return eventlib.KeyDefinition{ + Key: "test.chain_seam_v1", + EventType: "test.chain_seam_v1", + Params: []eventlib.ParamDef{ + {Name: "who", Type: eventlib.ParamString}, + }, + Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + NormalizeParams: func(_ context.Context, _ eventlib.APIClient, params map[string]string) error { + normalizeCalls.Add(1) + if params["who"] == "me" { + params["who"] = "resolved@example.com" + } + return nil + }, + PreConsume: func(context.Context, eventlib.APIClient, map[string]string) (func() error, error) { + prepareCalls.Add(1) + return nil, nil + }, + } +} + +func chainService(t *testing.T, def eventlib.KeyDefinition) (*appconsume.Service, *catalog.Entry) { + t.Helper() + snap, err := catalog.Compile(append(events.All(), def), catalog.StrategyRefs{ + catalog.StrategyNone, catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile catalog: %v", err) + } + entry, ok := snap.Resolve(def.Key) + if !ok { + t.Fatalf("compiled catalog has no %s", def.Key) + } + return &appconsume.Service{ + Strategies: appconsume.DefaultRegistry(), + Identity: chainIdentity{}, + Preflight: chainPreflight{}, + }, entry +} + +// The normalizer must run exactly once for a consumer, across both layers. +// Deciding runs it to compute the subscription identity, and the host is told +// so it skips its own call. Get that flag wrong in either direction and the +// symptom is silent: a second run for a non-idempotent hook, or a host +// normalizing values the bus was never told about. +func TestExecutionChain_NormalizerRunsOncePerConsumer(t *testing.T) { + var normalizeCalls, prepareCalls atomic.Int64 + def := chainKey(&normalizeCalls, &prepareCalls) + svc, entry := chainService(t, def) + + decision, err := svc.Decide(context.Background(), entry, + appconsume.Request{EventKey: def.Key, Params: map[string]string{"who": "me"}}, + appconsume.ExecutionContext{API: chainAPIClient{}}) + if err != nil { + t.Fatalf("decide: %v", err) + } + if got := normalizeCalls.Load(); got != 1 { + t.Fatalf("deciding ran the normalizer %d time(s), want 1", got) + } + if got := decision.NormalizedParams()["who"]; got != "resolved@example.com" { + t.Fatalf("the decision must carry normalized values, got %q", got) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tr := testutil.NewBusStub(currentAck).Listen(t, chainAppID) + + err = svc.Execute(ctx, entry, decision, runnerFor(t, tr, def, decision), appconsume.ExecutionContext{API: chainAPIClient{}}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if got := normalizeCalls.Load(); got != 1 { + t.Errorf("the normalizer ran %d time(s) across decide and execute, want exactly 1", got) + } +} + +// The preparation the decision chose is the preparation that runs: the host +// takes it by injection rather than reaching for the declaration's own hook. +// Both paths call the same function today, so a broken injection would go +// unnoticed — this asserts the injected one is what executes by counting a +// spy the declaration does not know about. +func TestExecutionChain_ExecutesTheInjectedPreparation(t *testing.T) { + var normalizeCalls, declaredPrepareCalls atomic.Int64 + def := chainKey(&normalizeCalls, &declaredPrepareCalls) + svc, entry := chainService(t, def) + + decision, err := svc.Decide(context.Background(), entry, + appconsume.Request{EventKey: def.Key}, + appconsume.ExecutionContext{API: chainAPIClient{}}) + if err != nil { + t.Fatalf("decide: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tr := testutil.NewBusStub(currentAck).Listen(t, chainAppID) + + var injectedRan atomic.Int64 + runner := appconsume.StreamRunner(runnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + // Wrap the strategy's preparation exactly as the command does, then + // record that the host invoked this one. + return consume.Run(ctx, tr, chainAppID, "", "", consume.Options{ + EventKey: def.Key, + Def: entry.Definition(), + Params: decision.NormalizedParams(), + ParamsNormalized: true, + Runtime: chainAPIClient{}, + Out: io.Discard, + ErrOut: io.Discard, + Quiet: true, + MaxEvents: 0, + Timeout: 300 * time.Millisecond, + Prepare: func(ctx context.Context) (func() error, error) { + injectedRan.Add(1) + return prepare(ctx) + }, + }) + })) + + if err := svc.Execute(ctx, entry, decision, runner, appconsume.ExecutionContext{API: chainAPIClient{}}); err != nil { + t.Fatalf("execute: %v", err) + } + + if got := injectedRan.Load(); got != 1 { + t.Errorf("the injected preparation ran %d time(s), want 1: the host must use what the decision chose", got) + } + if got := declaredPrepareCalls.Load(); got != 1 { + t.Errorf("the declaration's hook ran %d time(s), want 1 through the injected strategy", got) + } +} + +// currentAck is the hello_ack the current bus sends: first for the key and +// advertising canonical metadata, so these tests exercise the normal path. +const currentAck = `{"type":"hello_ack","bus_version":"v1","first_for_key":true,"capabilities":["canonical_metadata_v1"]}` + +type runnerFunc func(ctx context.Context, prepare appconsume.PrepareFunc) error + +func (f runnerFunc) Run(ctx context.Context, prepare appconsume.PrepareFunc) error { + return f(ctx, prepare) +} + +func runnerFor(t *testing.T, tr *testutil.FakeTransport, def eventlib.KeyDefinition, decision *appconsume.Decision) appconsume.StreamRunner { + t.Helper() + return runnerFunc(func(ctx context.Context, prepare appconsume.PrepareFunc) error { + return consume.Run(ctx, tr, chainAppID, "", "", consume.Options{ + EventKey: def.Key, + Def: &def, + Params: decision.NormalizedParams(), + ParamsNormalized: true, + Runtime: chainAPIClient{}, + Out: io.Discard, + ErrOut: io.Discard, + Quiet: true, + Timeout: 300 * time.Millisecond, + Prepare: prepare, + }) + }) +} diff --git a/internal/event/integration_test.go b/internal/event/integration_test.go index 798b1425e6..4eca9891ab 100644 --- a/internal/event/integration_test.go +++ b/internal/event/integration_test.go @@ -20,11 +20,11 @@ import ( "time" "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" "github.com/larksuite/cli/internal/event/bus" - "github.com/larksuite/cli/internal/event/protocol" - "github.com/larksuite/cli/internal/event/source" + "github.com/larksuite/cli/internal/event/catalog" "github.com/larksuite/cli/internal/event/testutil" - "github.com/larksuite/cli/internal/event/transport" ) type integTestOut struct{ A string } @@ -33,6 +33,20 @@ func integNativeSchema() event.SchemaDef { return event.SchemaDef{Native: &event.SchemaSpec{Type: reflect.TypeOf(integTestOut{})}} } +// compileTestSnapshot compiles synthetic declarations into the snapshot a +// bus or consumer under test is handed, replacing the removed global registry. +func compileTestSnapshot(t *testing.T, defs ...event.KeyDefinition) *catalog.Snapshot { + t.Helper() + snap, err := catalog.Compile(defs, catalog.StrategyRefs{ + catalog.StrategyNone, + catalog.StrategyLegacyPreConsume, + }) + if err != nil { + t.Fatalf("compile test catalog: %v", err) + } + return snap +} + func waitForBusReady(t *testing.T, tr transport.IPC, addr string) { t.Helper() deadline := time.Now().Add(2 * time.Second) @@ -69,7 +83,7 @@ type mockIntegSource struct { func (s *mockIntegSource) Name() string { return "mock-integration" } -func (s *mockIntegSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ source.StatusNotifier) error { +func (s *mockIntegSource) Start(ctx context.Context, _ []string, emit func(*event.RawEvent), _ func(state, detail string)) error { s.mu.Lock() s.emitFn = emit s.mu.Unlock() @@ -87,17 +101,14 @@ func (s *mockIntegSource) emit(e *event.RawEvent) { } func TestIntegration_BusToConsume(t *testing.T) { - event.ResetRegistryForTest() - source.ResetForTest() - event.RegisterKey(event.KeyDefinition{ + snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "test.event.v1", EventType: "test.event.v1", Schema: integNativeSchema(), }) mockSrc := &mockIntegSource{} - source.Register(mockSrc) dir := t.TempDir() addr := filepath.Join(dir, "t.sock") @@ -106,7 +117,7 @@ func TestIntegration_BusToConsume(t *testing.T) { logger := log.New(os.Stderr, "[test-bus] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-app", "test-secret", "", testTr, logger) + b := bus.NewBus("test-app", "test-secret", "", testTr, logger, snap, mockSrc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -185,17 +196,14 @@ func TestIntegration_BusToConsume(t *testing.T) { } func TestIntegration_MultipleConsumers(t *testing.T) { - event.ResetRegistryForTest() - source.ResetForTest() - event.RegisterKey(event.KeyDefinition{ + snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "multi.event.v1", EventType: "multi.event.v1", Schema: integNativeSchema(), }) mockSrc := &mockIntegSource{} - source.Register(mockSrc) dir := t.TempDir() addr := filepath.Join(dir, "m.sock") @@ -203,7 +211,7 @@ func TestIntegration_MultipleConsumers(t *testing.T) { logger := log.New(os.Stderr, "[test-multi] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-multi", "test-secret", "", testTr, logger) + b := bus.NewBus("test-multi", "test-secret", "", testTr, logger, snap, mockSrc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -279,17 +287,14 @@ func TestIntegration_MultipleConsumers(t *testing.T) { } func TestIntegration_DedupFilter(t *testing.T) { - event.ResetRegistryForTest() - source.ResetForTest() - event.RegisterKey(event.KeyDefinition{ + snap := compileTestSnapshot(t, event.KeyDefinition{ Key: "dedup.event.v1", EventType: "dedup.event.v1", Schema: integNativeSchema(), }) mockSrc := &mockIntegSource{} - source.Register(mockSrc) dir := t.TempDir() addr := filepath.Join(dir, "d.sock") @@ -297,7 +302,7 @@ func TestIntegration_DedupFilter(t *testing.T) { logger := log.New(os.Stderr, "[test-dedup] ", log.LstdFlags) testTr := testutil.NewWrappedFake(tr, addr) - b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger) + b := bus.NewBus("test-dedup", "test-secret", "", testTr, logger, snap, mockSrc) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/internal/event/model/event.go b/internal/event/model/event.go new file mode 100644 index 0000000000..5a949f31c4 --- /dev/null +++ b/internal/event/model/event.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package model holds the pure value types of the event kernel. It may import +// the standard library only; every other event package builds on top of it. +package model + +import ( + "encoding/json" + "time" +) + +// Event is the single canonical representation of one upstream event. +// The ingress adapter parses the envelope header exactly once and fills these +// fields; everything downstream propagates and validates them but never +// re-derives them from the payload. +type Event struct { + // EventID is the upstream unique id — the dedup and diagnostics anchor. + EventID string `json:"event_id"` + EventType string `json:"event_type"` + // SourceTime is the upstream create_time. When the upstream omits it, it + // stays visibly empty — it is never backfilled from a local clock. + SourceTime string `json:"source_time,omitempty"` + // AppID and TenantKey identify the tenant the event was delivered for, + // as parsed from the envelope header at ingress. + AppID string `json:"app_id,omitempty"` + TenantKey string `json:"tenant_key,omitempty"` + Payload json.RawMessage `json:"payload"` + // Timestamp is the local observation clock at ingress. SourceTime and + // Timestamp are two different facts and never substitute for each other. + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/event/preconsume_contract_test.go b/internal/event/preconsume_contract_test.go new file mode 100644 index 0000000000..b6d84d9784 --- /dev/null +++ b/internal/event/preconsume_contract_test.go @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +// PreConsume first/last lifecycle contract tests. +// +// These tests pin the setup/cleanup ownership semantics of the consume +// pipeline against a real in-process bus (fake transport on a temp socket): +// +// - PreConsume (setup) runs exactly once per key while at least one consumer +// stays connected — a duplicate setup would register a duplicate +// server-side subscription record. +// - cleanup runs at most once, and only when the exiting consumer is the +// LAST one for its key — an early cleanup would tear down a server-side +// subscription that other still-live consumers depend on. +// +// They are a frozen baseline: any refactor of the bus/consume lifecycle must +// keep these green (or consciously revisit the contract — see the note on +// TestPreConsumeContract_OwnerExitsFirst). +package event_test + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/event/bus" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/consume" + "github.com/larksuite/cli/internal/event/testutil" +) + +// contractAppSeq makes every test invocation use a distinct app id. The bus +// pins its per-app-id alive lock fd until process exit, so reusing an app id +// within one test process (e.g. under -count=2) would make the second bus +// conclude another bus is already running and refuse to start. +var contractAppSeq atomic.Int64 + +type preConsumeCounters struct { + setup atomic.Int64 + cleanup atomic.Int64 +} + +// contractKey declares a synthetic EventKey whose PreConsume counts setups. +// With withCleanup, PreConsume returns a closure that counts cleanups; +// without, it returns (nil, nil) — modeling keys whose server-side +// subscription is a durable relationship with deliberately no unsubscribe. +// Callers compile the declaration into the snapshot the bus and consumers use. +func contractKey(key string, withCleanup bool) (event.KeyDefinition, *preConsumeCounters) { + c := &preConsumeCounters{} + def := event.KeyDefinition{ + Key: key, + EventType: key, + Schema: integNativeSchema(), + PreConsume: func(context.Context, event.APIClient, map[string]string) (func() error, error) { + c.setup.Add(1) + if !withCleanup { + return nil, nil + } + return func() error { + c.cleanup.Add(1) + return nil + }, nil + }, + } + return def, c +} + +// resolveDef pulls the canonical definition for a key out of a compiled +// snapshot — what the CLI entry point hands consume.Run as Options.Def. +func resolveDef(t *testing.T, snap *catalog.Snapshot, key string) *event.KeyDefinition { + t.Helper() + entry, ok := snap.Resolve(key) + if !ok { + t.Fatalf("compiled snapshot has no entry for %s", key) + } + return entry.Definition() +} + +// startContractBus runs an in-process bus on a temp-dir socket with a mock +// source (so no real upstream connection is attempted) and returns the fake +// transport plus the unique app id consumers must use. snap is the compiled +// catalog the bus serves. +func startContractBus(t *testing.T, snap *catalog.Snapshot) (*testutil.FakeTransport, string) { + t.Helper() + + appID := fmt.Sprintf("preconsume-contract-%d-%d", os.Getpid(), contractAppSeq.Add(1)) + // Short-named temp dir instead of t.TempDir(): the long test names would + // push the unix socket path past the OS sun_path limit (~104 bytes on + // macOS), making bind fail with EINVAL. + sockDir, err := os.MkdirTemp("", "pcc-*") + if err != nil { + t.Fatalf("create socket dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(sockDir) }) + addr := filepath.Join(sockDir, "s") + tr := testutil.NewWrappedFake(transport.New(), addr) + logger := log.New(os.Stderr, "[contract-bus] ", log.LstdFlags) + b := bus.NewBus(appID, "test-secret", "", tr, logger, snap, &mockIntegSource{}) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + runBus(t, b, ctx) + // Registered after runBus so it fires before runBus's wait-for-exit cleanup. + t.Cleanup(cancel) + waitForBusReady(t, tr, addr) + return tr, appID +} + +// contractConsumer drives one consume.Run in a goroutine with its own +// cancellable context, so tests control exit order deterministically. +type contractConsumer struct { + name string + cancel context.CancelFunc + done chan error +} + +func startContractConsumer(t *testing.T, tr transport.IPC, appID string, def *event.KeyDefinition, name string) *contractConsumer { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + h := &contractConsumer{name: name, cancel: cancel, done: make(chan error, 1)} + go func() { + h.done <- consume.Run(ctx, tr, appID, "", "", consume.Options{ + EventKey: def.Key, + Def: def, + Params: map[string]string{}, + Quiet: true, + Out: io.Discard, + ErrOut: io.Discard, + }) + }() + return h +} + +// stop cancels the consumer's context and waits for consume.Run to return, +// i.e. its shutdown path (last-for-key check + possible cleanup) completed. +func (h *contractConsumer) stop(t *testing.T) { + t.Helper() + h.cancel() + select { + case err := <-h.done: + if err != nil { + t.Fatalf("consumer %s exited with error: %v", h.name, err) + } + case <-time.After(10 * time.Second): + t.Fatalf("consumer %s did not exit within 10s after cancel", h.name) + } +} + +func waitForState(t *testing.T, desc string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", desc) +} + +// busSubscriberCount asks the bus (status query round-trip) how many consumer +// connections its hub currently tracks. The count drops only after the bus +// fully processed a disconnect, so polling it makes exit ordering +// deterministic instead of sleep-based. +func busSubscriberCount(tr transport.IPC, addr string) (int, error) { + conn, err := tr.Dial(addr) + if err != nil { + return 0, err + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + return 0, err + } + if err := protocol.Encode(conn, protocol.NewStatusQuery()); err != nil { + return 0, err + } + line, err := protocol.ReadFrame(bufio.NewReader(conn)) + if err != nil { + return 0, err + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + return 0, err + } + resp, ok := msg.(*protocol.StatusResponse) + if !ok { + return 0, fmt.Errorf("expected StatusResponse, got %T", msg) + } + return resp.ActiveConns, nil +} + +func waitForSubscriberCount(t *testing.T, tr transport.IPC, appID string, want int) { + t.Helper() + addr := tr.Address(appID) + waitForState(t, fmt.Sprintf("bus subscriber count == %d", want), func() bool { + n, err := busSubscriberCount(tr, addr) + return err == nil && n == want + }) +} + +// TestPreConsumeContract_NonOwnerExitsFirst: A connects first (runs setup, +// holds the cleanup closure), B joins, then B exits BEFORE A. B is not the +// last consumer for the key, so nothing may be cleaned up while A still +// depends on the server-side subscription. A then exits last and runs cleanup +// exactly once. +func TestPreConsumeContract_NonOwnerExitsFirst(t *testing.T) { + const key = "contract.nonowner.v1" + keyDef, counters := contractKey(key, true) + snap := compileTestSnapshot(t, keyDef) + def := resolveDef(t, snap, key) + tr, appID := startContractBus(t, snap) + + a := startContractConsumer(t, tr, appID, def, "A") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) + + // A is registered and set up, so B deterministically joins as non-first + // and never runs PreConsume (no duplicate server-side subscription). + b := startContractConsumer(t, tr, appID, def, "B") + waitForSubscriberCount(t, tr, appID, 2) + + // B exits while A is still connected: not last for the key, no cleanup. + b.stop(t) + // Wait until the bus fully processed B's disconnect before exiting A, + // so A's last-for-key check cannot race B's teardown. + waitForSubscriberCount(t, tr, appID, 1) + + // A exits last: it holds the cleanup closure and is last, cleanup runs once. + a.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + if got := counters.setup.Load(); got != 1 { + t.Errorf("setup ran %d times, want 1 (duplicate setup would register duplicate server-side subscriptions)", got) + } + if got := counters.cleanup.Load(); got != 1 { + t.Errorf("cleanup ran %d times, want 1 (last consumer must tear down the subscription exactly once)", got) + } +} + +// TestPreConsumeContract_OwnerExitsFirst: A connects first (runs setup, holds +// the cleanup closure), B joins, then A — the closure OWNER — exits first. +// +// This pins the CURRENT, KNOWN-LEAK semantics: cleanup ownership is not +// transferable. A skips cleanup because B is still connected (correct — B +// depends on the subscription), but when B later exits as the last consumer +// it has no closure to run, so the server-side subscription is never cleaned +// up. Total cleanups: 0. +// +// If the cleanup==0 assertion here turns red, the cleanup ownership semantics +// have been changed (e.g. handing the closure off to survivors). That may +// well be an improvement, but it alters observable lifecycle behavior for +// every EventKey — take it through design review first, then update this +// baseline deliberately. +func TestPreConsumeContract_OwnerExitsFirst(t *testing.T) { + const key = "contract.owner.v1" + keyDef, counters := contractKey(key, true) + snap := compileTestSnapshot(t, keyDef) + def := resolveDef(t, snap, key) + tr, appID := startContractBus(t, snap) + + a := startContractConsumer(t, tr, appID, def, "A") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) + + b := startContractConsumer(t, tr, appID, def, "B") + waitForSubscriberCount(t, tr, appID, 2) + + // Owner exits first: B is still connected, so A must NOT run cleanup — + // doing so would unsubscribe the server-side state B depends on. + a.stop(t) + waitForSubscriberCount(t, tr, appID, 1) + + // B exits last: it is last for the key, but it never ran PreConsume and + // holds no cleanup closure — nothing runs. This is the leak being pinned. + b.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + if got := counters.setup.Load(); got != 1 { + t.Errorf("setup ran %d times, want 1 (B joined as non-first and must not re-run setup)", got) + } + if got := counters.cleanup.Load(); got != 0 { + t.Errorf("cleanup ran %d times, want 0 — current contract leaks the server-side subscription when the setup owner exits before the last consumer; see the test comment before changing this", got) + } +} + +// TestPreConsumeContract_NoCleanupKey: the key's PreConsume returns (nil, nil) +// — setup establishes a durable server-side relationship that is deliberately +// never unsubscribed. Regardless of exit order, cleanup count stays 0, and +// setup re-runs each time a consumer becomes first for the key again. +func TestPreConsumeContract_NoCleanupKey(t *testing.T) { + const key = "contract.nocleanup.v1" + keyDef, counters := contractKey(key, false) + snap := compileTestSnapshot(t, keyDef) + def := resolveDef(t, snap, key) + tr, appID := startContractBus(t, snap) + + // Round 1: non-owner exits first. + a := startContractConsumer(t, tr, appID, def, "A") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer A pre-consume setup", func() bool { return counters.setup.Load() == 1 }) + b := startContractConsumer(t, tr, appID, def, "B") + waitForSubscriberCount(t, tr, appID, 2) + b.stop(t) + waitForSubscriberCount(t, tr, appID, 1) + a.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + // Round 2: owner exits first. The key's consumer count dropped to zero + // above, so C is first for the key again and setup runs a second time. + c := startContractConsumer(t, tr, appID, def, "C") + waitForSubscriberCount(t, tr, appID, 1) + waitForState(t, "consumer C pre-consume setup", func() bool { return counters.setup.Load() == 2 }) + d := startContractConsumer(t, tr, appID, def, "D") + waitForSubscriberCount(t, tr, appID, 2) + c.stop(t) + waitForSubscriberCount(t, tr, appID, 1) + d.stop(t) + waitForSubscriberCount(t, tr, appID, 0) + + if got := counters.setup.Load(); got != 2 { + t.Errorf("setup ran %d times, want 2 (once per first-for-key consumer)", got) + } + if got := counters.cleanup.Load(); got != 0 { + t.Errorf("cleanup ran %d times, want 0 (key declares no cleanup: the server-side subscription is a durable relationship)", got) + } +} diff --git a/internal/event/processing/result.go b/internal/event/processing/result.go new file mode 100644 index 0000000000..8456fdd08f --- /dev/null +++ b/internal/event/processing/result.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package processing defines how a domain processor's outcome is interpreted +// by the consume pipeline: emit exactly what the key's schema declares, or +// drop with a stated reason — never leak an undeclared shape to stdout. +package processing + +import ( + "context" + "encoding/json" + "errors" +) + +// APIClient is the narrow API surface handed to domain hooks. Identity stays +// opaque on purpose so business code cannot bypass the pre-flight checks. +type APIClient interface { + CallAPI(ctx context.Context, method, path string, body any) (json.RawMessage, error) +} + +type dropMalformedError struct{ eventType string } + +func (d *dropMalformedError) Error() string { + return "malformed payload for " + d.eventType +} + +// DropMalformed signals that the payload could not be decoded into the shape +// this EventKey declares. The event is dropped instead of passing the raw +// envelope through to stdout, which would violate the declared output schema. +func DropMalformed(eventType string) error { + return &dropMalformedError{eventType: eventType} +} + +// IsDropMalformed reports whether err marks an event dropped for a malformed +// payload, letting the pipeline pick the right diagnostic without parsing +// error strings. +func IsDropMalformed(err error) bool { + var d *dropMalformedError + return errors.As(err, &d) +} diff --git a/internal/event/registry.go b/internal/event/registry.go deleted file mode 100644 index dd684be674..0000000000 --- a/internal/event/registry.go +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package event - -import ( - "fmt" - "sort" - "sync" -) - -var ( - keys = map[string]*KeyDefinition{} - mu sync.RWMutex -) - -// RegisterKey panics on duplicate Key, empty EventType, or schema/process contract violations. -func RegisterKey(def KeyDefinition) { - mu.Lock() - defer mu.Unlock() - - if _, exists := keys[def.Key]; exists { - panic(fmt.Sprintf("duplicate EventKey: %s", def.Key)) - } - if def.EventType == "" { - 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) - - if def.BufferSize > MaxBufferSize { - def.BufferSize = MaxBufferSize - } - if def.BufferSize <= 0 { - def.BufferSize = DefaultBufferSize - } - if def.Workers <= 0 { - def.Workers = 1 - } - keys[def.Key] = &def -} - -// validateSchema: exactly one of Native/Custom; Native incompatible with Process. -func validateSchema(def KeyDefinition) { - nativeSet := def.Schema.Native != nil - customSet := def.Schema.Custom != nil - if nativeSet && customSet { - panic(fmt.Sprintf("EventKey %s: Schema.Native and Schema.Custom are mutually exclusive", def.Key)) - } - if !nativeSet && !customSet { - panic(fmt.Sprintf("EventKey %s: Schema requires either Native or Custom", def.Key)) - } - if nativeSet && def.Process != nil { - panic(fmt.Sprintf("EventKey %s: Schema.Native forbids Process (Process produces a complete shape — use Schema.Custom)", def.Key)) - } - if spec := def.Schema.Native; spec != nil { - validateSpec(def.Key, "Schema.Native", spec) - } - if spec := def.Schema.Custom; spec != nil { - validateSpec(def.Key, "Schema.Custom", spec) - } -} - -func validateSpec(key, field string, s *SchemaSpec) { - typeSet := s.Type != nil - rawSet := len(s.Raw) > 0 - if typeSet == rawSet { - panic(fmt.Sprintf("EventKey %s: %s requires exactly one of Type or Raw", key, field)) - } -} - -func validateParams(def KeyDefinition) { - for _, p := range def.Params { - switch p.Type { - case "", ParamString, ParamBool, ParamInt: - case ParamEnum, ParamMulti: - if len(p.Values) == 0 { - panic(fmt.Sprintf("EventKey %s: param %q type %q requires Values", def.Key, p.Name, p.Type)) - } - for _, v := range p.Values { - if v.Desc == "" { - panic(fmt.Sprintf("EventKey %s: param %q value %q requires non-empty Desc", def.Key, p.Name, v.Value)) - } - } - default: - panic(fmt.Sprintf("EventKey %s: param %q has unknown type %q", def.Key, p.Name, p.Type)) - } - } -} - -func validateAuth(def KeyDefinition) { - for _, t := range def.AuthTypes { - if t != "user" && t != "bot" { - panic(fmt.Sprintf("EventKey %s: AuthTypes elements must be \"user\" or \"bot\"; got %q", def.Key, t)) - } - } -} - -func Lookup(key string) (*KeyDefinition, bool) { - mu.RLock() - defer mu.RUnlock() - def, ok := keys[key] - return def, ok -} - -// ListAll returns all KeyDefinitions sorted by Key. -func ListAll() []*KeyDefinition { - mu.RLock() - defer mu.RUnlock() - result := make([]*KeyDefinition, 0, len(keys)) - for _, def := range keys { - result = append(result, def) - } - sort.Slice(result, func(i, j int) bool { - return result[i].Key < result[j].Key - }) - return result -} - -func resetRegistry() { - mu.Lock() - defer mu.Unlock() - keys = map[string]*KeyDefinition{} -} - -func ResetRegistryForTest() { resetRegistry() } - -// UnregisterKeyForTest removes one key — use this (not Reset) in tests with synthetic keys -// alongside production keys to keep -count=N reruns idempotent. -func UnregisterKeyForTest(key string) { - mu.Lock() - defer mu.Unlock() - delete(keys, key) -} diff --git a/internal/event/registry_test.go b/internal/event/registry_test.go deleted file mode 100644 index 9de2780796..0000000000 --- a/internal/event/registry_test.go +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package event - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "strings" - "testing" -) - -func mustPanic(t *testing.T, substring string) { - t.Helper() - r := recover() - if r == nil { - t.Fatal("expected panic, got none") - } - msg, _ := r.(string) - if msg == "" { - if err, ok := r.(error); ok { - msg = err.Error() - } else { - msg = fmt.Sprintf("%v", r) - } - } - if !strings.Contains(msg, substring) { - t.Errorf("panic %q does not contain %q", msg, substring) - } -} - -type emptyOut struct { - A string `json:"a"` -} - -func nativeSchema() SchemaDef { - return SchemaDef{Native: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}} -} - -func customSchema() SchemaDef { - return SchemaDef{Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}} -} - -func customProcess() func(context.Context, APIClient, *RawEvent, map[string]string) (json.RawMessage, error) { - return func(context.Context, APIClient, *RawEvent, map[string]string) (json.RawMessage, error) { - return nil, nil - } -} - -func TestRegisterKey_NativeOnly(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{ - Key: "t.native", - EventType: "t.native", - Schema: nativeSchema(), - }) - def, ok := Lookup("t.native") - if !ok { - t.Fatal("Lookup failed") - } - if def.Schema.Native == nil { - t.Fatal("Native not stored") - } - if def.Process != nil { - t.Error("Process should be nil for Native") - } -} - -func TestRegisterKey_CustomWithProcess(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{ - Key: "t.custom", - EventType: "t.custom", - Schema: customSchema(), - Process: customProcess(), - }) - def, ok := Lookup("t.custom") - if !ok { - t.Fatal("Lookup failed") - } - if def.Schema.Custom == nil { - t.Fatal("Custom not stored") - } - if def.Process == nil { - t.Error("Process should be set") - } -} - -func TestRegisterKey_DuplicatePanics(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{Key: "t.dup", EventType: "t.dup", Schema: nativeSchema()}) - defer mustPanic(t, "duplicate EventKey") - RegisterKey(KeyDefinition{Key: "t.dup", EventType: "t.dup", Schema: nativeSchema()}) -} - -func TestRegisterKey_EmptyEventTypePanics(t *testing.T) { - resetRegistry() - defer mustPanic(t, "EventType must not be empty") - RegisterKey(KeyDefinition{Key: "t.no_type", Schema: nativeSchema()}) -} - -func TestRegisterKey_PanicsWhenBothSchemasSet(t *testing.T) { - resetRegistry() - defer mustPanic(t, "mutually exclusive") - RegisterKey(KeyDefinition{ - Key: "t.both", - EventType: "t.both", - Schema: SchemaDef{ - Native: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}, - Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{})}, - }, - }) -} - -func TestRegisterKey_PanicsWhenNoSchemaSet(t *testing.T) { - resetRegistry() - defer mustPanic(t, "Schema requires either Native or Custom") - RegisterKey(KeyDefinition{Key: "t.empty", EventType: "t.empty"}) -} - -func TestRegisterKey_PanicsWhenNativeWithProcess(t *testing.T) { - resetRegistry() - defer mustPanic(t, "Schema.Native forbids Process") - RegisterKey(KeyDefinition{ - Key: "t.badcombo", - EventType: "t.badcombo", - Schema: nativeSchema(), - Process: customProcess(), - }) -} - -func TestRegisterKey_PanicsWhenSpecHasBothTypeAndRaw(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires exactly one of Type or Raw") - RegisterKey(KeyDefinition{ - Key: "t.bothsrc", - EventType: "t.bothsrc", - Schema: SchemaDef{ - Custom: &SchemaSpec{Type: reflect.TypeOf(emptyOut{}), Raw: json.RawMessage(`{}`)}, - }, - }) -} - -func TestRegisterKey_PanicsWhenSpecHasNeitherTypeNorRaw(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires exactly one of Type or Raw") - RegisterKey(KeyDefinition{ - Key: "t.nosrc", - EventType: "t.nosrc", - Schema: SchemaDef{ - Custom: &SchemaSpec{}, - }, - }) -} - -func TestRegisterKey_ParamMultiRequiresValues(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires Values") - RegisterKey(KeyDefinition{ - Key: "t.paramnovalues", - EventType: "t.paramnovalues", - Schema: nativeSchema(), - Params: []ParamDef{{Name: "fields", Type: ParamMulti}}, - }) -} - -func TestRegisterKey_ParamEnumRequiresValues(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires Values") - RegisterKey(KeyDefinition{ - Key: "t.enumnovalues", - EventType: "t.enumnovalues", - Schema: nativeSchema(), - Params: []ParamDef{{Name: "mode", Type: ParamEnum}}, - }) -} - -func TestRegisterKey_ParamValueRequiresDesc(t *testing.T) { - resetRegistry() - defer mustPanic(t, "requires non-empty Desc") - RegisterKey(KeyDefinition{ - Key: "t.paramdesc", - EventType: "t.paramdesc", - Schema: nativeSchema(), - Params: []ParamDef{{ - Name: "f", - Type: ParamEnum, - Values: []ParamValue{{Value: "x"}}, - }}, - }) -} - -func TestRegisterKey_UnknownParamType(t *testing.T) { - resetRegistry() - defer mustPanic(t, "unknown type") - RegisterKey(KeyDefinition{ - Key: "t.badtype", - EventType: "t.badtype", - Schema: nativeSchema(), - Params: []ParamDef{{Name: "x", Type: ParamType("wtf")}}, - }) -} - -func TestRegisterKey_InvalidAuthTypesPanics(t *testing.T) { - resetRegistry() - defer mustPanic(t, "AuthTypes elements must be") - RegisterKey(KeyDefinition{ - Key: "t.badauth", - EventType: "t.badauth", - Schema: nativeSchema(), - AuthTypes: []string{"invalid"}, - }) -} - -func TestRegisterKey_ValidAuthTypes(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{Key: "u.e", EventType: "u.e", Schema: nativeSchema(), AuthTypes: []string{"user"}}) - RegisterKey(KeyDefinition{Key: "b.e", EventType: "b.e", Schema: nativeSchema(), AuthTypes: []string{"bot"}}) - RegisterKey(KeyDefinition{Key: "ub.e", EventType: "ub.e", Schema: nativeSchema(), AuthTypes: []string{"bot", "user"}}) - RegisterKey(KeyDefinition{Key: "na.e", EventType: "na.e", Schema: nativeSchema()}) -} - -func TestListAll_SortedByKey(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{Key: "z.event", EventType: "z", Schema: nativeSchema()}) - RegisterKey(KeyDefinition{Key: "a.event", EventType: "a", Schema: nativeSchema()}) - RegisterKey(KeyDefinition{Key: "m.event", EventType: "m", Schema: nativeSchema()}) - all := ListAll() - if len(all) != 3 || all[0].Key != "a.event" || all[1].Key != "m.event" || all[2].Key != "z.event" { - t.Errorf("keys not sorted: %v", []string{all[0].Key, all[1].Key, all[2].Key}) - } -} - -func TestBufferSize_Clamped(t *testing.T) { - resetRegistry() - RegisterKey(KeyDefinition{ - Key: "big", EventType: "big", Schema: nativeSchema(), - BufferSize: 5000, - }) - def, _ := Lookup("big") - if def.BufferSize != MaxBufferSize { - 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/source/source.go b/internal/event/source/source.go deleted file mode 100644 index 1b17f84d94..0000000000 --- a/internal/event/source/source.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -// Package source is a pluggable event source abstraction (separate package to keep -// business registrations free of SDK transitive deps). -package source - -import ( - "context" - "sync" - - "github.com/larksuite/cli/internal/event" -) - -// StatusNotifier surfaces SourceState* lifecycle states; detail is free-form context. -type StatusNotifier func(state, detail string) - -// Source produces events; emit MUST return quickly (anything slow stalls the SDK read loop). -type Source interface { - Name() string - Start(ctx context.Context, eventTypes []string, emit func(*event.RawEvent), notify StatusNotifier) error -} - -var ( - registry []Source - registryMu sync.Mutex -) - -func Register(s Source) { - registryMu.Lock() - defer registryMu.Unlock() - registry = append(registry, s) -} - -func All() []Source { - registryMu.Lock() - defer registryMu.Unlock() - out := make([]Source, len(registry)) - copy(out, registry) - return out -} - -func ResetForTest() { - registryMu.Lock() - defer registryMu.Unlock() - registry = nil -} diff --git a/internal/event/testutil/busstub.go b/internal/event/testutil/busstub.go new file mode 100644 index 0000000000..11a5d81a40 --- /dev/null +++ b/internal/event/testutil/busstub.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package testutil + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net" + "testing" + + "github.com/larksuite/cli/internal/event/adapter/localbus/protocol" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" + "github.com/larksuite/cli/internal/vfs" +) + +// BusStub speaks just enough of the local bus protocol to drive a real +// consumer: it answers the status probe, replies to the hello with a +// caller-supplied ack, and then writes caller-supplied event frames. +// +// The ack and the frames are raw lines on purpose. Reproducing what an older +// bus put on the wire is the point — building them through the current +// protocol constructors would encode today's frame shape and prove nothing +// about compatibility. +type BusStub struct { + rawAck string + rawFrames []string +} + +// NewBusStub returns a stub that answers hello with rawAck and then writes +// rawFrames, in order, to every consumer that attaches. +func NewBusStub(rawAck string, rawFrames ...string) *BusStub { + return &BusStub{rawAck: rawAck, rawFrames: rawFrames} +} + +// Listen starts the stub and returns the transport a consumer should dial. +// +// The socket lives in a short directory of its own rather than t.TempDir(): +// that path embeds the test name, and a unix socket path has a low length +// limit, so a descriptive subtest name would fail the bind. +func (s *BusStub) Listen(t *testing.T, appID string) *FakeTransport { + t.Helper() + dir, err := vfs.MkdirTemp("", "busstub-*") + if err != nil { + t.Fatalf("stub bus tempdir: %v", err) + } + t.Cleanup(func() { _ = vfs.RemoveAll(dir) }) + + tr := NewWrappedFake(transport.New(), dir+"/bus.sock") + ln, err := tr.Listen(appID) + if err != nil { + t.Fatalf("stub bus listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go s.serve(conn) + } + }() + return tr +} + +func (s *BusStub) serve(conn net.Conn) { + defer conn.Close() + br := bufio.NewReader(conn) + for { + line, err := protocol.ReadFrame(br) + if err != nil { + return + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + continue + } + switch msg.(type) { + case *protocol.StatusQuery: + _ = protocol.Encode(conn, protocol.NewStatusResponse(1, 1, 0, nil)) + return + case *protocol.Hello: + if _, err := fmt.Fprintf(conn, "%s\n", s.rawAck); err != nil { + return + } + for _, frame := range s.rawFrames { + if _, err := fmt.Fprintf(conn, "%s\n", frame); err != nil { + return + } + } + // Hold the connection open like a real bus would, so the consumer + // decides when to walk away. + _, _ = protocol.ReadFrame(br) + return + } + } +} + +// LegacyAck is the hello_ack a bus predating canonical metadata sent: no +// capability list at all. +const LegacyAck = `{"type":"hello_ack","bus_version":"v1","first_for_key":true}` + +// LegacyEventFrame renders the event frame such a bus wrote: the fields that +// version knew about and nothing else. app_id, tenant_key and observed_at are +// absent because they did not exist yet. +func LegacyEventFrame(eventType, eventID, sourceTime string, seq uint64, payload json.RawMessage) string { + frame := map[string]any{ + "type": "event", + "event_type": eventType, + "event_id": eventID, + "seq": seq, + "payload": payload, + } + if sourceTime != "" { + frame["source_time"] = sourceTime + } + raw, err := json.Marshal(frame) + if err != nil { + panic("testutil: marshal legacy frame: " + err.Error()) + } + return string(raw) +} diff --git a/internal/event/testutil/testutil.go b/internal/event/testutil/testutil.go index 600156787d..803b5493c9 100644 --- a/internal/event/testutil/testutil.go +++ b/internal/event/testutil/testutil.go @@ -11,7 +11,7 @@ import ( "net" "sync" - "github.com/larksuite/cli/internal/event/transport" + "github.com/larksuite/cli/internal/event/adapter/localbus/transport" ) // FakeTransport delegates to inner with a fixed addr, so tests can use t.TempDir paths. diff --git a/internal/event/types.go b/internal/event/types.go index 28592c2749..4dc8c09db6 100644 --- a/internal/event/types.go +++ b/internal/event/types.go @@ -1,171 +1,50 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package event owns the EventKey registry, RawEvent, APIClient, and dedup filter. +// Package event is the compatibility facade over the event kernel packages: +// the value model (model), the declaration/compilation layer (catalog), the +// processing contracts (processing), and the dedup filter kept here. New code +// should import the owning package directly; the aliases keep the existing +// declaration and call sites compiling unchanged. package event import ( - "context" - "encoding/json" - "reflect" - "time" - - "github.com/larksuite/cli/internal/event/schemas" + "github.com/larksuite/cli/internal/event/catalog" + "github.com/larksuite/cli/internal/event/model" + "github.com/larksuite/cli/internal/event/processing" ) const ( - DefaultBufferSize = 100 - MaxBufferSize = 1000 + DefaultBufferSize = catalog.DefaultBufferSize + MaxBufferSize = catalog.MaxBufferSize ) -// RawEvent: SourceTime is upstream create_time; Timestamp is local source observation time. -type RawEvent struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - SourceTime string `json:"source_time,omitempty"` - Payload json.RawMessage `json:"payload"` - Timestamp time.Time `json:"timestamp"` -} - -// APIClient: identity is opaque so business code can't bypass pre-flight checks. -type APIClient interface { - CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) -} - -type ParamType string - -const ( - ParamString ParamType = "string" - ParamEnum ParamType = "enum" - ParamMulti ParamType = "multi" - ParamBool ParamType = "bool" - ParamInt ParamType = "int" +// RawEvent is the canonical event fact carrier; see model.Event for the field +// contracts. +type RawEvent = model.Event + +// APIClient is the narrow API surface handed to domain hooks; see +// processing.APIClient for the contract. +type APIClient = processing.APIClient + +type ( + ParamType = catalog.ParamType + SubscriptionType = catalog.SubscriptionType + ParamValue = catalog.ParamValue + ParamDef = catalog.ParamDef + ProcessFunc = catalog.ProcessFunc + SchemaDef = catalog.SchemaDef + SchemaSpec = catalog.SchemaSpec + KeyDefinition = catalog.KeyDefinition ) -// 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" + ParamString = catalog.ParamString + ParamEnum = catalog.ParamEnum + ParamMulti = catalog.ParamMulti + ParamBool = catalog.ParamBool + ParamInt = catalog.ParamInt + + SubTypeEvent = catalog.SubTypeEvent + SubTypeCallback = catalog.SubTypeCallback ) - -// ParamValue.Desc is mandatory so AI consumers can decide which value to pick. -type ParamValue struct { - Value string `json:"value"` - Desc string `json:"desc"` -} - -type ParamDef struct { - Name string `json:"name"` - Type ParamType `json:"type"` - Required bool `json:"required"` - Default string `json:"default,omitempty"` - Description string `json:"description"` - Values []ParamValue `json:"values,omitempty"` - - // SubscriptionKey marks this param as part of the subscription identity. - // Two consumers of the same EventKey but different values for any - // SubscriptionKey-marked param are treated as DISTINCT subscriptions: - // PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per - // (EventKey, SubscriptionID). - // - // CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side - // subscribe/unsubscribe API is itself scoped to that resource. Lark keys the - // subscription record by (app, user, event_type) and overwrites it rather - // than reference-counting, so for a non-per-resource API the cleanup of one - // resource's last consumer unsubscribes the shared record and silently cuts - // off every other resource sharing that event_type. - // - // Default false = the param is a filter / formatting / metadata param - // and does not affect subscription identity. - SubscriptionKey bool `json:"subscription_key,omitempty"` -} - -type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) - -// SchemaDef: exactly one of Native or Custom must be set. -// Native auto-wraps the SDK type in the V2 envelope; Custom passes through verbatim. -type SchemaDef struct { - Native *SchemaSpec `json:"native,omitempty"` - Custom *SchemaSpec `json:"custom,omitempty"` - FieldOverrides map[string]schemas.FieldMeta `json:"field_overrides,omitempty"` -} - -// SchemaSpec: exactly one of Type or Raw. -type SchemaSpec struct { - Type reflect.Type `json:"-"` - Raw json.RawMessage `json:"raw,omitempty"` -} - -type KeyDefinition struct { - Key string `json:"key"` - DisplayName string `json:"display_name,omitempty"` - 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"` - - // NormalizeParams canonicalizes param values BEFORE fingerprint compute, - // PreConsume, Match, and Process. Mutates the params map in place. - // May call OAPI; runs once per consumer at startup. - // - // Use cases: resolve aliases ("me" -> real email, a name -> an ID), - // trim whitespace. On error, consume fails (no retry); caller gets the - // wrapped error. - // - // Default nil = no normalization, params pass through unchanged. - NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"` - - // Process required when Schema.Custom is Processed output; must be nil when Native is used. - // - // Convention: returning (nil, nil) signals "drop this event" — the - // consumer loop will skip writing it to sink and not advance the - // emitted counter. Useful for async filtering (e.g. fetch metadata, - // drop if folder doesn't match). For sync filters that don't need - // OAPI, use Match instead. - Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"` - - // Match is a synchronous payload filter run on every received event - // BEFORE Process. Return false to drop the event without further work. - // - // Signature deliberately omits ctx/rt to physically enforce "no OAPI - // calls in Match". For filters that need a metadata fetch first, use - // Process and return nil to drop. - // - // Default nil = accept all events. - Match func(raw *RawEvent, params map[string]string) bool `json:"-"` - - // PreConsume runs once per (EventKey, SubscriptionID) when this consumer - // is first for that scope. Returns a cleanup function that the framework - // invokes when this consumer is the last for its scope. - // - // The cleanup's error return is honored: on nil the framework prints - // "[event] cleanup done."; on non-nil it prints a WARN with an - // idempotency note. - PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"` - - Scopes []string `json:"scopes,omitempty"` - - // AuthTypes: whitelist of identities the EventKey accepts. Empty = no identity required. - AuthTypes []string `json:"auth_types,omitempty"` - - RequiredConsoleEvents []string `json:"required_console_events,omitempty"` - - 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"` -} diff --git a/internal/qualitygate/config/allowlists/fixture-domains.txt b/internal/qualitygate/config/allowlists/fixture-domains.txt index 7f3b14395f..d1c465cd25 100644 --- a/internal/qualitygate/config/allowlists/fixture-domains.txt +++ b/internal/qualitygate/config/allowlists/fixture-domains.txt @@ -2,6 +2,7 @@ abc.feishu.cn attacker.example.com bytedance.feishu.cn +cdn.example.com cdn.feishu.cn evil.example.com example.feishu.cn @@ -16,6 +17,7 @@ host.lima.internal lf3-static.bytednsdoc.com meetings.feishu.cn meetings.larksuite.com +open.feishu.cn.example.com p3-lark-file.byteimg.com passport.feishu.cn sample.feishu.cn diff --git a/lint/domaincontract/unapproved_test.go b/lint/domaincontract/unapproved_test.go index 1b671d7ddf..03e890a7d2 100644 --- a/lint/domaincontract/unapproved_test.go +++ b/lint/domaincontract/unapproved_test.go @@ -285,7 +285,7 @@ var source = FeishuSource{Domain: "events.example.com"} ` got := evidenceHosts(scanTypedDomainEvidenceInPackage( t, - "github.com/larksuite/cli/internal/event/source", + "github.com/larksuite/cli/internal/event/adapter/lark/websocket", source, )) want := []string{"api.example.com", "events.example.com"} diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 321bdecb0c..2aa9313898 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -32,7 +32,7 @@ metadata: | `--max-events N` | Exit after N events. Default 0 = unlimited | | `--timeout D` | Exit after duration D (e.g. `30s`, `2m`). Default 0 = no timeout. Whichever of `--max-events` / `--timeout` fires first wins | | `--output-dir ` | Write each event as a file (relative paths only; prevents traversal) | -| `--quiet` | Suppress stderr diagnostics. **AI should not use this** — it silences the ready marker | +| `--quiet` | Suppress ready/exit markers and per-event stderr diagnostics, including drop warnings. This can hide event loss. **AI should not use this** — it removes readiness and integrity signals | | `--as user\|bot\|auto` | Identity for the session (see lark-shared) | @@ -42,6 +42,9 @@ metadata: # Default: stream every event for the key (no filter, no projection) lark-cli event consume im.message.receive_v1 --as bot +# List every EventKey of one domain (the authoritative, always-current catalog) +lark-cli event list --domain vc --json + # Grab one sample event to inspect payload shape lark-cli event consume im.message.receive_v1 --max-events 1 --timeout 30s --as bot @@ -57,7 +60,7 @@ wait ## Call flow -1. `lark-cli event list --json` → pick a legal key +1. `lark-cli event list --json` → pick a legal key. `--domain ` narrows to one domain; the domains are `application`, `approval`, `board`, `card`, `im`, `minutes`, `task`, `vc`. An unknown domain fails with the valid set listed in the hint. 2. `lark-cli event schema --json` → read `resolved_output_schema` + `jq_root_path` to determine field paths 3. `lark-cli event consume [--jq '']` → consume @@ -94,7 +97,7 @@ Orchestrators should treat `reason: limit/timeout/signal` (all exit 0) as "busin ### Never `kill -9` -**Avoid `kill -9` on consume processes**: for EventKeys with a **PreConsume hook** (those that register server-side subscriptions via OAPI), `kill -9` skips the OAPI unsubscribe and leaks server-side subscriptions (symptoms: "subscription already exists" on restart, duplicate event delivery). Prefer SIGTERM or closing stdin. +**Avoid `kill -9` on consume processes** for EventKeys whose PreConsume registers a server-side subscription **and** unsubscribes on exit (minutes, vc, board keys): `kill -9` skips the OAPI unsubscribe and leaks the server-side subscription (symptoms: "subscription already exists" on restart, duplicate event delivery). Keys whose subscription is a durable relation with no cleanup (task, approval keys) do not leak this way, but SIGTERM or closing stdin remains the right shutdown for every key. ### One consume, one EventKey (multi-key = multi-shell) @@ -151,6 +154,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val | Approval | [`references/lark-event-approval.md`](references/lark-event-approval.md) | Catalog of 2 Approval EventKeys (`approval.instance.status_changed_v4`, `approval.task.status_changed_v4`) + optional/multi `subscription_type` pre-registration + user-auth subscription lifecycle + flat output field reference | | IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) | | Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes | -| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) | +| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 7 VC EventKeys (meeting lifecycle `participant_meeting_started/joined/ended_v1`, `vc.note.generated_v1`, recording `recording_started/transcript_generated/ended_v1`) + field reference + source type semantics; the live list is always `lark-cli event list --domain vc --json` | | Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) | | Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=`) + payload field reference (whiteboard_id / operator_ids triple-id) | diff --git a/skills/lark-event/references/lark-event-vc.md b/skills/lark-event/references/lark-event-vc.md index 16679aaeb1..225f09eec9 100644 --- a/skills/lark-event/references/lark-event-vc.md +++ b/skills/lark-event/references/lark-event-vc.md @@ -2,7 +2,7 @@ > **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage). -## Key catalog (4) +## Key catalog (7) | EventKey | Purpose | |---|---| @@ -10,8 +10,11 @@ | `vc.meeting.participant_meeting_joined_v1` | The current user has joined a meeting | | `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended | | `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) | +| `vc.recording.recording_started_v1` | A recording_bean recording has started (Feishu software only) | +| `vc.recording.recording_transcript_generated_v1` | Recording_bean transcript items were generated (Feishu software only) | +| `vc.recording.recording_ended_v1` | A recording_bean recording ended and uploaded successfully (Feishu software only) | -All four keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. All require `--as user`. +All seven keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. All require `--as user`. ## Scopes & auth @@ -21,6 +24,9 @@ All four keys use a **Custom schema** (flat output) and carry a **PreConsume hoo | `vc.meeting.participant_meeting_joined_v1` | `vc:meeting.meetingevent:read` | user | | `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user | | `vc.note.generated_v1` | `vc:note:read` | user | +| `vc.recording.recording_started_v1` | `vc:recording:read` | user | +| `vc.recording.recording_transcript_generated_v1` | `vc:recording:read` | user | +| `vc.recording.recording_ended_v1` | `vc:recording:read` | user | ---