From d75c802336eb07e1dbcf363f3e6c4c0ef995eb09 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Wed, 13 May 2026 16:10:03 +0200 Subject: [PATCH 1/2] feat(build): DM the user on Discord when a triggered build finishes Adds a --discord-dm flag to `panda build` that the proxy uses to send a Discord DM (via the panda-pulse bot token, plumbed through proxy config) when the workflow run reaches a terminal state. - CLI: --discord-dm / --no-discord-dm. The first time --discord-dm is supplied and no default is configured, the username is persisted to config.user.yaml under notifications.discord.username so subsequent builds notify without re-passing the flag. - Server: forwards Notify spec to the proxy's /github/actions/trigger. - Proxy: optional `discord:` config with bot_token + guild_id. When set, the GitHub handler spawns a watcher goroutine after a successful dispatch that polls the run and DMs the resolved user on completion. Resolution accepts either a numeric Discord ID or a username (looked up via guild member search). - Discord client uses raw REST against discord.com/api/v10 to avoid a websocket/gateway dependency. --- config.example.yaml | 7 ++ pkg/cli/build.go | 90 ++++++++++++++ pkg/config/config.go | 16 +++ pkg/proxy/handlers/discord.go | 225 ++++++++++++++++++++++++++++++++++ pkg/proxy/handlers/github.go | 90 ++++++++++++++ pkg/proxy/server.go | 8 ++ pkg/proxy/server_config.go | 14 +++ pkg/server/api.go | 4 + pkg/serverapi/types.go | 9 ++ proxy-config.example.yaml | 11 ++ 10 files changed, 474 insertions(+) create mode 100644 pkg/proxy/handlers/discord.go diff --git a/config.example.yaml b/config.example.yaml index 9ed3bd81..276a3d78 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -55,6 +55,13 @@ proxy: # repository: "ethereum/consensus-specs" # GitHub owner/repo (for forks) # ref: "" # Branch, tag, or SHA (empty = latest release) +# Notification defaults shared by long-running commands (e.g. panda build). +# Persisted to config.user.yaml the first time `panda build --discord-dm ` +# is invoked, so subsequent builds DM you without re-passing the flag. +# notifications: +# discord: +# username: "barnabasbusa" # Discord username or user ID + # Observability configuration observability: metrics_enabled: true diff --git a/pkg/cli/build.go b/pkg/cli/build.go index 2770c99a..b226b0c3 100644 --- a/pkg/cli/build.go +++ b/pkg/cli/build.go @@ -9,6 +9,8 @@ import ( "github.com/spf13/cobra" + "github.com/ethpandaops/panda/pkg/config" + "github.com/ethpandaops/panda/pkg/configpath" "github.com/ethpandaops/panda/pkg/serverapi" ) @@ -21,6 +23,8 @@ var ( buildRepo string buildDockerTag string buildWait bool + buildDiscordDM string + buildNoDiscord bool ) var buildCmd = &cobra.Command{ @@ -65,17 +69,24 @@ func init() { buildCmd.Flags().StringVar(&buildRepo, "repo", "", "source repository override (e.g. user/go-ethereum)") buildCmd.Flags().StringVar(&buildDockerTag, "tag", "", "override target docker tag") buildCmd.Flags().BoolVar(&buildWait, "wait", false, "block until the build completes instead of returning immediately") + buildCmd.Flags().StringVar(&buildDiscordDM, "discord-dm", "", + "Discord username or user ID to DM on build completion (default: notifications.discord.username in config)") + buildCmd.Flags().BoolVar(&buildNoDiscord, "no-discord-dm", false, + "disable Discord notification for this build, even if a default username is configured") } func runBuild(_ *cobra.Command, args []string) error { client := args[0] ctx := context.Background() + discordUser := resolveDiscordTarget() + resp, err := triggerBuild(ctx, serverapi.BuildTriggerRequest{ Client: client, Repository: buildRepo, Ref: buildRef, DockerTag: buildDockerTag, + Notify: buildNotifySpec(discordUser), }) if err != nil { return fmt.Errorf("triggering build: %w", err) @@ -196,6 +207,85 @@ func printBuildTriggered(resp *serverapi.BuildTriggerResponse) error { return nil } +// resolveDiscordTarget picks the Discord recipient for this build invocation. +// Precedence: --no-discord-dm wins; otherwise --discord-dm; otherwise the +// configured default in notifications.discord.username. When --discord-dm is +// supplied but the configured default is empty, the value is persisted to +// config.user.yaml so subsequent builds notify without re-passing the flag. +func resolveDiscordTarget() string { + if buildNoDiscord { + return "" + } + + configured := loadDiscordDefault() + + if buildDiscordDM == "" { + return configured + } + + if configured == "" { + if err := persistDiscordDefault(buildDiscordDM); err != nil { + fmt.Fprintf(os.Stderr, " (could not save Discord username to config: %v)\n", err) + } else { + fmt.Fprintf(os.Stderr, " Saved %q as default for future builds (override with --discord-dm or --no-discord-dm)\n", buildDiscordDM) + } + } + + return buildDiscordDM +} + +func buildNotifySpec(discordUser string) *serverapi.BuildNotifySpec { + if discordUser == "" { + return nil + } + + return &serverapi.BuildNotifySpec{DiscordUser: discordUser} +} + +func loadDiscordDefault() string { + resolved, err := configpath.ResolveAppConfigPath(cfgFile) + if err != nil { + return "" + } + + cfg, err := config.LoadWithUserOverrides(resolved) + if err != nil { + return "" + } + + return cfg.Notifications.Discord.Username +} + +func persistDiscordDefault(username string) error { + resolved, err := configpath.ResolveAppConfigPath(cfgFile) + if err != nil { + return err + } + + userPath := config.UserConfigPath(resolved) + + overrides, err := config.LoadUserConfigMap(userPath) + if err != nil { + return err + } + + notifications, _ := overrides["notifications"].(map[string]any) + if notifications == nil { + notifications = make(map[string]any, 1) + } + + discord, _ := notifications["discord"].(map[string]any) + if discord == nil { + discord = make(map[string]any, 1) + } + + discord["username"] = username + notifications["discord"] = discord + overrides["notifications"] = notifications + + return config.SaveUserConfig(userPath, overrides) +} + func pollBuildStatus(ctx context.Context, runID int64) (*serverapi.BuildStatusResponse, error) { ticker := time.NewTicker(buildPollInterval) defer ticker.Stop() diff --git a/pkg/config/config.go b/pkg/config/config.go index b17d11db..2504bcfa 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,10 +24,26 @@ type Config struct { Storage StorageConfig `yaml:"storage"` Observability ObservabilityConfig `yaml:"observability"` ConsensusSpecs ConsensusSpecsConfig `yaml:"consensus_specs,omitempty"` + Notifications NotificationsConfig `yaml:"notifications,omitempty"` path string `yaml:"-"` } +// NotificationsConfig holds per-user notification preferences shared by panda +// CLI commands that produce long-running results (e.g. panda build). +type NotificationsConfig struct { + // Discord configures Discord notifications. + Discord DiscordNotificationConfig `yaml:"discord,omitempty"` +} + +// DiscordNotificationConfig holds Discord notification preferences. +type DiscordNotificationConfig struct { + // Username is the Discord username or user ID to DM on build completion. + // When set, panda build will auto-include it in trigger requests. + // Override with --discord-dm or disable with --no-discord-dm. + Username string `yaml:"username,omitempty"` +} + // ConsensusSpecsConfig configures how consensus-specs are fetched from GitHub. type ConsensusSpecsConfig struct { // Repository is the GitHub owner/repo (e.g. "ethereum/consensus-specs"). diff --git a/pkg/proxy/handlers/discord.go b/pkg/proxy/handlers/discord.go new file mode 100644 index 00000000..86e712ab --- /dev/null +++ b/pkg/proxy/handlers/discord.go @@ -0,0 +1,225 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +const ( + discordAPIBase = "https://discord.com/api/v10" +) + +// DiscordConfig holds Discord configuration for sending build completion DMs. +type DiscordConfig struct { + BotToken string + GuildID string +} + +// DiscordClient sends Discord DMs as a bot user. Resolution accepts either a +// numeric user ID (preferred, unambiguous) or a username string that will be +// looked up against the configured guild. +type DiscordClient struct { + log logrus.FieldLogger + cfg DiscordConfig + httpClient *http.Client +} + +// NewDiscordClient returns a client configured to act as the bot. Returns nil +// if no bot token is configured. +func NewDiscordClient(log logrus.FieldLogger, cfg DiscordConfig) *DiscordClient { + if cfg.BotToken == "" { + return nil + } + + return &DiscordClient{ + log: log.WithField("handler", "discord"), + cfg: cfg, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +// SendDM resolves the target and posts a DM. Errors are returned for the +// caller to log — they are not surfaced to the user who triggered the build. +func (c *DiscordClient) SendDM(ctx context.Context, target, message string) error { + target = strings.TrimSpace(strings.TrimPrefix(target, "@")) + if target == "" { + return fmt.Errorf("empty target") + } + + userID, err := c.resolveUserID(ctx, target) + if err != nil { + return fmt.Errorf("resolving %q: %w", target, err) + } + + channelID, err := c.openDMChannel(ctx, userID) + if err != nil { + return fmt.Errorf("opening DM channel: %w", err) + } + + return c.postMessage(ctx, channelID, message) +} + +// resolveUserID accepts either a numeric snowflake or a username. Usernames are +// resolved by searching the configured guild(s). +func (c *DiscordClient) resolveUserID(ctx context.Context, target string) (string, error) { + if isSnowflake(target) { + return target, nil + } + + if c.cfg.GuildID == "" { + return "", fmt.Errorf("guild_id not configured; pass a Discord user ID instead of a username") + } + + for _, guildID := range strings.Split(c.cfg.GuildID, ",") { + guildID = strings.TrimSpace(guildID) + if guildID == "" { + continue + } + + id, err := c.searchGuildMember(ctx, guildID, target) + if err != nil { + c.log.WithError(err).WithField("guild_id", guildID). + Debug("Guild member search failed") + + continue + } + + if id != "" { + return id, nil + } + } + + return "", fmt.Errorf("no member matching %q found in configured guilds", target) +} + +func (c *DiscordClient) searchGuildMember(ctx context.Context, guildID, query string) (string, error) { + endpoint := fmt.Sprintf("%s/guilds/%s/members/search?query=%s&limit=10", + discordAPIBase, guildID, urlQueryEscape(query)) + + var members []struct { + User struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"user"` + } + + if err := c.doJSON(ctx, http.MethodGet, endpoint, nil, &members); err != nil { + return "", err + } + + for _, m := range members { + if strings.EqualFold(m.User.Username, query) { + return m.User.ID, nil + } + } + + if len(members) > 0 { + return members[0].User.ID, nil + } + + return "", nil +} + +func (c *DiscordClient) openDMChannel(ctx context.Context, userID string) (string, error) { + body := map[string]string{"recipient_id": userID} + + var resp struct { + ID string `json:"id"` + } + + if err := c.doJSON(ctx, http.MethodPost, discordAPIBase+"/users/@me/channels", body, &resp); err != nil { + return "", err + } + + if resp.ID == "" { + return "", fmt.Errorf("discord returned empty channel id") + } + + return resp.ID, nil +} + +func (c *DiscordClient) postMessage(ctx context.Context, channelID, content string) error { + body := map[string]string{"content": content} + endpoint := fmt.Sprintf("%s/channels/%s/messages", discordAPIBase, channelID) + + return c.doJSON(ctx, http.MethodPost, endpoint, body, nil) +} + +func (c *DiscordClient) doJSON(ctx context.Context, method, url string, body, out any) error { + var reader io.Reader + + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshaling body: %w", err) + } + + reader = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", "Bot "+c.cfg.BotToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + + return fmt.Errorf("discord HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + if out == nil { + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +// isSnowflake reports whether s is a numeric Discord snowflake. +func isSnowflake(s string) bool { + if len(s) < 15 || len(s) > 20 { + return false + } + + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + + return true +} + +// urlQueryEscape escapes a query value without importing net/url just for this. +func urlQueryEscape(s string) string { + var b strings.Builder + + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.', r == '~': + b.WriteRune(r) + default: + fmt.Fprintf(&b, "%%%02X", r) + } + } + + return b.String() +} diff --git a/pkg/proxy/handlers/github.go b/pkg/proxy/handlers/github.go index c796c487..c62bb07a 100644 --- a/pkg/proxy/handlers/github.go +++ b/pkg/proxy/handlers/github.go @@ -48,6 +48,14 @@ type GitHubTriggerRequest struct { Ref string `json:"ref"` // Inputs are the workflow_dispatch inputs. Inputs map[string]string `json:"inputs,omitempty"` + // Notify carries optional completion-notification settings. + Notify *GitHubNotifySpec `json:"notify,omitempty"` +} + +// GitHubNotifySpec configures completion notifications for a triggered run. +type GitHubNotifySpec struct { + // DiscordUser is a Discord username or user ID to DM when the run finishes. + DiscordUser string `json:"discord_user,omitempty"` } // GitHubTriggerResponse is the response from a successful workflow trigger. @@ -85,6 +93,7 @@ type GitHubHandler struct { log logrus.FieldLogger token string httpClient *http.Client + discord *DiscordClient mu sync.Mutex lastTrigger map[string]time.Time // workflow -> last trigger time @@ -101,6 +110,12 @@ func NewGitHubHandler(log logrus.FieldLogger, cfg GitHubConfig) *GitHubHandler { } } +// SetDiscordClient installs a Discord client used for completion DMs. Safe to +// pass nil to disable notifications. +func (h *GitHubHandler) SetDiscordClient(c *DiscordClient) { + h.discord = c +} + // checkTriggerAllowed returns an error message if the trigger should be rejected. func (h *GitHubHandler) checkTriggerAllowed(workflow string) string { h.mu.Lock() @@ -272,6 +287,12 @@ func (h *GitHubHandler) handleTriggerWorkflow(w http.ResponseWriter, r *http.Req if run := h.findTriggeredRun(r.Context(), req.Repository, req.Workflow, dispatchTime); run != nil { triggerResp.RunID = run.ID triggerResp.RunURL = run.HTMLURL + + // Spawn a watcher if a Discord notification was requested. Detached from + // the request context so the goroutine outlives the trigger response. + if h.discord != nil && req.Notify != nil && req.Notify.DiscordUser != "" { + go h.watchAndNotify(req.Repository, req.Workflow, *run, req.Notify.DiscordUser) + } } w.Header().Set("Content-Type", "application/json") @@ -279,6 +300,75 @@ func (h *GitHubHandler) handleTriggerWorkflow(w http.ResponseWriter, r *http.Req _ = json.NewEncoder(w).Encode(triggerResp) } +// watchAndNotify polls a workflow run until it reaches a terminal status, then +// DMs the requested Discord user. Errors are logged and swallowed — the user +// who triggered the build already got an immediate response. +func (h *GitHubHandler) watchAndNotify(repo, workflow string, initial gitHubWorkflowRun, discordTarget string) { + const ( + pollInterval = 30 * time.Second + maxDuration = 2 * time.Hour + ) + + ctx, cancel := context.WithTimeout(context.Background(), maxDuration) + defer cancel() + + log := h.log.WithFields(logrus.Fields{ + "run_id": initial.ID, + "target": discordTarget, + "client": strings.TrimSuffix(strings.TrimPrefix(workflow, "build-push-"), ".yml"), + }) + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + final := initial + + for { + select { + case <-ctx.Done(): + log.Warn("Watcher timed out before workflow run completed") + return + case <-ticker.C: + run, err := h.getRun(ctx, repo, initial.ID) + if err != nil { + log.WithError(err).Debug("Failed to get run status, retrying") + continue + } + + final = *run + if run.Status == "completed" { + goto done + } + } + } + +done: + clientName := strings.TrimSuffix(strings.TrimPrefix(workflow, "build-push-"), ".yml") + icon := "✅" + + if final.Conclusion != "success" { + icon = "❌" + } + + msg := fmt.Sprintf("%s `%s` build %s — %s\n%s", + icon, clientName, final.Conclusion, summarizeStatus(final), final.HTMLURL) + + if err := h.discord.SendDM(ctx, discordTarget, msg); err != nil { + log.WithError(err).Warn("Failed to send Discord DM") + return + } + + log.Info("Sent Discord build-completion DM") +} + +func summarizeStatus(run gitHubWorkflowRun) string { + if run.Conclusion == "" { + return run.Status + } + + return run.Conclusion +} + func (h *GitHubHandler) handleRunStatus(w http.ResponseWriter, r *http.Request) { var req GitHubRunStatusRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index f915f69b..62d988a2 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -180,6 +180,14 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) ( s.githubHandler = handlers.NewGitHubHandler(log, handlers.GitHubConfig{ Token: cfg.GitHub.Token, }) + + // Attach a Discord client when configured, so completion DMs can be sent. + if cfg.Discord != nil && cfg.Discord.BotToken != "" { + s.githubHandler.SetDiscordClient(handlers.NewDiscordClient(log, handlers.DiscordConfig{ + BotToken: cfg.Discord.BotToken, + GuildID: cfg.Discord.GuildID, + })) + } } if s.url == "" { diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go index 85d6fc5f..8e3e58cc 100644 --- a/pkg/proxy/server_config.go +++ b/pkg/proxy/server_config.go @@ -49,6 +49,9 @@ type ServerConfig struct { // GitHub holds optional GitHub API configuration for triggering workflows. GitHub *GitHubAPIConfig `yaml:"github,omitempty"` + + // Discord holds optional Discord configuration used for build completion DMs. + Discord *DiscordAPIConfig `yaml:"discord,omitempty"` } // GitHubAPIConfig holds GitHub API configuration for the proxy. @@ -57,6 +60,17 @@ type GitHubAPIConfig struct { Token string `yaml:"token"` } +// DiscordAPIConfig holds Discord configuration used to send build completion DMs. +type DiscordAPIConfig struct { + // BotToken is the Discord bot token used to authenticate as the bot user. + BotToken string `yaml:"bot_token"` + + // GuildID is the Discord guild used to resolve @usernames to user IDs. + // The bot must be a member of this guild. Multiple comma-separated IDs are + // accepted; the first that yields a match is used. + GuildID string `yaml:"guild_id,omitempty"` +} + // HTTPServerConfig holds HTTP server configuration. type HTTPServerConfig struct { // ListenAddr is the address to listen on (default: ":18081"). diff --git a/pkg/server/api.go b/pkg/server/api.go index 5cf611a1..d2da434f 100644 --- a/pkg/server/api.go +++ b/pkg/server/api.go @@ -591,6 +591,10 @@ func (s *service) handleAPIBuildTrigger(w http.ResponseWriter, r *http.Request) "inputs": inputs, } + if req.Notify != nil && req.Notify.DiscordUser != "" { + proxyReq["notify"] = map[string]string{"discord_user": req.Notify.DiscordUser} + } + body, err := json.Marshal(proxyReq) if err != nil { writeAPIError(w, http.StatusInternalServerError, "failed to marshal proxy request") diff --git a/pkg/serverapi/types.go b/pkg/serverapi/types.go index 0df788e2..ad388d8b 100644 --- a/pkg/serverapi/types.go +++ b/pkg/serverapi/types.go @@ -215,6 +215,15 @@ type BuildTriggerRequest struct { Ref string `json:"ref,omitempty"` // DockerTag is the target docker tag override (optional). DockerTag string `json:"docker_tag,omitempty"` + // Notify configures completion notifications for this build (optional). + Notify *BuildNotifySpec `json:"notify,omitempty"` +} + +// BuildNotifySpec configures completion notifications for a build. +type BuildNotifySpec struct { + // DiscordUser is a Discord username or user ID to DM when the build + // reaches a terminal state. Empty disables Discord notifications. + DiscordUser string `json:"discord_user,omitempty"` } // BuildTriggerResponse is the response from POST /api/v1/build/trigger. diff --git a/proxy-config.example.yaml b/proxy-config.example.yaml index b67b3af5..a27f6a84 100644 --- a/proxy-config.example.yaml +++ b/proxy-config.example.yaml @@ -114,6 +114,17 @@ loki: # allowed_orgs: # - ethpandaops +# GitHub API (optional — enables `panda build ` workflow_dispatch) +# github: +# token: "${GITHUB_TOKEN}" # PAT with actions:write on the target repo + +# Discord (optional — enables build-completion DMs) +# Requires `github:` to also be configured. The bot must be a member of the +# guild named in `guild_id` so usernames can be resolved to user IDs. +# discord: +# bot_token: "${DISCORD_BOT_TOKEN}" +# guild_id: "${DISCORD_GUILD_ID}" # comma-separated for multiple guilds + # Embedding API (optional — enables remote embedding for semantic search) # embedding: # api_key: "${OPENROUTER_API_KEY}" From 546b2f7f016b76aaed687c2260bbf537d711bb2f Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Wed, 13 May 2026 16:58:11 +0200 Subject: [PATCH 2/2] feat(init): prompt for + persist a Discord username during setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional Discord username step to `panda init` so build-completion DMs work out of the box without ever passing --discord-dm. The value lands in config.user.yaml under notifications.discord.username, which `panda build` already reads as its default target. - Interactive: prompts on a TTY after auth, unless --skip-discord is set or a username is already configured. - Non-interactive: --discord-username sets it directly; --skip-discord silences the prompt entirely. Stdin not a TTY ⇒ no prompt, no failure. - Persists via the same config.SaveUserConfig path used by `panda build`'s auto-save behavior, so the flows produce identical YAML. --- go.mod | 2 +- pkg/cli/init.go | 112 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 7cfeaed2..98201590 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.41.0 + golang.org/x/term v0.40.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -103,7 +104,6 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/pkg/cli/init.go b/pkg/cli/init.go index d8af885b..51ea0561 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "encoding/json" "fmt" @@ -17,6 +18,7 @@ import ( dockerimage "github.com/docker/docker/api/types/image" dockerclient "github.com/docker/docker/client" "github.com/spf13/cobra" + "golang.org/x/term" authclient "github.com/ethpandaops/panda/pkg/auth/client" authstore "github.com/ethpandaops/panda/pkg/auth/store" @@ -32,14 +34,16 @@ const ( ) var ( - initDir = configpath.DefaultConfigDir() - initForce bool - initProxyURL string - initSandboxImage string - initServerImage string - initSkipDocker bool - initSkipAuth bool - initSkipStart bool + initDir = configpath.DefaultConfigDir() + initForce bool + initProxyURL string + initSandboxImage string + initServerImage string + initSkipDocker bool + initSkipAuth bool + initSkipStart bool + initDiscordUsername string + initSkipDiscord bool ) var initCmd = &cobra.Command{ @@ -73,6 +77,10 @@ func init() { initCmd.Flags().BoolVar(&initSkipStart, "skip-start", false, "skip starting the server") initCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "manual auth flow for SSH/headless environments (auto-detected over SSH)") + initCmd.Flags().StringVar(&initDiscordUsername, "discord-username", "", + "Discord username or user ID to DM on build completion (skips interactive prompt)") + initCmd.Flags().BoolVar(&initSkipDiscord, "skip-discord", false, + "skip the Discord username prompt entirely") } func runInit(_ *cobra.Command, _ []string) error { @@ -162,6 +170,11 @@ func runInit(_ *cobra.Command, _ []string) error { fmt.Println("\nSkipping authentication (--skip-auth)") } + // 4b. Discord username (optional, persisted to config.user.yaml). + if err := initEnsureDiscordUsername(configPath); err != nil { + fmt.Fprintf(os.Stderr, " (could not save Discord username: %v)\n", err) + } + // 5. Start the server. switch { case initSkipStart: @@ -495,3 +508,86 @@ func initEnsureAuth() (bool, error) { return false, runAuthLogin(nil, nil) } + +// initEnsureDiscordUsername gathers an optional Discord username and persists +// it under notifications.discord.username so future `panda build` invocations +// notify the user without needing the --discord-dm flag. The prompt is +// suppressed under --skip-discord, when stdin is not a TTY, or when a value is +// already configured. A value supplied via --discord-username always wins. +func initEnsureDiscordUsername(configPath string) error { + if initSkipDiscord { + return nil + } + + username := strings.TrimSpace(initDiscordUsername) + + if username == "" { + existing, _ := loadConfiguredDiscordUsername(configPath) + if existing != "" { + fmt.Printf("Discord notifications: already configured for %q\n", existing) + return nil + } + + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil + } + + fmt.Println() + fmt.Println("Optional: Discord username for build-completion DMs.") + fmt.Println(" Leave blank to skip; you can set it later with --discord-dm on `panda build`.") + fmt.Print("Discord username: ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return nil + } + + username = strings.TrimSpace(scanner.Text()) + } + + if username == "" { + return nil + } + + if err := saveDiscordUsername(configPath, username); err != nil { + return err + } + + fmt.Printf("Saved Discord username %q to config.user.yaml\n", username) + + return nil +} + +func loadConfiguredDiscordUsername(configPath string) (string, error) { + cfg, err := config.LoadWithUserOverrides(configPath) + if err != nil { + return "", err + } + + return cfg.Notifications.Discord.Username, nil +} + +func saveDiscordUsername(configPath, username string) error { + userPath := config.UserConfigPath(configPath) + + overrides, err := config.LoadUserConfigMap(userPath) + if err != nil { + return err + } + + notifications, _ := overrides["notifications"].(map[string]any) + if notifications == nil { + notifications = make(map[string]any, 1) + } + + discord, _ := notifications["discord"].(map[string]any) + if discord == nil { + discord = make(map[string]any, 1) + } + + discord["username"] = username + notifications["discord"] = discord + overrides["notifications"] = notifications + + return config.SaveUserConfig(userPath, overrides) +}