diff --git a/Makefile b/Makefile index a969b4b..317dc3c 100644 --- a/Makefile +++ b/Makefile @@ -85,8 +85,7 @@ dev: @sleep 3 @echo "API running at http://localhost:8080" @echo "" - @echo "Installing frontend dependencies..." - @cd frontend && npm install --silent + @if [ ! -d frontend/node_modules ]; then echo "Installing frontend dependencies..." && cd frontend && npm install --silent; fi @echo "Starting Vite dev server at http://localhost:3000" @echo "" @cd frontend && npm run dev diff --git a/backend/internal/api/handlers/slack_config.go b/backend/internal/api/handlers/slack_config.go index 9df1b40..76a2186 100644 --- a/backend/internal/api/handlers/slack_config.go +++ b/backend/internal/api/handlers/slack_config.go @@ -18,7 +18,6 @@ type slackConfigResponse struct { WorkspaceName string `json:"workspace_name,omitempty"` BotUserID string `json:"bot_user_id,omitempty"` HasBotToken bool `json:"has_bot_token"` - HasAppToken bool `json:"has_app_token"` HasOAuthConfig bool `json:"has_oauth_config"` ConnectedAt *time.Time `json:"connected_at,omitempty"` } @@ -33,7 +32,6 @@ func toSlackConfigResponse(cfg *models.SlackConfig) slackConfigResponse { WorkspaceName: cfg.WorkspaceName, BotUserID: cfg.BotUserID, HasBotToken: cfg.BotToken != "", - HasAppToken: cfg.AppToken != "", HasOAuthConfig: cfg.OAuthClientID != "" && cfg.OAuthClientSecret != "", ConnectedAt: &cfg.ConnectedAt, } @@ -51,13 +49,18 @@ func GetSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc { } } +// HandlerInvalidator is implemented by types that cache a Slack event handler +// and need to be notified when config changes. +type HandlerInvalidator interface { + Invalidate() +} + // SaveSlackConfig stores Slack tokens. -func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc { +func SaveSlackConfig(repo repository.SlackConfigRepository, invalidator ...HandlerInvalidator) gin.HandlerFunc { return func(c *gin.Context) { var req struct { BotToken string `json:"bot_token"` SigningSecret string `json:"signing_secret"` - AppToken string `json:"app_token"` WorkspaceID string `json:"workspace_id"` WorkspaceName string `json:"workspace_name"` BotUserID string `json:"bot_user_id"` @@ -83,7 +86,6 @@ func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc { cfg := &models.SlackConfig{ BotToken: req.BotToken, SigningSecret: req.SigningSecret, - AppToken: req.AppToken, WorkspaceID: req.WorkspaceID, WorkspaceName: req.WorkspaceName, BotUserID: req.BotUserID, @@ -97,6 +99,9 @@ func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save slack config"}) return } + for _, inv := range invalidator { + inv.Invalidate() + } c.JSON(http.StatusOK, toSlackConfigResponse(cfg)) } } @@ -129,12 +134,15 @@ func TestSlackConfig() gin.HandlerFunc { } // DeleteSlackConfig removes the Slack integration. -func DeleteSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc { +func DeleteSlackConfig(repo repository.SlackConfigRepository, invalidator ...HandlerInvalidator) gin.HandlerFunc { return func(c *gin.Context) { if err := repo.Delete(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete slack config"}) return } + for _, inv := range invalidator { + inv.Invalidate() + } c.JSON(http.StatusOK, gin.H{"message": "slack integration removed"}) } } diff --git a/backend/internal/api/handlers/slack_http.go b/backend/internal/api/handlers/slack_http.go new file mode 100644 index 0000000..f912a73 --- /dev/null +++ b/backend/internal/api/handlers/slack_http.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/url" + + "github.com/FluidifyAI/Regen/backend/internal/api/middleware" + "github.com/FluidifyAI/Regen/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" +) + +// SlackEvents handles POST /api/v1/slack/events — Slack Events API payloads. +// Signature verification is done by SlackSignatureVerification middleware upstream. +func SlackEvents(resolver *services.SlackHandlerResolver) gin.HandlerFunc { + return func(c *gin.Context) { + body, err := middleware.SlackBodyFromContext(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"}) + return + } + + // url_verification is Slack's one-time challenge when saving the Events URL. + // Respond even when the handler isn't ready yet. + var challenge struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + } + if err := json.Unmarshal(body, &challenge); err == nil && challenge.Type == "url_verification" { + c.JSON(http.StatusOK, gin.H{"challenge": challenge.Challenge}) + return + } + + handler := resolver.Get() + if handler == nil { + c.Status(http.StatusOK) // ACK Slack; processing unavailable until config is saved + return + } + + eventsAPIEvent, err := slackevents.ParseEvent(json.RawMessage(body), slackevents.OptionNoVerifyToken()) + if err != nil { + slog.Warn("slack events: failed to parse payload", "error", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid event payload"}) + return + } + + // ACK immediately; dispatch is async inside HandleEventsAPI. + c.Status(http.StatusOK) + go handler.HandleEventsAPI(eventsAPIEvent) + } +} + +// SlackInteractions handles POST /api/v1/slack/interactions — button clicks and modal submissions. +func SlackInteractions(resolver *services.SlackHandlerResolver) gin.HandlerFunc { + return func(c *gin.Context) { + handler := resolver.Get() + if handler == nil { + c.Status(http.StatusOK) + return + } + + body, err := middleware.SlackBodyFromContext(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"}) + return + } + + // Interactions arrive as application/x-www-form-urlencoded with a "payload" key. + values, err := url.ParseQuery(string(body)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid form body"}) + return + } + payloadJSON := values.Get("payload") + if payloadJSON == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing payload field"}) + return + } + + var callback slack.InteractionCallback + if err := json.Unmarshal([]byte(payloadJSON), &callback); err != nil { + slog.Warn("slack interactions: failed to parse payload", "error", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid interaction payload"}) + return + } + + // ACK with 200 immediately — Slack requires a response within 3 seconds. + c.Status(http.StatusOK) + go handler.HandleInteraction(callback) + } +} + +// SlackCommands handles POST /api/v1/slack/commands — slash command payloads. +func SlackCommands(resolver *services.SlackHandlerResolver) gin.HandlerFunc { + return func(c *gin.Context) { + handler := resolver.Get() + if handler == nil { + c.Status(http.StatusOK) + return + } + + body, err := middleware.SlackBodyFromContext(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"}) + return + } + + values, err := url.ParseQuery(string(body)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid form body"}) + return + } + + cmd := slack.SlashCommand{ + Token: values.Get("token"), + TeamID: values.Get("team_id"), + TeamDomain: values.Get("team_domain"), + EnterpriseID: values.Get("enterprise_id"), + EnterpriseName: values.Get("enterprise_name"), + ChannelID: values.Get("channel_id"), + ChannelName: values.Get("channel_name"), + UserID: values.Get("user_id"), + UserName: values.Get("user_name"), + Command: values.Get("command"), + Text: values.Get("text"), + ResponseURL: values.Get("response_url"), + TriggerID: values.Get("trigger_id"), + } + + // ACK with 200 immediately — Slack requires a response within 3 seconds. + c.Status(http.StatusOK) + go handler.HandleCommand(cmd) + } +} diff --git a/backend/internal/api/middleware/slack_signature.go b/backend/internal/api/middleware/slack_signature.go index 424014e..e6174e7 100644 --- a/backend/internal/api/middleware/slack_signature.go +++ b/backend/internal/api/middleware/slack_signature.go @@ -10,7 +10,6 @@ import ( "log/slog" "math" "net/http" - "os" "strconv" "time" @@ -26,33 +25,20 @@ const ( SlackSignatureVersion = "v0" ) -// SlackSignatureVerification returns a middleware that verifies Slack request signatures +// SlackSignatureVerification returns a middleware that verifies Slack request signatures. +// getSecret is called on every request so the signing secret is always current — no restart +// needed after Slack config is saved via the UI. // // This implements Slack's signature verification algorithm: // https://api.slack.com/authentication/verifying-requests-from-slack -// -// The middleware: -// 1. Checks that the request timestamp is within 5 minutes (prevents replay attacks) -// 2. Computes HMAC-SHA256 signature of "v0:timestamp:body" -// 3. Compares computed signature with X-Slack-Signature header -// 4. Rejects requests with invalid or missing signatures -// -// Usage: -// -// slackRoutes.Use(middleware.SlackSignatureVerification()) -func SlackSignatureVerification() gin.HandlerFunc { - signingSecret := os.Getenv("SLACK_SIGNING_SECRET") - - // If no signing secret is configured, allow requests through with a warning - // This is for development/testing only - if signingSecret == "" { - slog.Warn("SLACK_SIGNING_SECRET not set - Slack signature verification disabled") - return func(c *gin.Context) { +func SlackSignatureVerification(getSecret func() string) gin.HandlerFunc { + return func(c *gin.Context) { + signingSecret := getSecret() + if signingSecret == "" { + slog.Warn("Slack signing secret not configured - signature verification disabled") c.Next() + return } - } - - return func(c *gin.Context) { // Get the timestamp and signature headers timestamp := c.GetHeader("X-Slack-Request-Timestamp") signature := c.GetHeader("X-Slack-Signature") @@ -141,6 +127,9 @@ func SlackSignatureVerification() gin.HandlerFunc { return } + // Store the buffered body so handlers can read it without a second io.ReadAll. + c.Set("slack_raw_body", bodyBytes) + // Signature is valid, proceed c.Next() } @@ -173,3 +162,14 @@ func computeSlackSignature(signingSecret, timestamp string, body []byte) string // Format as v0= return fmt.Sprintf("%s=%s", SlackSignatureVersion, hex.EncodeToString(hash)) } + +// SlackBodyFromContext retrieves the raw request body buffered by SlackSignatureVerification. +// Falls back to reading from c.Request.Body when the key is absent (e.g. in unit tests). +func SlackBodyFromContext(c *gin.Context) ([]byte, error) { + if raw, ok := c.Get("slack_raw_body"); ok { + if b, ok := raw.([]byte); ok { + return b, nil + } + } + return io.ReadAll(c.Request.Body) +} diff --git a/backend/internal/api/middleware/slack_signature_test.go b/backend/internal/api/middleware/slack_signature_test.go index d88bbb5..434ce5a 100644 --- a/backend/internal/api/middleware/slack_signature_test.go +++ b/backend/internal/api/middleware/slack_signature_test.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os" "strconv" "testing" "time" @@ -115,23 +114,21 @@ func TestSlackSignatureVerification(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Setup environment - if tt.setupEnv { - os.Setenv("SLACK_SIGNING_SECRET", tt.signingSecret) - } else { - os.Unsetenv("SLACK_SIGNING_SECRET") - } - defer os.Unsetenv("SLACK_SIGNING_SECRET") - // Compute signature if requested signature := tt.signature if tt.computeSignature && tt.timestamp != "" && tt.setupEnv { signature = computeTestSignature(tt.signingSecret, tt.timestamp, []byte(tt.body)) } + // Pass the signing secret directly (DB-stored, not via env var) + secret := "" + if tt.setupEnv { + secret = tt.signingSecret + } + // Create test router router := gin.New() - router.Use(SlackSignatureVerification()) + router.Use(SlackSignatureVerification(func() string { return secret })) router.POST("/slack/events", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 0e5edbf..1548a4c 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -108,25 +108,10 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc * } } - // Start Slack Socket Mode event handler (bidirectional sync) if app token is configured. - if slackCfgForSocket, err := slackConfigRepo.Get(); err == nil && - slackCfgForSocket != nil && slackCfgForSocket.AppToken != "" && chatService != nil { - eventHandler, err := services.NewSlackEventHandler( - slackCfgForSocket.AppToken, - slackCfgForSocket.BotToken, - incidentSvc, - chatService, - userRepo, - pmRepo, - ) - if err != nil { - slog.Error("failed to initialize slack socket mode", "error", err) - slog.Warn("bidirectional Slack sync disabled - Slack will be one-way only") - } else { - eventHandler.SetAIService(aiSvc) - eventHandler.Start() - } - } + // Slack event handler resolver — lazily initializes from DB config on first use. + // Routes are always registered so events work immediately after config is saved via UI, + // without requiring a server restart. + slackResolver := services.NewSlackHandlerResolver(slackConfigRepo, incidentSvc, chatService, userRepo, pmRepo, aiSvc) // Middleware router.Use(middleware.RequestID()) // Must be first for request tracing @@ -243,6 +228,19 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc * // Public: is Slack OAuth login enabled? (LoginPage uses this to show/hide the button) v1.GET("/auth/slack/config", handlers.GetSlackOAuthConfig(slackConfigRepo)) + // Slack Events API, interactive components, and slash commands. + // Routes are always registered; the handler and signing secret are resolved from DB + // on each request so they activate immediately after config is saved — no restart needed. + slackGroup := v1.Group("/slack", middleware.SlackSignatureVerification(func() string { + if cfg, err := slackConfigRepo.Get(); err == nil && cfg != nil { + return cfg.SigningSecret + } + return "" + })) + slackGroup.POST("/events", handlers.SlackEvents(slackResolver)) + slackGroup.POST("/interactions", handlers.SlackInteractions(slackResolver)) + slackGroup.POST("/commands", handlers.SlackCommands(slackResolver)) + // Local login/logout endpoints (always open — these ARE the auth actions) if localAuth != nil { v1.POST("/auth/login", middleware.RateLimitAuth(), handlers.Login(localAuth)) @@ -412,9 +410,9 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, teamsSvc * // Slack integration config (admin only) settingsGroup.GET("/slack", handlers.GetSlackConfig(slackConfigRepo)) - settingsGroup.POST("/slack", handlers.SaveSlackConfig(slackConfigRepo)) + settingsGroup.POST("/slack", handlers.SaveSlackConfig(slackConfigRepo, slackResolver)) settingsGroup.POST("/slack/test", handlers.TestSlackConfig()) - settingsGroup.DELETE("/slack", handlers.DeleteSlackConfig(slackConfigRepo)) + settingsGroup.DELETE("/slack", handlers.DeleteSlackConfig(slackConfigRepo, slackResolver)) settingsGroup.GET("/slack/members", handlers.ListSlackMembers(slackConfigRepo, userRepo)) // Teams integration config (admin only) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index fa7947b..f7ffd51 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,7 +32,6 @@ type Config struct { // Slack SlackBotToken string SlackSigningSecret string - SlackAppToken string // OpenAI (optional — AI features disabled if APIKey is empty) OpenAIAPIKey string @@ -105,7 +104,6 @@ func Load() (*Config, error) { // Slack SlackBotToken: getEnv("SLACK_BOT_TOKEN", ""), SlackSigningSecret: getEnv("SLACK_SIGNING_SECRET", ""), - SlackAppToken: getEnv("SLACK_APP_TOKEN", ""), // OpenAI OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), diff --git a/backend/internal/models/slack_config.go b/backend/internal/models/slack_config.go index a4c16f7..3a8c8d6 100644 --- a/backend/internal/models/slack_config.go +++ b/backend/internal/models/slack_config.go @@ -12,7 +12,6 @@ type SlackConfig struct { ID int `gorm:"primaryKey;default:1"` BotToken string `gorm:"column:bot_token"` SigningSecret string `gorm:"column:signing_secret"` - AppToken string `gorm:"column:app_token"` WorkspaceID string `gorm:"column:workspace_id"` WorkspaceName string `gorm:"column:workspace_name"` BotUserID string `gorm:"column:bot_user_id"` diff --git a/backend/internal/models/webhooks/generic.go b/backend/internal/models/webhooks/generic.go index ade4874..b4d0dc5 100644 --- a/backend/internal/models/webhooks/generic.go +++ b/backend/internal/models/webhooks/generic.go @@ -161,8 +161,13 @@ func (g *GenericProvider) ParsePayload(body []byte) ([]NormalizedAlert, error) { return nil, fmt.Errorf("invalid generic webhook payload: %w", err) } + // If no alerts array, try interpreting the body as a single flat alert object. if len(payload.Alerts) == 0 { - return nil, fmt.Errorf("generic webhook payload contains no alerts") + var single GenericAlert + if err := json.Unmarshal(body, &single); err != nil || single.Title == "" { + return nil, fmt.Errorf("generic webhook payload contains no alerts") + } + payload.Alerts = []GenericAlert{single} } alerts := make([]NormalizedAlert, 0, len(payload.Alerts)) diff --git a/backend/internal/repository/slack_config_repository.go b/backend/internal/repository/slack_config_repository.go index 41f2b6c..711670a 100644 --- a/backend/internal/repository/slack_config_repository.go +++ b/backend/internal/repository/slack_config_repository.go @@ -38,7 +38,7 @@ func (r *slackConfigRepository) Save(cfg *models.SlackConfig) error { cfg.ID = 1 return r.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{"bot_token", "signing_secret", "app_token", "workspace_id", "workspace_name", "bot_user_id", "oauth_client_id", "oauth_client_secret", "connected_at", "connected_by"}), + DoUpdates: clause.AssignmentColumns([]string{"bot_token", "signing_secret", "workspace_id", "workspace_name", "bot_user_id", "oauth_client_id", "oauth_client_secret", "connected_at", "connected_by"}), }).Create(cfg).Error } diff --git a/backend/internal/services/slack_event_handler.go b/backend/internal/services/slack_event_handler.go index 8f99b25..a05695e 100644 --- a/backend/internal/services/slack_event_handler.go +++ b/backend/internal/services/slack_event_handler.go @@ -14,7 +14,6 @@ import ( "github.com/google/uuid" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" - "github.com/slack-go/slack/socketmode" ) // validSlackTransitions mirrors the state machine in the HTTP handler. @@ -31,11 +30,12 @@ var validSlackTransitions = map[models.IncidentStatus][]models.IncidentStatus{ models.IncidentStatusCanceled: {}, } -// SlackEventHandler listens for Slack events via Socket Mode (WebSocket) and -// dispatches them to the appropriate handlers. Socket Mode avoids needing a -// public URL or SSL certificate — it uses an outbound WebSocket connection. +// SlackEventHandler processes inbound Slack payloads delivered over HTTP +// (Events API, interactive components, slash commands). Each public Handle* +// method is called by the corresponding Gin route handler after the request +// signature has been verified by SlackSignatureMiddleware. type SlackEventHandler struct { - client *socketmode.Client + client *slack.Client incidentService IncidentService chatService ChatService userRepo repository.UserRepository @@ -49,38 +49,29 @@ func (h *SlackEventHandler) SetAIService(ai AIService) { h.aiService = ai } -// NewSlackEventHandler creates a Socket Mode event handler. -// Requires both SLACK_APP_TOKEN (xapp-...) and SLACK_BOT_TOKEN (xoxb-...). +// NewSlackEventHandler creates an HTTP-based Slack event handler. +// Requires SLACK_BOT_TOKEN (xoxb-...) for Web API calls. func NewSlackEventHandler( - appToken string, botToken string, incidentService IncidentService, chatService ChatService, userRepo repository.UserRepository, pmRepo repository.PostMortemRepository, ) (*SlackEventHandler, error) { - if appToken == "" { - return nil, fmt.Errorf("SLACK_APP_TOKEN is required for Socket Mode") - } if botToken == "" { - return nil, fmt.Errorf("SLACK_BOT_TOKEN is required for Socket Mode") + return nil, fmt.Errorf("SLACK_BOT_TOKEN is required") } - api := slack.New( - botToken, - slack.OptionAppLevelToken(appToken), - ) - - client := socketmode.New(api) + client := slack.New(botToken) // Identify the bot's own user ID so we can filter out its messages // (prevents echo loops when the bot posts status updates) - auth, err := api.AuthTest() + auth, err := client.AuthTest() if err != nil { return nil, fmt.Errorf("slack auth failed: %w", err) } - slog.Info("slack socket mode initialized", + slog.Info("slack http event handler initialized", "bot_id", auth.BotID, "bot_user_id", auth.UserID, "team", auth.Team, @@ -96,27 +87,10 @@ func NewSlackEventHandler( }, nil } -// Start begins the Socket Mode connection and event listener in background goroutines. -// It returns immediately; events are processed asynchronously. -func (h *SlackEventHandler) Start() { - go h.listen() - go func() { - if err := h.client.Run(); err != nil { - slog.Error("slack socket mode client stopped", "error", err) - } - }() -} - -// handleInteraction handles block action button clicks and modal submissions. -func (h *SlackEventHandler) handleInteraction(evt socketmode.Event) { - slog.Info("slack interaction event received", "data_type", fmt.Sprintf("%T", evt.Data)) - callback, ok := evt.Data.(slack.InteractionCallback) - if !ok { - slog.Warn("slack interaction: unexpected data type, skipping") - h.client.Ack(*evt.Request) - return - } - h.client.Ack(*evt.Request) +// HandleInteraction handles block action button clicks and modal submissions. +// Called by the HTTP route handler after signature verification; the HTTP 200 +// response is the implicit ACK (no socketmode Ack call needed). +func (h *SlackEventHandler) HandleInteraction(callback slack.InteractionCallback) { slog.Info("slack interaction callback", "type", callback.Type, "user_id", callback.User.ID, "channel", callback.Channel.ID) switch callback.Type { @@ -220,16 +194,11 @@ func isValidSlackTransition(current, target models.IncidentStatus) bool { return false } -// handleSlashCommand handles /incident slash commands. -// Supported: /incident new [title], /incident list, /incident help -func (h *SlackEventHandler) handleSlashCommand(evt socketmode.Event) { - cmd, ok := evt.Data.(slack.SlashCommand) - if !ok { - h.client.Ack(*evt.Request) - return - } - h.client.Ack(*evt.Request) - +// HandleCommand handles /regen slash commands. +// Called by the HTTP route handler after signature verification; the HTTP 200 +// response is the implicit ACK. +// Supported: /regen new [title], /regen list, /regen help +func (h *SlackEventHandler) HandleCommand(cmd slack.SlashCommand) { parts := strings.Fields(cmd.Text) if len(parts) == 0 { h.sendHelpResponse(cmd) @@ -264,7 +233,7 @@ func (h *SlackEventHandler) handleSlashCommand(evt socketmode.Event) { } // openCreateIncidentModal opens a Block Kit modal for declaring a new incident. -// Pre-fills the title from the text after "new" (e.g. /incident new High CPU). +// Pre-fills the title from the text after "new" (e.g. /regen new High CPU). func (h *SlackEventHandler) openCreateIncidentModal(cmd slack.SlashCommand) { prefillTitle := strings.TrimSpace(strings.TrimPrefix(cmd.Text, "new")) @@ -390,26 +359,20 @@ func (h *SlackEventHandler) sendHelpResponse(cmd slack.SlashCommand) { h.postEphemeral(cmd.ChannelID, cmd.UserID, "*Fluidify Regen Slash Commands:*\n\n"+ "*Declare & Browse*\n"+ - "• `/incident new [title]` — Declare a new incident (opens form)\n"+ - "• `/incident list` — List open incidents\n\n"+ + "• `/regen new [title]` — Declare a new incident (opens form)\n"+ + "• `/regen list` — List open incidents\n\n"+ "*In an Incident Channel*\n"+ - "• `/incident ack` — Acknowledge this incident\n"+ - "• `/incident resolve` — Resolve this incident\n"+ - "• `/incident status` — Show incident status\n"+ - "• `/incident note ` — Add a timeline note (opens form if no text)\n"+ - "• `/incident lead [me|@user]` — Assign incident commander\n\n"+ - "• `/incident help` — Show this message") + "• `/regen ack` — Acknowledge this incident\n"+ + "• `/regen resolve` — Resolve this incident\n"+ + "• `/regen status` — Show incident status\n"+ + "• `/regen note ` — Add a timeline note (opens form if no text)\n"+ + "• `/regen lead [me|@user]` — Assign incident commander\n\n"+ + "• `/regen help` — Show this message") } -// handleEventsAPI handles Events API payloads (message events, etc.). -func (h *SlackEventHandler) handleEventsAPI(evt socketmode.Event) { - eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) - if !ok { - h.client.Ack(*evt.Request) - return - } - h.client.Ack(*evt.Request) - +// HandleEventsAPI dispatches an inbound Events API payload to the appropriate +// handler. Called by the HTTP route handler after signature verification. +func (h *SlackEventHandler) HandleEventsAPI(eventsAPIEvent slackevents.EventsAPIEvent) { slog.Info("slack events api event received", "type", eventsAPIEvent.InnerEvent.Type) switch ev := eventsAPIEvent.InnerEvent.Data.(type) { case *slackevents.MessageEvent: @@ -609,7 +572,7 @@ func (h *SlackEventHandler) showIncidentStatus(cmd slack.SlashCommand) { h.postEphemeral(cmd.ChannelID, cmd.UserID, msg) } -// assignLeadFromSlash assigns an incident commander via /incident lead [me|@user]. +// assignLeadFromSlash assigns an incident commander via /regen lead [me|@user]. // targetArg="" or "me" → self; "<@UXXXXXX>" or "<@UXXXXXX|name>" → that user. func (h *SlackEventHandler) assignLeadFromSlash(cmd slack.SlashCommand, targetArg string) { incident, err := h.getIncidentFromChannel(cmd.ChannelID) @@ -624,7 +587,7 @@ func (h *SlackEventHandler) assignLeadFromSlash(cmd slack.SlashCommand, targetAr slackUserID = parseSlackMention(targetArg) if slackUserID == "" { h.postEphemeral(cmd.ChannelID, cmd.UserID, - "Could not parse user. Usage: `/incident lead` or `/incident lead @username`") + "Could not parse user. Usage: `/regen lead` or `/regen lead @username`") return } } @@ -798,15 +761,15 @@ func (h *SlackEventHandler) handleViewCommands(callback slack.InteractionCallbac h.postEphemeral(callback.Channel.ID, callback.User.ID, "*Fluidify Regen Slash Commands:*\n\n"+ "*Declare & Browse*\n"+ - "• `/incident new [title]` — Declare a new incident (opens form)\n"+ - "• `/incident list` — List open incidents\n\n"+ + "• `/regen new [title]` — Declare a new incident (opens form)\n"+ + "• `/regen list` — List open incidents\n\n"+ "*In an Incident Channel*\n"+ - "• `/incident ack` — Acknowledge this incident\n"+ - "• `/incident resolve` — Resolve this incident\n"+ - "• `/incident status` — Show incident status\n"+ - "• `/incident note ` — Add a timeline note (opens form if no text)\n"+ - "• `/incident lead [me|@user]` — Assign incident commander\n\n"+ - "• `/incident help` — Show this message") + "• `/regen ack` — Acknowledge this incident\n"+ + "• `/regen resolve` — Resolve this incident\n"+ + "• `/regen status` — Show incident status\n"+ + "• `/regen note ` — Add a timeline note (opens form if no text)\n"+ + "• `/regen lead [me|@user]` — Assign incident commander\n\n"+ + "• `/regen help` — Show this message") } // ── Shared utilities ───────────────────────────────────────────────────────── @@ -926,7 +889,7 @@ func (h *SlackEventHandler) handleAppMention(ev *slackevents.AppMentionEvent) { incident, err := h.incidentService.GetIncidentBySlackChannelID(ev.Channel) if err != nil || incident == nil { // Not an incident channel — give a generic help response - _, _ = h.postToThread(ev.Channel, ev.TimeStamp, "*Fluidify Regen* here! I respond to questions in incident channels. Use `/incident new` to create an incident.") + _, _ = h.postToThread(ev.Channel, ev.TimeStamp, "*Fluidify Regen* here! I respond to questions in incident channels. Use `/regen new` to create an incident.") return } @@ -1049,8 +1012,7 @@ func (h *SlackEventHandler) handleReactionAdded(ev *slackevents.ReactionAddedEve } func (h *SlackEventHandler) postToThread(channelID, threadTS, text string) (string, error) { - api := h.client.Client - _, msgTS, err := api.PostMessage( + _, msgTS, err := h.client.PostMessage( channelID, slack.MsgOptionText(text, false), slack.MsgOptionTS(threadTS), @@ -1063,26 +1025,6 @@ func (h *SlackEventHandler) postToThread(channelID, threadTS, text string) (stri // deleteMessage deletes a message by channel and timestamp. func (h *SlackEventHandler) deleteMessage(channelID, msgTS string) error { - _, _, err := h.client.Client.DeleteMessage(channelID, msgTS) + _, _, err := h.client.DeleteMessage(channelID, msgTS) return err } - -// listen processes events from the Socket Mode channel. -func (h *SlackEventHandler) listen() { - for evt := range h.client.Events { - switch evt.Type { - case socketmode.EventTypeConnecting: - slog.Info("slack socket mode connecting...") - case socketmode.EventTypeConnectionError: - slog.Warn("slack socket mode connection error, will retry") - case socketmode.EventTypeConnected: - slog.Info("slack socket mode connected - bidirectional sync active") - case socketmode.EventTypeInteractive: - h.handleInteraction(evt) - case socketmode.EventTypeSlashCommand: - h.handleSlashCommand(evt) - case socketmode.EventTypeEventsAPI: - h.handleEventsAPI(evt) - } - } -} diff --git a/backend/internal/services/slack_handler_resolver.go b/backend/internal/services/slack_handler_resolver.go new file mode 100644 index 0000000..a19b414 --- /dev/null +++ b/backend/internal/services/slack_handler_resolver.go @@ -0,0 +1,80 @@ +package services + +import ( + "sync" + + "github.com/FluidifyAI/Regen/backend/internal/repository" +) + +// SlackHandlerResolver lazily initializes the SlackEventHandler from DB config. +// Routes are always registered; the handler is created on first successful request +// after Slack is configured — no server restart needed. +type SlackHandlerResolver struct { + mu sync.RWMutex + handler *SlackEventHandler + repo repository.SlackConfigRepository + incidentSvc IncidentService + chatService ChatService + userRepo repository.UserRepository + pmRepo repository.PostMortemRepository + aiSvc AIService +} + +func NewSlackHandlerResolver( + repo repository.SlackConfigRepository, + incidentSvc IncidentService, + chatService ChatService, + userRepo repository.UserRepository, + pmRepo repository.PostMortemRepository, + aiSvc AIService, +) *SlackHandlerResolver { + return &SlackHandlerResolver{ + repo: repo, + incidentSvc: incidentSvc, + chatService: chatService, + userRepo: userRepo, + pmRepo: pmRepo, + aiSvc: aiSvc, + } +} + +// Get returns the cached handler, or tries to initialize one from the current DB config. +// Returns nil if Slack is not yet configured or the token is invalid. +func (r *SlackHandlerResolver) Get() *SlackEventHandler { + r.mu.RLock() + h := r.handler + r.mu.RUnlock() + if h != nil { + return h + } + return r.tryInit() +} + +// Invalidate clears the cached handler so the next request re-reads from DB. +// Call this after Slack config is saved or deleted. +func (r *SlackHandlerResolver) Invalidate() { + r.mu.Lock() + r.handler = nil + r.mu.Unlock() +} + +func (r *SlackHandlerResolver) tryInit() *SlackEventHandler { + if r.chatService == nil { + return nil + } + cfg, err := r.repo.Get() + if err != nil || cfg == nil || cfg.BotToken == "" { + return nil + } + h, err := NewSlackEventHandler(cfg.BotToken, r.incidentSvc, r.chatService, r.userRepo, r.pmRepo) + if err != nil { + return nil + } + if r.aiSvc != nil { + h.SetAIService(r.aiSvc) + } + r.mu.Lock() + r.handler = h + r.mu.Unlock() + return h +} diff --git a/backend/migrations/000039_slack_drop_app_token.down.sql b/backend/migrations/000039_slack_drop_app_token.down.sql new file mode 100644 index 0000000..5aba94a --- /dev/null +++ b/backend/migrations/000039_slack_drop_app_token.down.sql @@ -0,0 +1 @@ +ALTER TABLE slack_config ADD COLUMN IF NOT EXISTS app_token TEXT; diff --git a/backend/migrations/000039_slack_drop_app_token.up.sql b/backend/migrations/000039_slack_drop_app_token.up.sql new file mode 100644 index 0000000..d7989da --- /dev/null +++ b/backend/migrations/000039_slack_drop_app_token.up.sql @@ -0,0 +1 @@ +ALTER TABLE slack_config DROP COLUMN IF EXISTS app_token; diff --git a/frontend/src/api/slack.ts b/frontend/src/api/slack.ts index a214a13..1f0eef9 100644 --- a/frontend/src/api/slack.ts +++ b/frontend/src/api/slack.ts @@ -6,7 +6,6 @@ export interface SlackConfigStatus { workspace_name?: string bot_user_id?: string has_bot_token: boolean - has_app_token: boolean has_oauth_config: boolean connected_at?: string } @@ -21,7 +20,6 @@ export interface SlackTestResult { export interface SaveSlackConfigRequest { bot_token: string signing_secret: string - app_token?: string workspace_id?: string workspace_name?: string bot_user_id?: string diff --git a/frontend/src/components/SlackSetupModal.tsx b/frontend/src/components/SlackSetupModal.tsx index 2fb4ca8..e9c75bf 100644 --- a/frontend/src/components/SlackSetupModal.tsx +++ b/frontend/src/components/SlackSetupModal.tsx @@ -13,12 +13,6 @@ interface Props { } function slackManifest(appUrl: string): string { - // Slack requires HTTPS for webhook URLs. For local dev (http:// or localhost), - // we use Socket Mode which doesn't need public URLs. - const isLocal = - appUrl.startsWith('http://') || appUrl.includes('localhost') || appUrl.includes('127.0.0.1') - const useSocketMode = isLocal - const manifest: Record = { _metadata: { major_version: 1, @@ -26,27 +20,24 @@ function slackManifest(appUrl: string): string { }, display_information: { name: 'Fluidify Regen', - description: 'Incident management — alert routing, on-call scheduling, and Slack coordination', - background_color: '#ffffff', - long_description: - 'Fluidify Regen is an open-source incident management platform. This bot creates dedicated Slack channels for each incident, posts status updates, and accepts /incident commands for managing incidents directly from Slack.', + description: 'Incident management for Slack', + background_color: '#1800ad', }, features: { app_home: { - home_tab_enabled: false, - messages_tab_enabled: true, + home_tab_enabled: true, + messages_tab_enabled: false, messages_tab_read_only_enabled: false, }, bot_user: { display_name: 'Fluidify Regen', - always_online: true, }, slash_commands: [ { - command: '/incident', - ...(useSocketMode ? {} : { url: `${appUrl}/api/v1/webhooks/slack/commands` }), - description: 'Manage incidents from Slack', - usage_hint: 'new [title] | ack | resolve | status', + command: '/regen', + url: `${appUrl}/api/v1/slack/commands`, + description: 'Manage incidents — new, ack, resolve, status, note, lead, list', + usage_hint: 'new [title] | ack | resolve | status | note | lead [me|@user] | list | help', should_escape: false, }, ], @@ -55,47 +46,32 @@ function slackManifest(appUrl: string): string { redirect_urls: [`${appUrl}/api/v1/auth/slack/callback`], scopes: { bot: [ + 'commands', + 'app_mentions:read', 'channels:history', 'channels:manage', 'channels:read', 'channels:write.invites', 'chat:write', 'chat:write.public', - 'commands', - 'groups:history', - 'groups:read', - 'groups:write', - 'groups:write.invites', 'im:write', - 'mpim:write', + 'reactions:read', 'users:read', 'users:read.email', ], - user: ['openid', 'email', 'profile'], }, }, settings: { - ...(useSocketMode - ? { - socket_mode_enabled: true, - } - : { - event_subscriptions: { - request_url: `${appUrl}/api/v1/webhooks/slack/events`, - bot_events: [ - 'message.channels', - 'message.groups', - 'app_mention', - 'member_joined_channel', - ], - }, - interactivity: { - is_enabled: true, - request_url: `${appUrl}/api/v1/webhooks/slack/interactive`, - }, - socket_mode_enabled: false, - }), + event_subscriptions: { + request_url: `${appUrl}/api/v1/slack/events`, + bot_events: ['app_mention', 'message.channels', 'reaction_added'], + }, + interactivity: { + is_enabled: true, + request_url: `${appUrl}/api/v1/slack/interactions`, + }, org_deploy_enabled: false, + socket_mode_enabled: false, token_rotation_enabled: false, }, } @@ -116,7 +92,6 @@ export function SlackSetupModal({ onClose, onConnected }: Props) { const [botToken, setBotToken] = useState('') const [signingSecret, setSigningSecret] = useState('') - const [appToken, setAppToken] = useState('') const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState(null) const [testError, setTestError] = useState('') @@ -157,7 +132,6 @@ export function SlackSetupModal({ onClose, onConnected }: Props) { const req: SaveSlackConfigRequest = { bot_token: botToken, signing_secret: signingSecret, - app_token: appToken || undefined, workspace_id: testResult?.workspace_id, workspace_name: testResult?.workspace_name, bot_user_id: testResult?.bot_user_id, @@ -318,9 +292,7 @@ export function SlackSetupModal({ onClose, onConnected }: Props) { Bot Token *

