Files
dingtalk-workspace-cli/internal/app/event_command.go
T
aa6abc5ed6 feat(event): add personal event subscriptions (#589)
* dws event

* fix consume fail

* test: add stream ticket injection probe

* feat: add portal ticket stream mode

* user event

* fix: allow portal ticket normal without app secret

* event

* user event

* eventType filter

* refactor(event): 优化IPC端点路径处理和改进相关测试

- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖

* opt

* event skill

* default value

* install script event

* fix: remove subscribe id event fanout filter

* fix(personal): 修正指定发送人消息描述错误

* more im event

* filter subId

* fix: align personal event schema with stream payload

* fix: avoid duplicate app helper name

* feat: simplify personal event schemas

* feat: simplify event schema output

* docs: refine dingtalk event skill references

* feat: align personal event consume flags

* fix event stop and status visibility

* hide app event public entrypoints

* hide incomplete personal sender event

* remove external event reference comments

* chore(event): prepare official release

* fix(event): harden personal stream lifecycle

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
2026-07-10 17:54:43 +08:00

1108 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"text/tabwriter"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// newEventCommand returns the `event` parent command and all its subcommands.
// Wired into root.go's utilityCommands list.
func newEventCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "event",
Short: "事件订阅 (DingTalk Stream 长连接)",
Long: "通过 DingTalk Stream 长连接订阅事件并以 NDJSON 输出到 stdout。详见 `dws event consume --help`。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
newEventStatusCommand(),
newEventStopCommand(),
newEventBusCommand(),
)
return cmd
}
// ─────────────────────────────────────────────────────────────────────
// event consume
// ─────────────────────────────────────────────────────────────────────
func newEventConsumeCommand() *cobra.Command {
var (
eventTypes []string
filter string
compact bool
formatRaw string
outputDir string
routesRaw []string
maxEvents int
duration time.Duration
quiet bool
force bool
dryRun bool
foreground bool
asIdentity string
personalOpts personalConsumeOptions
streamOpts eventStreamTicketOptions
)
cmd := &cobra.Command{
Use: "consume [event_key]",
Short: "订阅事件流并输出到 stdout",
Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。
输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对
事件流无意义会 fallback 到 ndjson):
ndjson (默认) 一行一对象,适合 jq / 管道处理
json 每事件多行美化 JSON(必须配 --max-events 或 --duration)
pretty 同 json,未来加颜色
raw 仅 SDK 原始 payload,无外层封装
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接。
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
通常不需要设置。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as == "user" {
personalOpts.EventKey = firstArg(args)
personalOpts.Common = commonConsumeOptions{
EventTypes: eventTypes,
Filter: filter,
Compact: compact,
FormatRaw: formatRaw,
OutputDir: outputDir,
RoutesRaw: routesRaw,
MaxEvents: maxEvents,
Duration: duration,
Quiet: quiet,
Force: force,
DryRun: dryRun,
Foreground: foreground,
}
personalOpts.StreamTicketMode = streamOpts.Mode
personalOpts.StreamTicketURL = streamOpts.TicketURL
personalOpts.StreamSourceID = streamOpts.SourceID
return runPersonalEventConsume(c, personalOpts)
}
if personalOpts.DebugRawEvents {
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
}
if err := rejectChangedFlags(c, "user",
"subscribe-id",
"rule",
"name",
"filter-json",
"query",
"ttl",
"ephemeral",
"user",
"group",
"personal-event-base-url",
); err != nil {
return fmt.Errorf("event consume: %w", err)
}
if len(args) > 0 {
return fmt.Errorf("event consume: event_key is only supported with --as user")
}
ctx, cancel := signal.NotifyContext(c.Context(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Step 1: resolve credentials.
// Portal ticket normal mode uses portal-managed app credentials, so
// local ClientSecret is intentionally not required there.
configDir := defaultConfigDir()
clientID, clientSecret, err := resolveEventCredentials(configDir, streamOpts)
if err != nil {
return fmt.Errorf("event consume: %w", err)
}
if !streamOpts.usesPortalNormalMode() && authpkg.EnvHalfSet() {
fmt.Fprintln(c.ErrOrStderr(),
"WARN: only one of DWS_CLIENT_ID/DWS_CLIENT_SECRET is set; env fallback disabled, using keychain/app config")
}
// Step 2: derive bus working directory + IPC endpoint.
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
// Step 3: parse routes.
routes, err := consume.ParseRoutes(routesRaw)
if err != nil {
return fmt.Errorf("event consume: %w", err)
}
// Step 4: resolve format. Event command's default is ndjson;
// we check Changed on the inherited global -f/--format flag so
// the user can still override (-f json etc.). table/csv fall
// back to ndjson with a stderr WARN. See plan §3.1 输出约束.
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = formatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !quiet {
fmt.Fprintf(c.ErrOrStderr(),
"WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: clientID,
EventTypes: eventTypesWithDefault(eventTypes),
Filter: filter,
Compact: compact,
MaxEvents: maxEvents,
Duration: duration,
Format: normalised,
OutputDir: outputDir,
Routes: routes,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: quiet,
Foreground: foreground,
Force: force,
DryRun: dryRun,
SpawnExtraArgs: streamOpts.spawnArgs(),
}
// Step 5: validation (flag-only rules).
if err := consume.ValidateConfig(cfg); err != nil {
return err
}
// --output-dir/--route vs global hidden -o/--output.
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
// Step 6: foreground mode runs the bus in-process. Otherwise
// consume.Run discovers / forks the bus and dials it.
if foreground {
return runForegroundBus(ctx, cfg, configDir, clientSecret, streamOpts)
}
return consume.Run(ctx, cfg)
},
}
f := cmd.Flags()
f.StringVar(&asIdentity, "as", "user", "事件身份: user")
f.StringSliceVar(&eventTypes, "event-types", nil,
"逗号分隔事件类型;省略时按 event_key 过滤")
f.StringVar(&filter, "filter", "",
"客户端正则过滤事件类型 (下推到 bus 减少 IPC 投递量)")
f.BoolVar(&compact, "compact", false,
"提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)")
f.StringVarP(&formatRaw, "format", "f", "ndjson",
"输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson")
f.StringVar(&outputDir, "output-dir", "",
"每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥")
f.StringArrayVar(&routesRaw, "route", nil,
"按 regex 路由事件到目录:'<regex>=dir:<path>',可重复;未命中走 stdout/--output-dir")
f.IntVar(&maxEvents, "max-events", 0,
"收到 N 条后退出 (0 = 不限)")
f.DurationVar(&duration, "duration", 0,
"运行时长上限 (Go duration 如 30s/5m);事件流专用,不复用全局 --timeout")
f.BoolVar(&quiet, "quiet", false,
"抑制 stderr 状态信息")
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置,不连接 bus / 云端")
f.BoolVar(&foreground, "foreground", false,
"不 fork daemon,当前进程跑 bus (systemd/k8s/launchd 友好)")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
"个人事件订阅 ID;传入后复用已有订阅")
f.StringVar(&personalOpts.Rule, "rule", "",
"个人事件规则类型;默认根据 event_key 推断")
f.StringVar(&personalOpts.Name, "name", "",
"个人事件订阅名称")
f.StringVar(&personalOpts.FilterJSON, "filter-json", "",
"个人事件 Filter DSL JSON")
f.StringVar(&personalOpts.QueryCSV, "query", "",
"按消息文本关键词过滤,逗号分隔")
f.DurationVar(&personalOpts.TTL, "ttl", 0,
"个人订阅 TTL (Go duration,如 24h;0 表示不过期)")
f.BoolVar(&personalOpts.Ephemeral, "ephemeral", false,
"consume 退出时自动取消个人订阅")
f.StringVar(&personalOpts.UserID, "user", "",
"个人单聊对端 userId")
f.StringVar(&personalOpts.GroupID, "group", "",
"group 规则:openConversationId")
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
"个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false,
"个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件")
f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
"个人 Stream 建联模式;默认 normal")
f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人 Stream sourceId;开源版默认 open,可由 edition 覆盖")
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"个人 Stream 取票 URL;默认由 MCP base URL 派生")
hideEventInternalFlags(cmd, "as")
return cmd
}
// runForegroundBus implements --foreground: instead of dialing an existing
// bus or forking a new one, the current process IS the bus. We construct a
// source.DingtalkSource with the same credentials, then bus.Run in this
// goroutine with a ready pipe wired to stderr-only.
//
// Implementation note: a true --foreground run does NOT spawn a second
// process to consume — the consumer's NDJSON output would then have no
// stdout. For v1 we keep it simple: --foreground runs bus only; the user
// can run `dws event consume` from another shell to consume the events.
// v2 may add a "foreground + in-process consumer" combined mode.
func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error {
src, err := newEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts)
if err != nil {
return err
}
busCfg := bus.Config{
WorkDir: cfg.WorkDir,
IPCEndpoint: cfg.IPCEndpoint,
ClientID: cfg.ClientID,
Edition: editionNameOrDefault(),
SourceKind: dwsevent.SourceKindAppStream,
IdentityHash: dwsevent.ClientIDHash(cfg.ClientID),
Source: src,
Logger: slog.Default(),
}
bus.ApplyEnvTuning(&busCfg)
return bus.Run(ctx, busCfg)
}
type eventStreamTicketOptions struct {
Mode string
SourceID string
TicketURL string
}
func (o eventStreamTicketOptions) enabled() bool {
return strings.TrimSpace(o.Mode) != ""
}
func (o eventStreamTicketOptions) normalizedMode() string {
return strings.ToLower(strings.TrimSpace(o.Mode))
}
func (o eventStreamTicketOptions) usesPortalNormalMode() bool {
return o.enabled() && o.normalizedMode() == source.PortalTicketModeNormal
}
func (o eventStreamTicketOptions) spawnArgs() []string {
if !o.enabled() {
return nil
}
args := []string{"--stream-ticket-mode", strings.TrimSpace(o.Mode)}
if sourceID := strings.TrimSpace(o.SourceID); sourceID != "" {
args = append(args, "--stream-source-id", sourceID)
}
if ticketURL := strings.TrimSpace(o.TicketURL); ticketURL != "" {
args = append(args, "--stream-ticket-url", ticketURL)
}
return args
}
func resolveEventCredentials(configDir string, streamOpts eventStreamTicketOptions) (clientID, clientSecret string, err error) {
if !streamOpts.usesPortalNormalMode() {
clientID, clientSecret, _, _, err = authpkg.ResolveAppCredentialsStrict(configDir)
return clientID, clientSecret, err
}
return eventStreamBusID(streamOpts), "", nil
}
func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
sourceID := eventStreamSourceID(streamOpts.SourceID)
return "portal-ticket-normal:" + sourceID
}
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
if !streamOpts.enabled() {
return source.New(source.Config{
ClientID: clientID,
ClientSecret: clientSecret,
})
}
token, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("event stream ticket: empty user token")
}
portalClientID := clientID
portalClientSecret := clientSecret
if streamOpts.usesPortalNormalMode() {
portalClientID = ""
portalClientSecret = ""
}
return source.New(source.Config{
ClientID: portalClientID,
ClientSecret: portalClientSecret,
PortalTicket: &source.PortalTicketConfig{
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
AccessToken: token,
SourceID: eventStreamSourceID(streamOpts.SourceID),
Mode: streamOpts.Mode,
ClientID: portalClientID,
ClientSecret: portalClientSecret,
UserAgent: "dws-event-consume",
},
})
}
func eventStreamTicketURL(raw string) string {
if v := strings.TrimSpace(raw); v != "" {
return v
}
return strings.TrimRight(config.GetMCPBaseURL(), "/") + "/stream/connections/ticket"
}
func defaultEventStreamSourceID() string {
if v := strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")); v != "" {
return v
}
base := strings.ToLower(config.GetMCPBaseURL())
if strings.Contains(base, "pre-mcp") {
return "pre_open_source"
}
return "open"
}
func eventStreamSourceID(raw string) string {
if v := strings.TrimSpace(raw); v != "" {
return v
}
return defaultEventStreamSourceID()
}
// ─────────────────────────────────────────────────────────────────────
// event _bus (hidden — auto-forked by consume)
// ─────────────────────────────────────────────────────────────────────
func newEventBusCommand() *cobra.Command {
var (
clientIDOverride string
idleTimeout time.Duration
sourceKindRaw string
streamOpts eventStreamTicketOptions
)
cmd := &cobra.Command{
Use: "_bus",
Short: "Internal event bus daemon (do not call directly)",
Long: "Hidden subcommand auto-spawned by `dws event consume`. Do not invoke directly.",
Hidden: true,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
ctx, cancel := signal.NotifyContext(c.Context(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Acquire ReadyPipe early so pre-bus.Run failures can signal
// 'E' to the parent process instead of silently dying.
readyPipe := busctl.ReadyFDFromEnv()
failEarly := func(err error) error {
if readyPipe != nil {
_, _ = readyPipe.Write([]byte{'E'})
_ = readyPipe.Close()
}
return err
}
configDir := defaultConfigDir()
sourceKind := dwsevent.SourceKind(strings.TrimSpace(sourceKindRaw))
if sourceKind == "" {
sourceKind = dwsevent.SourceKindAppStream
}
if sourceKind == dwsevent.SourceKindPersonalStream {
identity, err := resolvePersonalEventIdentity(ctx, configDir, streamOpts.SourceID)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
if clientIDOverride != "" {
identity.ClientID = clientIDOverride
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: streamOpts.Mode,
TicketURL: streamOpts.TicketURL,
ClientIDOverride: clientIDOverride,
})
if err != nil {
return failEarly(err)
}
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
defer lf.Close()
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
}
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
}
bus.ApplyEnvTuning(&busCfg)
return bus.Run(ctx, busCfg)
}
resolvedID, secret, err := resolveEventCredentials(configDir, streamOpts)
if err != nil {
return failEarly(fmt.Errorf("event _bus: %w", err))
}
clientID := resolvedID
if clientIDOverride != "" {
clientID = clientIDOverride
}
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
src, err := newEventSource(ctx, configDir, clientID, secret, streamOpts)
if err != nil {
return failEarly(err)
}
// busLogger writes to bus.log inside WorkDir so the daemon's
// own log lines never pollute stdout/stderr (which busctl/Spawn
// detached). Best-effort: if mkdir / open fails we fall back
// to slog.Default (stderr) so we at least see startup errors.
if err := os.MkdirAll(workDir, config.DirPerm); err == nil {
if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil {
defer lf.Close()
slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo})))
}
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: clientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindAppStream,
IdentityHash: clientIDHash,
Source: src,
IdleTimeout: idleTimeout,
ReadyPipe: readyPipe,
Logger: slog.Default(),
}
// env-var tuning (only fills in fields left at zero; explicit
// flags above keep precedence).
bus.ApplyEnvTuning(&busCfg)
return bus.Run(ctx, busCfg)
},
}
cmd.Flags().StringVar(&clientIDOverride, "client-id", "",
"override clientID resolved from app config / env (used by busctl/Spawn)")
cmd.Flags().DurationVar(&idleTimeout, "idle-timeout", 5*time.Minute,
"exit after this long with zero consumers (0 = disabled)")
cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream),
"event source kind: app_stream|personal_stream")
cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
"用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票")
cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"用户 Stream sourceId;personal_stream 开源版默认 open")
cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生")
return cmd
}
// ─────────────────────────────────────────────────────────────────────
// event list
// ─────────────────────────────────────────────────────────────────────
// listEntry is one row in `dws event list` output. Combines on-disk bus
// metadata with the live status RPC results so consumers from multiple
// buses can be rendered as a single flat table.
type listEntry struct {
ClientID string `json:"client_id"`
ClientIDHash string `json:"client_id_hash"`
SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"`
SourceID string `json:"source_id,omitempty"`
Edition string `json:"edition"`
BusPID int `json:"bus_pid"`
BusState busctl.BusEntryState `json:"bus_state"`
WorkDir string `json:"workdir"`
Consumers []transport.StatusConsumer `json:"consumers,omitempty"`
// PerType is included in JSON only — text mode renders it in `status`.
PerType map[string]transport.Counters `json:"per_event_type,omitempty"`
}
func newEventListCommand() *cobra.Command {
var (
all bool
allEditions bool
formatRaw string
clientIDOver string
asIdentity string
category string
enabledOnly bool
includePending bool
)
cmd := &cobra.Command{
Use: "list",
Short: "列出个人事件目录",
Long: "列出当前支持的个人事件目录。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as == "user" {
if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil {
return fmt.Errorf("event list: %w", err)
}
return runPersonalEventList(c, personalListOptions{
Category: category,
EnabledOnly: enabledOnly,
IncludePending: includePending,
Format: formatRaw,
})
}
if err := rejectChangedFlags(c, "user", "category", "enabled-only", "include-pending"); err != nil {
return fmt.Errorf("event list: %w", err)
}
entries, err := collectEntries(c, clientIDOver, all, allEditions)
if err != nil {
return err
}
rendered := make([]listEntry, 0, len(entries))
for _, qs := range entries {
rendered = append(rendered, buildListEntry(qs))
}
return renderList(c.OutOrStdout(), rendered, formatRaw)
},
}
cmd.Flags().BoolVar(&all, "all", false, "列出当前 edition 下所有 ClientID 的消费者")
cmd.Flags().BoolVar(&allEditions, "all-editions", false, "跨 edition 列出(罕用,调试用)")
cmd.Flags().StringVar(&clientIDOver, "client-id", "", "指定具体 ClientID(覆盖凭证解析)")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "table", "输出格式: table|json")
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVar(&category, "category", "", "个人事件目录分类")
cmd.Flags().BoolVar(&enabledOnly, "enabled-only", false, "个人事件目录只显示 enabled")
cmd.Flags().BoolVar(&includePending, "include-pending", false, "个人事件目录包含 pending 项")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id")
return cmd
}
// ─────────────────────────────────────────────────────────────────────
// event status
// ─────────────────────────────────────────────────────────────────────
func newEventStatusCommand() *cobra.Command {
var (
all bool
allEditions bool
formatRaw string
clientIDOver string
failOnOrphan bool
asIdentity string
personalOpts personalStatusOptions
)
cmd := &cobra.Command{
Use: "status",
Short: "显示个人事件订阅和本地消费状态",
Long: "显示当前用户个人事件订阅、personal bus 状态和本地 consume 进程。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as == "user" {
if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id", "fail-on-orphan"); err != nil {
return fmt.Errorf("event status: %w", err)
}
personalOpts.Format = formatRaw
return runPersonalEventStatus(c, personalOpts)
}
if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil {
return fmt.Errorf("event status: %w", err)
}
entries, err := collectEntries(c, clientIDOver, all, allEditions)
if err != nil {
return err
}
if err := renderStatus(c.OutOrStdout(), entries, formatRaw); err != nil {
return err
}
if failOnOrphan {
for _, qs := range entries {
if qs.Entry.State == busctl.BusStateOrphan {
return &consume.ValidationError{Msg: "orphan bus detected (--fail-on-orphan set)"}
}
}
}
return nil
},
}
cmd.Flags().BoolVar(&all, "all", false, "当前 edition 下所有 ClientID")
cmd.Flags().BoolVar(&allEditions, "all-editions", false, "跨 edition")
cmd.Flags().StringVar(&clientIDOver, "client-id", "", "指定具体 ClientID")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "text", "输出格式: text|json")
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "检测到 orphan 时退出码 2")
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤")
cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all")
cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
return cmd
}
// ─────────────────────────────────────────────────────────────────────
// list/status 共享 helpers
// ─────────────────────────────────────────────────────────────────────
// collectEntries resolves which BusEntries the command should operate on
// and queries each one's live status. Returns at most one entry when
// neither --all nor --all-editions is set.
func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool) ([]busctl.EntryStatus, error) {
configDir := defaultConfigDir()
editionName := editionNameOrDefault()
// --all-editions trumps --all (scan whole tree)
if allEditions {
entries, err := busctl.EnumerateBuses(configDir, "")
if err != nil {
return nil, err
}
return queryAll(entries), nil
}
if all {
entries, err := busctl.EnumerateBuses(configDir, editionName)
if err != nil {
return nil, err
}
return queryAll(entries), nil
}
// Single ClientID path. If --client-id passed use it directly,
// otherwise resolve via strict resolver.
clientID := clientIDOver
if clientID == "" {
resolved, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
if err != nil {
return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err)
}
clientID = resolved
}
hash := dwsevent.ClientIDHash(clientID)
entry := busctl.FindBusByClientID(configDir, editionName, hash)
if entry == nil {
// No directory at all — render an empty "not running" so the user
// sees a useful answer instead of an error.
return []busctl.EntryStatus{
{Entry: busctl.BusEntry{
WorkDir: eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, hash),
Edition: editionName,
SourceKind: dwsevent.SourceKindAppStream,
ClientIDHash: hash,
IdentityHash: hash,
State: busctl.BusStateNotRunning,
Meta: &bus.Meta{
ClientID: clientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindAppStream,
IdentityHash: hash,
},
}},
}, nil
}
if entry.Meta == nil {
entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName}
}
return []busctl.EntryStatus{busctl.QueryEntry(*entry)}, nil
}
func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus {
out := make([]busctl.EntryStatus, 0, len(entries))
for _, e := range entries {
out = append(out, busctl.QueryEntry(e))
}
return out
}
func buildListEntry(qs busctl.EntryStatus) listEntry {
le := listEntry{
ClientIDHash: qs.Entry.ClientIDHash,
SourceKind: qs.Entry.SourceKind,
Edition: qs.Entry.Edition,
BusPID: qs.Entry.HolderPID,
BusState: qs.Entry.State,
WorkDir: qs.Entry.WorkDir,
}
if qs.Entry.Meta != nil {
le.ClientID = qs.Entry.Meta.ClientID
le.SourceID = qs.Entry.Meta.SourceID
}
if qs.Live != nil {
le.Consumers = qs.Live.Consumers
le.PerType = qs.Live.PerEventTypeCounters
}
return le
}
// renderList prints either a table or a JSON array.
func renderList(w io.Writer, entries []listEntry, format string) error {
if format == "json" {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
// Table: one line per consumer, prefixed by client_id. Buses with no
// consumers still get one row (so users see they exist).
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SOURCE\tCLIENT_ID\tBUS\tCONSUMER PID\tEVENT KEYS\tSUBSCRIBE_ID\tRECEIVED\tDROPPED")
for _, le := range entries {
busDisplay := fmt.Sprintf("%s pid=%d", le.BusState, le.BusPID)
if le.BusState == busctl.BusStateNotRunning {
busDisplay = string(le.BusState)
}
clientLabel := le.ClientID
if clientLabel == "" {
clientLabel = "(unknown — hash=" + le.ClientIDHash + ")"
}
if len(le.Consumers) == 0 {
fmt.Fprintf(tw, "%s\t%s\t%s\t-\t-\t-\t-\t-\n", sourceKindLabel(le.SourceKind), clientLabel, busDisplay)
continue
}
for _, cs := range le.Consumers {
keys := strings.Join(cs.EventTypes, ",")
if keys == "" {
keys = "(catch-all)"
}
subID := cs.SubscribeID
if subID == "" {
subID = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\t%d\t%d\n",
sourceKindLabel(le.SourceKind), clientLabel, busDisplay, cs.PID, keys, subID, cs.Received, cs.Dropped)
}
}
return tw.Flush()
}
// renderStatus prints a multi-line block per bus. JSON mode dumps the raw
// EntryStatus slice.
func renderStatus(w io.Writer, entries []busctl.EntryStatus, format string) error {
if format == "json" {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
for i, qs := range entries {
if i > 0 {
fmt.Fprintln(w)
}
renderStatusBlock(w, qs)
}
return nil
}
func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) {
clientLabel := qs.Entry.ClientIDHash
if qs.Entry.Meta != nil && qs.Entry.Meta.ClientID != "" {
clientLabel = qs.Entry.Meta.ClientID
}
fmt.Fprintf(w, "ClientID: %s\n", clientLabel)
fmt.Fprintf(w, " Edition : %s\n", qs.Entry.Edition)
fmt.Fprintf(w, " Source : %s\n", sourceKindLabel(qs.Entry.SourceKind))
if qs.Entry.Meta != nil && qs.Entry.Meta.SourceID != "" {
fmt.Fprintf(w, " SourceID : %s\n", qs.Entry.Meta.SourceID)
}
fmt.Fprintf(w, " Workdir : %s\n", qs.Entry.WorkDir)
switch qs.Entry.State {
case busctl.BusStateNotRunning:
fmt.Fprintln(w, " Bus : not_running")
return
case busctl.BusStateOrphan:
fmt.Fprintf(w, " Bus : orphan (last_pid=%d not alive)\n", qs.Entry.HolderPID)
if qs.Entry.Meta != nil && !qs.Entry.Meta.StartedAt.IsZero() {
fmt.Fprintf(w, " Started : %s (from bus.meta)\n", qs.Entry.Meta.StartedAt.Format(time.RFC3339))
}
fmt.Fprintln(w, " Action : run `dws event consume` to force-restart, or rm -rf the workdir")
return
}
// Running: include live RPC results when present.
uptime := ""
if qs.Live != nil {
uptime = fmt.Sprintf("uptime=%ds", qs.Live.Bus.UptimeSecs)
} else if qs.Entry.Meta != nil && !qs.Entry.Meta.StartedAt.IsZero() {
uptime = fmt.Sprintf("uptime=%s (from bus.meta)", time.Since(qs.Entry.Meta.StartedAt).Round(time.Second))
}
fmt.Fprintf(w, " Bus : running pid=%d %s\n", qs.Entry.HolderPID, uptime)
if qs.Live == nil {
fmt.Fprintln(w, " (status RPC failed — bus may be shutting down)")
return
}
live := qs.Live
fmt.Fprintf(w, " Source : state=%s state_source=%s reconnects=%d\n",
live.SourceState.State, live.SourceState.Source, live.SourceState.ReconnectCount)
fmt.Fprintf(w, " Consumers: %d active\n", len(live.Consumers))
if len(live.PerEventTypeCounters) > 0 {
fmt.Fprintln(w, " Per-event-type counters (since bus start):")
keys := make([]string, 0, len(live.PerEventTypeCounters))
for k := range live.PerEventTypeCounters {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
c := live.PerEventTypeCounters[k]
fmt.Fprintf(w, " %s received=%d dropped=%d\n", k, c.Received, c.Dropped)
}
}
if len(live.Consumers) > 0 {
fmt.Fprintln(w, " Consumers:")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, " PID\tEVENT KEYS\tSUBSCRIBE_ID\tRECEIVED\tDROPPED")
for _, cs := range live.Consumers {
keys := strings.Join(cs.EventTypes, ",")
if keys == "" {
keys = "(catch-all)"
}
subID := cs.SubscribeID
if subID == "" {
subID = "-"
}
fmt.Fprintf(tw, " %d\t%s\t%s\t%d\t%d\n", cs.PID, keys, subID, cs.Received, cs.Dropped)
}
_ = tw.Flush()
}
}
func newEventStopCommand() *cobra.Command {
var asIdentity string
var opts personalStopOptions
cmd := &cobra.Command{
Use: "stop [subscribe_id]",
Short: "取消个人事件订阅并停止本地消费",
Long: `取消个人事件订阅并停止本地消费,清理对应本地消费状态,并尝试停止对应前台 consume。
传 subscribe_id 时取消该个人订阅;传 --all 时取消当前身份下本地记录的全部个人订阅。
取消后如果没有剩余本地个人订阅,会停止 personal bus。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as == "user" {
opts.SubscribeID = firstArg(args)
return runPersonalEventStop(c, opts)
}
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
return fmt.Errorf("event stop: %w", err)
}
if len(args) > 0 {
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
}
configDir := defaultConfigDir()
clientID, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir)
if err != nil {
return fmt.Errorf("event stop: %w", err)
}
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
return nil
}
return err
}
fmt.Fprintln(c.OutOrStdout(), "bus stopped")
return nil
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
hideEventInternalFlags(cmd, "as")
return cmd
}
// ─────────────────────────────────────────────────────────────────────
// helpers
// ─────────────────────────────────────────────────────────────────────
// editionNameOrDefault returns edition.Get().Name with "open" fallback.
// Centralised so every event subcommand agrees on the path prefix.
func editionNameOrDefault() string {
name := edition.Get().Name
if name == "" {
return "open"
}
return name
}
// defaultIPCEndpoint returns the canonical Unix socket path / Windows pipe
// name for the bus. Thin wrapper over dwsevent.IPCEndpoint, which owns the
// platform shape and the too-long-socket-path fallback.
func defaultIPCEndpoint(workDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) string {
return dwsevent.IPCEndpoint(workDir, editionName, sourceKind, identityHash)
}
func eventWorkDir(configDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) string {
if sourceKind == "" {
sourceKind = dwsevent.SourceKindAppStream
}
return filepath.Join(configDir, "events", editionName, string(sourceKind), identityHash)
}
func sourceKindLabel(kind dwsevent.SourceKind) string {
if kind == "" {
return string(dwsevent.SourceKindAppStream)
}
return string(kind)
}
func normalizeEventAs(v string) (string, error) {
v = strings.ToLower(strings.TrimSpace(v))
switch v {
case "", "user":
return "user", nil
case "app", "bot":
// App stream implementation is intentionally retained for future use,
// while the public event command currently exposes only user events.
return "", fmt.Errorf("app event is not publicly available yet")
default:
return "", fmt.Errorf("--as only supports user")
}
}
func hideEventInternalFlags(cmd *cobra.Command, names ...string) {
for _, name := range names {
_ = cmd.Flags().MarkHidden(name)
}
}
func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
if f := c.Flags().Lookup(name); f != nil && f.Changed {
changed = append(changed, "--"+name)
}
}
if len(changed) == 0 {
return nil
}
return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", "))
}
func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
if f := c.Flags().Lookup(name); f != nil && f.Changed {
changed = append(changed, "--"+name)
}
}
if len(changed) == 0 {
return nil
}
return fmt.Errorf("%s are only supported with --as %s", strings.Join(changed, ", "), supportedAs)
}
func firstArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}
// eventTypesWithDefault picks the catch-all list from registry when the
// user did not pass --event-types.
func eventTypesWithDefault(types []string) []string {
if len(types) > 0 {
return types
}
return registry.CatchAllEventTypes()
}
// compile-time guard: avoid "imported and not used" if any of these
// indirect imports become unused after future refactors.
var _ = io.Discard