Files
dingtalk-workspace-cli/internal/app/event_personal_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

846 lines
26 KiB
Go

// 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"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/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type commonConsumeOptions struct {
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
}
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
DebugRawEvents bool
SubscribeID string
Rule string
Name string
FilterJSON string
QueryCSV string
TTL time.Duration
Ephemeral bool
UserID string
GroupID string
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type personalListOptions struct {
Category string
EnabledOnly bool
IncludePending bool
Format string
}
type personalStatusOptions struct {
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
}
type personalStopOptions struct {
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
}
type personalStreamSourceOptions struct {
ConfigDir string
Identity personal.Identity
TicketMode string
TicketURL string
ClientIDOverride string
}
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
cmd := &cobra.Command{
Use: "schema <event_key>",
Short: "显示事件 schema",
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as != "user" {
return fmt.Errorf("event schema is only supported with --as user")
}
def, ok := personal.Lookup(args[0])
if !ok {
return fmt.Errorf("unknown personal event key %q", args[0])
}
if !def.Public {
return personal.PublicAvailabilityError(args[0])
}
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
hideEventInternalFlags(cmd, "as")
return cmd
}
func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending)
if opts.Format == "json" {
enc := json.NewEncoder(c.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(items)
}
tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION")
for _, it := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
it.EventKey, it.RuleType, it.Status, it.Description)
}
return tw.Flush()
}
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "json"
}
if format != "json" {
return fmt.Errorf("event schema only supports json output")
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(personal.BuildSchemaDocument(def))
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if opts.Common.DryRun {
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return consume.Run(ctx, cfg)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
if sub.SubscribeID == "" {
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
}
if err := personal.UpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
EventKey: eventKey,
RuleType: ruleType,
ClientID: identity.ClientID,
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
return fmt.Errorf("event consume --as user: save run state: %w", err)
}
cleanup := func() {
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
}
if opts.Ephemeral {
defer cleanup()
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
if opts.DebugRawEvents && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
workDir, filepath.Join(workDir, "bus.log"))
}
if err := consume.ValidateConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
if !opts.Ephemeral {
cleanup()
}
return err
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
}
bus.ApplyEnvTuning(&busCfg)
err = bus.Run(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
err = consume.Run(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
if cfg == nil {
return
}
if opts.DebugRawEvents {
cfg.EventTypes = nil
cfg.Filter = ""
cfg.SubscribeID = ""
return
}
cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes)
cfg.Filter = opts.Common.Filter
cfg.SubscribeID = strings.TrimSpace(subscribeID)
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
if err != nil {
return nil, "", "", err
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
ruleType = def.RuleType
}
}
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
return sub, eventKey, ruleType, nil
}
if strings.TrimSpace(opts.EventKey) == "" {
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
GroupID: opts.GroupID,
})
if err != nil {
return nil, "", "", err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return nil, "", "", err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
sub, err := client.CreateSubscription(ctx, req)
if err != nil {
return nil, "", "", err
}
return sub, opts.EventKey, ruleType, nil
}
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
var qs busctl.EntryStatus
if entry != nil {
qs = busctl.QueryEntry(*entry)
} else {
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
WorkDir: workDir,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
ClientIDHash: identityHash,
IdentityHash: identityHash,
State: busctl.BusStateNotRunning,
Meta: &bus.Meta{
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
},
}}
}
status := opts.Status
if status == "" || status == "all" {
status = ""
}
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
})
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
if opts.Format == "json" {
enc := json.NewEncoder(c.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"identity": redactedPersonalIdentity(identity, identityHash),
"subscriptions": subs,
"bus": qs,
})
}
renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs)
return nil
}
func ensurePublicPersonalEvent(eventKey string) error {
eventKey = strings.TrimSpace(eventKey)
if eventKey == "" {
return nil
}
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
return personal.PublicAvailabilityError(eventKey)
}
return nil
}
func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) {
fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n",
displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash)
fmt.Fprintf(w, "Bus: %s", qs.Entry.State)
if qs.Entry.HolderPID > 0 {
fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID)
}
fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir)
if len(subs) == 0 {
fmt.Fprintln(w, "Subscriptions: none")
} else {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE")
for _, sub := range subs {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID)
}
_ = tw.Flush()
}
renderPersonalConsumers(w, qs)
}
func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) {
if qs.Entry.State != busctl.BusStateRunning {
fmt.Fprintln(w, "Consumers: none")
return
}
if qs.Live == nil {
fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)")
return
}
if len(qs.Live.Consumers) == 0 {
fmt.Fprintln(w, "Consumers: none")
return
}
fmt.Fprintln(w, "Consumers:")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED")
for _, cs := range qs.Live.Consumers {
eventKeys := strings.Join(cs.EventTypes, ",")
if eventKeys == "" {
eventKeys = "(catch-all)"
}
subscribeID := displayPersonalStatusValue(cs.SubscribeID)
filter := displayPersonalStatusValue(cs.Filter)
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n",
cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped)
}
_ = tw.Flush()
}
func displayPersonalStatusValue(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "-"
}
return v
}
func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
ctx := c.Context()
explicitSubscribeID := strings.TrimSpace(opts.SubscribeID)
isSingleTarget := explicitSubscribeID != ""
if explicitSubscribeID != "" && opts.All {
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
}
if explicitSubscribeID == "" && !opts.All {
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := client.DeleteSubscription(ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
}
}
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
remaining, err := personal.LoadRunStates(workDir)
if err != nil {
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
}
if len(remaining) > 0 {
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running")
return nil
}
busState := "personal bus stopped"
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
return err
}
}
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState)
return nil
}
func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
explicit = strings.TrimSpace(explicit)
if explicit != "" && all {
return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive")
}
if explicit != "" {
return []string{explicit}, nil
}
if !all {
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
}
states, err := personal.LoadRunStates(workDir)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(states))
for _, st := range states {
if st.SubscribeID != "" {
ids = append(ids, st.SubscribeID)
}
}
sort.Strings(ids)
return ids, nil
}
func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error {
targets := make(map[string]struct{}, len(subscribeIDs))
for _, id := range subscribeIDs {
id = strings.TrimSpace(id)
if id != "" {
targets[id] = struct{}{}
}
}
if ipcEndpoint == "" || len(targets) == 0 {
return nil
}
status, err := busctl.QueryStatus(ipcEndpoint)
if err != nil {
return nil
}
signalled := make(map[int]struct{})
for _, consumer := range status.Consumers {
if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok {
continue
}
if consumer.PID <= 0 || consumer.PID == os.Getpid() {
continue
}
if _, ok := signalled[consumer.PID]; ok {
continue
}
proc, err := os.FindProcess(consumer.PID)
if err != nil {
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
}
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
}
signalled[consumer.PID] = struct{}{}
}
return nil
}
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
if single && len(subscribeIDs) == 1 {
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
return
}
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
return personal.Identity{}, err
}
tokenData, _ := authpkg.LoadTokenData(configDir)
var corpID, userID, clientID, refreshToken string
if tokenData != nil {
corpID = tokenData.CorpID
userID = tokenData.UserID
clientID = tokenData.ClientID
refreshToken = tokenData.RefreshToken
}
if corpID == "" {
corpID = resolveRuntimeDefault(ctx, "$corpId")
}
if userID == "" {
userID = resolveRuntimeDefault(ctx, "$currentUserId")
}
if clientID == "" {
clientID = authpkg.ClientID()
}
if clientID == "" {
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
clientID = id
}
}
if clientID == "" {
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
}
sourceID := strings.TrimSpace(sourceIDOverride)
if sourceID == "" {
sourceID = personalEventStreamSourceID("")
}
localSubject := ""
if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" {
localSubject = personalTokenSubject("refresh", refreshToken)
if localSubject == "" {
localSubject = personalTokenSubject("access", accessToken)
}
}
return personal.Identity{
AccessToken: accessToken,
LocalSubject: localSubject,
CorpID: corpID,
UserID: userID,
ClientID: clientID,
SourceID: sourceID,
}, nil
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
}
func resolveRuntimeDefault(ctx context.Context, key string) string {
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
if fn := fnMap()[key]; fn != nil {
if v, ok := fn(ctx); ok {
return strings.TrimSpace(v)
}
}
}
return ""
}
func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
mode := strings.TrimSpace(opts.TicketMode)
if mode == "" {
mode = "normal"
}
if mode != "normal" && mode != "custom" {
return nil, fmt.Errorf("stream ticket mode must be normal or custom")
}
ticketURL := strings.TrimSpace(opts.TicketURL)
if ticketURL == "" {
ticketURL = personalEventStreamTicketURL("", opts.ConfigDir)
}
clientID := opts.Identity.ClientID
clientSecret := ""
if mode == "custom" {
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
if err != nil {
return nil, err
}
if opts.ClientIDOverride != "" {
clientID = opts.ClientIDOverride
} else if clientID == "" {
clientID = resolvedID
}
clientSecret = secret
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessToken: opts.Identity.AccessToken,
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
}
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--stream-source-id", identity.SourceID,
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", ticketMode)
}
if strings.TrimSpace(ticketURL) != "" {
args = append(args, "--stream-ticket-url", ticketURL)
}
return args
}
func personalEventTypes(eventKey string, explicit []string) []string {
if len(explicit) > 0 {
return explicit
}
if strings.TrimSpace(eventKey) == "" {
return nil
}
return []string{eventKey}
}
func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string {
return map[string]string{
"corp_id": displayIdentityPart(identity.CorpID),
"user_id": displayIdentityPart(identity.UserID),
"client_id": identity.ClientID,
"source_id": identity.SourceID,
"identity_hash": identityHash,
}
}
func displayIdentityPart(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "unknown"
}
return v
}
func firstNonEmptyPersonalString(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func personalEventControlBaseURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
}
return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath
}
func personalEventStreamTicketURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
}
return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket"
}
func personalEventStreamSourceID(raw string) string {
if v := strings.TrimSpace(raw); v != "" {
return v
}
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
return v
}
return "open"
}
func personalEventMCPBaseURL(configDir string) string {
if v := configuredMCPBaseURL(configDir); v != "" {
return strings.TrimRight(v, "/")
}
return config.DefaultMCPBaseURL
}
func configuredMCPBaseURL(configDir string) string {
if strings.TrimSpace(configDir) == "" {
configDir = defaultConfigDir()
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}