- Sidebar → OAuth & Permissions → scroll to{' '} - OAuth Tokens section → copy{' '} - Bot User OAuth Token (starts with xoxb-) + OAuth & PermissionsInstall to Workspace → copy Bot User OAuth Token (starts with xoxb-)

- {/* App-Level Token (Socket Mode) */} -
- -

- ⚠️ Without this token, the Make me Lead and Add Note buttons in Slack will show an error when clicked. -

-

- In your Slack app: Basic InformationApp-Level Tokens → - click Generate Token and Scopes → add{' '} - connections:write scope - → copy the token (starts with xapp-) -

- setAppToken(e.target.value)} - placeholder="xapp-..." - className={inputClass} - /> -
- - +
+

OAuth Redirect URL

+
+ + {appUrl}/api/v1/auth/slack/callback + +
- )} +
*

- OAuth & PermissionsBot User OAuth Token (starts with xoxb-) + OAuth & PermissionsInstall to Workspace → copy Bot User OAuth Token (starts with xoxb-)

setBotToken(e.target.value)} placeholder="xoxb-..." className={inputClass} /> @@ -269,19 +253,6 @@ export function WizardStepSlack({ onComplete, onSkip }: Props) { -
- -

- Without this token, the Make me Lead and Add Note buttons in Slack will error when clicked. -

-

- Basic InformationApp-Level Tokens → generate with connections:write (starts with xapp-) -

- setAppToken(e.target.value)} placeholder="xapp-..." className={inputClass} /> -
-