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 & Permissions → Install 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 Information → App-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}
- />
-
-
- {isLocal && (
-
-
Local environment detected
-
- The manifest uses Socket Mode (no public URLs needed) — ideal for localhost.
- For production, deploy to an HTTPS URL and re-run this step.
-
-
- )}
-
- Click the button below to open Slack's App Portal
- Select your workspace from the dropdown
- Review the pre-filled settings and click "Create"
- Go to Settings → Install App and install to your workspace
- {isLocal && (
- -
- Go to Settings → Basic Information → App-Level Tokens →
- generate a token with
connections:write scope
-
- )}
- {!isLocal && (
-
@@ -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 Information → App-Level Tokens → generate with connections:write (starts with xapp-)
-
-
setAppToken(e.target.value)} placeholder="xapp-..." className={inputClass} />
-
-