Files
larksuite__cli/shortcuts/event/pipeline.go
evandance 2b4c6349a1 feat(event): emit typed error envelopes across the event domain (#1289)
Replace every command-facing error path in the event domain — the
consume/schema command layer, the +subscribe shortcut, EventKey
definitions, and the consume orchestration — with typed errs.*
envelopes, so consumers get stable type, subtype, param, hint, and
missing_scopes metadata for classification and recovery instead of
free-form message text.

- Input validation (--jq, --param, --output-dir, --filter, --route,
  unknown EventKey, EventKey params) reports validation /
  invalid_argument with the offending flag in param and an actionable
  hint.
- Scope preflight reports authorization / missing_scope with the
  machine-readable missing_scopes list; console-subscription and
  single-bus preconditions report failed_precondition with recovery
  hints.
- The consume API boundary passes already-typed errors through and
  classifies transport, non-JSON HTTP, and unparsable responses; the
  vc note-detail retry now matches the not-found code on typed errors
  (it silently never fired against the legacy envelope shape).
- Previously-bare failures exited 1 with a plain-text "Error:" line
  and now exit with their category code (validation 2, auth 3,
  network 4, internal 5) alongside the typed stderr envelope.
- forbidigo and errscontract guards now cover the event paths so
  regressions fail lint; AGENTS.md and the lark-event skill document
  the typed contract for agent consumers.

Validation: make unit-test (race) green; event unit and e2e suites
assert category/subtype/param/hint and cause preservation against the
real binary; errscontract and golangci lint clean.
2026-06-09 17:12:55 +08:00

200 lines
5.1 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sync"
"sync/atomic"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
)
const dedupTTL = 5 * time.Minute
// PipelineConfig configures the event processing pipeline.
type PipelineConfig struct {
Mode TransformMode // determined by --compact flag
JsonFlag bool // --json: pretty JSON instead of NDJSON
OutputDir string // --output-dir: write events to files
Quiet bool // --quiet: suppress stderr status messages
Router *EventRouter // --route: regex-based output routing
}
// EventPipeline chains filter → dedup → transform → emit.
type EventPipeline struct {
registry *ProcessorRegistry
filters *FilterChain
config PipelineConfig
eventCount atomic.Int64
seen sync.Map // key → time.Time (first-seen timestamp)
out io.Writer
errOut io.Writer
}
// NewEventPipeline builds an event processing pipeline.
func NewEventPipeline(
registry *ProcessorRegistry,
filters *FilterChain,
config PipelineConfig,
out, errOut io.Writer,
) *EventPipeline {
return &EventPipeline{
registry: registry,
filters: filters,
config: config,
out: out,
errOut: errOut,
}
}
// EnsureDirs creates all configured output directories once at startup.
func (p *EventPipeline) EnsureDirs() error {
if p.config.OutputDir != "" {
if err := vfs.MkdirAll(p.config.OutputDir, 0700); err != nil {
return eventFileIOError(err, "create output dir")
}
}
if p.config.Router != nil {
for _, route := range p.config.Router.routes {
if err := vfs.MkdirAll(route.dir, 0700); err != nil {
return eventFileIOError(err, "create route dir %s", route.dir)
}
}
}
return nil
}
// EventCount returns the number of processed events.
func (p *EventPipeline) EventCount() int64 {
return p.eventCount.Load()
}
func (p *EventPipeline) infof(format string, args ...interface{}) {
if !p.config.Quiet {
fmt.Fprintf(p.errOut, format+"\n", args...)
}
}
// isDuplicate returns true if key was seen within dedupTTL.
func (p *EventPipeline) isDuplicate(key string) bool {
now := time.Now()
if v, loaded := p.seen.LoadOrStore(key, now); loaded {
if ts, ok := v.(time.Time); ok && now.Sub(ts) < dedupTTL {
return true
}
p.seen.Store(key, now)
}
return false
}
func (p *EventPipeline) cleanupSeen(now time.Time) {
p.seen.Range(func(k, v any) bool {
if ts, ok := v.(time.Time); ok && now.Sub(ts) >= dedupTTL {
p.seen.Delete(k)
}
return true
})
}
// Process is the pipeline entry point, called by the WebSocket callback.
func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) {
eventType := raw.Header.EventType
// 1. Filter
if !p.filters.Allow(eventType) {
return
}
// 2. Lookup processor
processor := p.registry.Lookup(eventType)
// 3. Dedup
if key := processor.DeduplicateKey(raw); key != "" && p.isDuplicate(key) {
p.infof("%s[dedup]%s %s (key=%s)", output.Dim, output.Reset, eventType, key)
return
}
n := p.eventCount.Add(1)
if n%100 == 0 {
p.cleanupSeen(time.Now())
}
// 4. Transform — processor returns the final serializable value
data := processor.Transform(ctx, raw, p.config.Mode)
// 5. Output routing (framework-controlled)
// 5a. Route-based output — matched events go to route dirs
if p.config.Router != nil {
if dirs := p.config.Router.Match(eventType); len(dirs) > 0 {
for _, dir := range dirs {
p.writeAndLog(dir, n, eventType, data, raw.Header)
}
return
}
}
// 5b. --output-dir
if p.config.OutputDir != "" {
p.writeAndLog(p.config.OutputDir, n, eventType, data, raw.Header)
return
}
// 5c. Stdout
if p.config.JsonFlag {
output.PrintJson(p.out, data)
} else {
output.PrintNdjson(p.out, data)
}
p.infof("%s[%d]%s %s", output.Dim, n, output.Reset, eventType)
}
// writeAndLog writes an event to a directory and logs the result.
func (p *EventPipeline) writeAndLog(dir string, n int64, eventType string, data interface{}, header larkevent.EventHeader) {
fp, err := writeEventFile(dir, data, header)
if err != nil {
output.PrintError(p.errOut, fmt.Sprintf("write failed (%s): %v", dir, err))
} else {
p.infof("%s[%d]%s %s → %s", output.Dim, n, output.Reset, eventType, fp)
}
}
var filenameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func writeEventFile(dir string, data interface{}, header larkevent.EventHeader) (string, error) {
eventID := header.EventID
if eventID == "" {
eventID = "unknown"
}
ts := header.CreateTime
if ts == "" {
ts = fmt.Sprintf("%d", os.Getpid())
}
safeName := filenameSanitizer.ReplaceAllString(header.EventType, "_")
filename := fmt.Sprintf("%s_%s_%s.json", safeName, eventID, ts)
outPath := filepath.Join(dir, filename)
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", err
}
if err := validate.AtomicWrite(outPath, append(jsonData, '\n'), 0600); err != nil {
return "", err
}
return outPath, nil
}