mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
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.
306 lines
10 KiB
Go
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
|
|
}
|
|
},
|
|
}
|