Files
larksuite-cli/shortcuts/event/subscribe.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

306 lines
10 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
// stderrLogger redirects SDK log output to an io.Writer (stderr),
// preventing SDK logs from polluting the stdout data stream.
// Debug logs are always suppressed to avoid noisy event-loop output.
// When quiet is true, Info logs are also suppressed; Warn and Error always print.
type stderrLogger struct {
w io.Writer
quiet bool
}
func (l *stderrLogger) Debug(_ context.Context, _ ...interface{}) {}
func (l *stderrLogger) Info(_ context.Context, args ...interface{}) {
if !l.quiet {
fmt.Fprintln(l.w, append([]interface{}{"[SDK Info]"}, args...)...)
}
}
func (l *stderrLogger) Warn(_ context.Context, args ...interface{}) {
fmt.Fprintln(l.w, append([]interface{}{"[SDK Warn]"}, args...)...)
}
func (l *stderrLogger) Error(_ context.Context, args ...interface{}) {
fmt.Fprintln(l.w, append([]interface{}{"[SDK Error]"}, args...)...)
}
var _ larkcore.Logger = (*stderrLogger)(nil)
// commonEventTypes are well-known event types registered in catch-all mode.
var commonEventTypes = []string{
"im.message.receive_v1",
"im.message.message_read_v1",
"im.message.reaction.created_v1",
"im.message.reaction.deleted_v1",
"im.chat.member.bot.added_v1",
"im.chat.member.bot.deleted_v1",
"im.chat.member.user.added_v1",
"im.chat.member.user.withdrawn_v1",
"im.chat.member.user.deleted_v1",
"im.chat.updated_v1",
"im.chat.disbanded_v1",
"contact.user.created_v3",
"contact.user.updated_v3",
"contact.user.deleted_v3",
"contact.department.created_v3",
"contact.department.updated_v3",
"contact.department.deleted_v3",
"calendar.calendar.acl.created_v4",
"calendar.calendar.event.changed_v4",
"approval.approval.updated",
"application.application.visibility.added_v6",
"task.task.update_tenant_v1",
"task.task.update_user_access_v2",
"task.task.comment_updated_v1",
"drive.notice.comment_add_v1",
}
var EventSubscribe = common.Shortcut{
Service: "event",
Command: "+subscribe",
Description: "Subscribe to Lark events via WebSocket (NDJSON output)",
Risk: "read",
Scopes: []string{}, // no direct OAPI; scopes depend on subscribed event types
AuthTypes: []string{"bot"},
// Hidden: superseded by `event consume`. Kept executable so existing
// scripts keep working, but removed from --help/tab-completion so new
// users land on the replacement. Delete once downstream callers have
// migrated.
Hidden: true,
Flags: []common.Flag{
// Output destination — where events go
{Name: "output-dir", Desc: "write each event as a JSON file in this directory (default: stdout)"},
{Name: "route", Type: "string_array", Desc: "regex-based event routing (e.g. --route '^im\\.message=dir:./im/' --route '^contact\\.=dir:./contacts/'); unmatched events fall through to --output-dir or stdout"},
// Output format — how events are serialized
{Name: "compact", Type: "bool", Desc: "flat key-value output: extract text, strip noise fields"},
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
// Filtering — which events reach the pipeline
{Name: "event-types", Desc: "comma-separated event types to subscribe; only use when you do not need other events (omit for catch-all)"},
{Name: "filter", Desc: "regex to further filter events by event_type"},
// Behavior
{Name: "quiet", Type: "bool", Desc: "suppress stderr status messages"},
{Name: "force", Type: "bool", Desc: "bypass single-instance lock (UNSAFE: server randomly splits events across connections, each instance only receives a subset)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
eventTypesDisplay := "(catch-all)"
if s := runtime.Str("event-types"); s != "" {
eventTypesDisplay = s
}
filterDisplay := "(none)"
if s := runtime.Str("filter"); s != "" {
filterDisplay = s
}
outputDirDisplay := "(stdout)"
if s := runtime.Str("output-dir"); s != "" {
outputDirDisplay = s
}
routeDisplay := "(none)"
if routes := runtime.StrArray("route"); len(routes) > 0 {
routeDisplay = strings.Join(routes, "; ")
}
return common.NewDryRunAPI().
Desc("Subscribe to Lark events via WebSocket (long-running)").
Set("command", "event +subscribe").
Set("app_id", runtime.Config.AppID).
Set("event_types", eventTypesDisplay).
Set("filter", filterDisplay).Set("output_dir", outputDirDisplay).
Set("route", routeDisplay)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
eventTypesStr := runtime.Str("event-types")
filterStr := runtime.Str("filter")
jsonFlag := runtime.Bool("json")
compactFlag := runtime.Bool("compact")
outputDir := runtime.Str("output-dir")
quietFlag := runtime.Bool("quiet")
routeSpecs := runtime.StrArray("route")
forceFlag := runtime.Bool("force")
// Validate output directory path before any work
if outputDir != "" {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return eventValidationParamErrorWithCause(err, "--output-dir", "unsafe --output-dir")
}
outputDir = safePath
}
errOut := runtime.IO().ErrOut
out := runtime.IO().Out
info := func(msg string) {
if !quietFlag {
fmt.Fprintln(errOut, msg)
}
}
// --- Single-instance lock ---
if !forceFlag {
lock, err := lockfile.ForSubscribe(runtime.Config.AppID)
if err != nil {
return eventFileIOError(err, "failed to create event subscriber lock")
}
if err := lock.TryLock(); err != nil {
if errors.Is(err, lockfile.ErrHeld) {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event +subscribe instance is already running for app %s\n"+
" Only one subscriber per app is allowed to prevent competing consumers.\n"+
" Use --force to bypass this check.",
runtime.Config.AppID,
).WithHint("stop the existing subscriber for this app, or rerun with --force if you accept split event delivery").WithCause(err)
}
return eventFileIOError(err, "failed to acquire event subscriber lock")
}
defer lock.Unlock()
}
// --- Build filter chain ---
eventTypeFilter := NewEventTypeFilter(eventTypesStr)
regexFilter, err := NewRegexFilter(filterStr)
if err != nil {
return eventValidationParamErrorWithCause(err, "--filter", "invalid --filter regex %q", filterStr)
}
var filterList []EventFilter
if eventTypeFilter != nil {
filterList = append(filterList, eventTypeFilter)
}
if regexFilter != nil {
filterList = append(filterList, regexFilter)
}
filters := NewFilterChain(filterList...)
// --- Parse route ---
router, err := ParseRoutes(routeSpecs)
if err != nil {
return err
}
// --- Build pipeline ---
mode := TransformRaw
if compactFlag {
mode = TransformCompact
}
pipeline := NewEventPipeline(DefaultRegistry(), filters, PipelineConfig{
Mode: mode,
JsonFlag: jsonFlag,
OutputDir: outputDir,
Quiet: quietFlag,
Router: router,
}, out, errOut)
if err := pipeline.EnsureDirs(); err != nil {
return err
}
// --- Build SDK event dispatcher ---
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
if event.Body == nil {
return nil
}
var raw RawEvent
if err := json.Unmarshal(event.Body, &raw); err != nil {
output.PrintError(errOut, fmt.Sprintf("failed to parse event: %v", err))
return nil
}
pipeline.Process(ctx, &raw)
return nil
}
sdkLogger := &stderrLogger{w: errOut, quiet: quietFlag}
eventDispatcher := dispatcher.NewEventDispatcher("", "")
eventDispatcher.InitConfig(larkevent.WithLogger(sdkLogger))
if eventTypeFilter != nil {
for _, et := range eventTypeFilter.Types() {
eventDispatcher.OnCustomizedEvent(et, rawHandler)
}
} else {
for _, et := range commonEventTypes {
eventDispatcher.OnCustomizedEvent(et, rawHandler)
}
}
// --- WebSocket ---
domain := lark.FeishuBaseUrl
if runtime.Config.Brand == core.BrandLark {
domain = lark.LarkBaseUrl
}
info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset))
if eventTypeFilter != nil {
info(fmt.Sprintf("Listening for: %s%s%s", output.Green, strings.Join(eventTypeFilter.Types(), ", "), output.Reset))
} else {
info(fmt.Sprintf("Listening for %s%d common event types%s (catch-all mode)", output.Green, len(commonEventTypes), output.Reset))
info(fmt.Sprintf("%sTip:%s use --event-types to listen for specific event types", output.Dim, output.Reset))
}
if regexFilter != nil {
info(fmt.Sprintf("Filter: %s%s%s", output.Yellow, regexFilter.String(), output.Reset))
}
if router != nil {
for _, spec := range routeSpecs {
info(fmt.Sprintf(" Route: %s%s%s", output.Green, spec, output.Reset))
}
}
cli := larkws.NewClient(runtime.Config.AppID, runtime.Config.AppSecret,
larkws.WithEventHandler(eventDispatcher),
larkws.WithDomain(domain),
larkws.WithLogger(sdkLogger),
)
// --- Graceful shutdown ---
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigCh)
startErrCh := make(chan error, 1)
go func() {
startErrCh <- cli.Start(ctx)
}()
info(fmt.Sprintf("%s%sConnected.%s Waiting for events... (Ctrl+C to stop)", output.Bold, output.Green, output.Reset))
select {
case sig, ok := <-sigCh:
if ok && sig != nil {
info(fmt.Sprintf("\n%sReceived %s, shutting down...%s (received %s%d%s events)", output.Yellow, sig, output.Reset, output.Bold, pipeline.EventCount(), output.Reset))
}
return nil
case err, ok := <-startErrCh:
if !ok {
return nil
}
if err != nil {
return eventNetworkError(err, "WebSocket connection failed")
}
return nil
}
},
}