From 5a8d7eeb3e4896d4688a61257e0483923a5f0a8f Mon Sep 17 00:00:00 2001 From: singret <100959986+singret@users.noreply.github.com> Date: Tue, 26 May 2026 04:40:04 +0000 Subject: [PATCH 1/2] feat(slack): migrate from Socket Mode to HTTP Events API (OPE-173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Socket Mode WebSocket transport with standard Slack HTTP endpoints, making the app Marketplace-compatible and fixing the "Enable Events" toggle problem that prevented bi-directional sync. Backend: - SlackEventHandler now holds *slack.Client (plain Web API); socketmode package and Start()/listen() goroutines removed entirely - Three new public methods: HandleEventsAPI, HandleInteraction, HandleCommand replace the socketmode event loop — all existing business logic unchanged - SlackSignatureVerification middleware updated to accept the signing secret as a parameter (DB-stored, not env var) - Three new Gin routes: POST /api/v1/slack/{events,interactions,commands} each protected by HMAC-SHA256 signature verification - url_verification challenge handled in /slack/events for initial URL setup - Migration 000039: drops app_token column from slack_config - SLACK_APP_TOKEN (xapp-...) no longer required or stored Frontend: - App Token field removed from SlackSetupModal and WizardStepSlack - has_app_token and app_token fields removed from API types Manifest: - socket_mode_enabled: false - request_url added to event_subscriptions, interactivity, and slash command - YOUR-INSTANCE placeholder replaces hardcoded example URL - Incorporates OPE-169 scope fixes (app_mentions:read, reactions:read, users:read.email, app_mention and reaction_added events) --- backend/internal/api/handlers/slack_config.go | 4 - backend/internal/api/handlers/slack_http.go | 118 ++++++++++++++++++ .../api/middleware/slack_signature.go | 28 +++-- .../api/middleware/slack_signature_test.go | 17 ++- backend/internal/api/routes.go | 33 +++-- backend/internal/models/slack_config.go | 1 - .../internal/services/slack_event_handler.go | 106 ++++------------ .../000039_slack_drop_app_token.down.sql | 1 + .../000039_slack_drop_app_token.up.sql | 1 + frontend/src/api/slack.ts | 2 - frontend/src/components/SlackSetupModal.tsx | 26 ---- .../components/onboarding/WizardStepSlack.tsx | 15 --- slack-app-manifest.yaml | 35 +++--- 13 files changed, 216 insertions(+), 171 deletions(-) create mode 100644 backend/internal/api/handlers/slack_http.go create mode 100644 backend/migrations/000039_slack_drop_app_token.down.sql create mode 100644 backend/migrations/000039_slack_drop_app_token.up.sql diff --git a/backend/internal/api/handlers/slack_config.go b/backend/internal/api/handlers/slack_config.go index 9df1b40..a209d9f 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, } @@ -57,7 +55,6 @@ func SaveSlackConfig(repo repository.SlackConfigRepository) gin.HandlerFunc { 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 +80,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, diff --git a/backend/internal/api/handlers/slack_http.go b/backend/internal/api/handlers/slack_http.go new file mode 100644 index 0000000..67d45cc --- /dev/null +++ b/backend/internal/api/handlers/slack_http.go @@ -0,0 +1,118 @@ +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(handler *services.SlackEventHandler) 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. + 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 + } + + 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) + handler.HandleEventsAPI(eventsAPIEvent) + } +} + +// SlackInteractions handles POST /api/v1/slack/interactions — button clicks and modal submissions. +func SlackInteractions(handler *services.SlackEventHandler) 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 + } + + // 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(handler *services.SlackEventHandler) 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 + } + + 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..a57cde5 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,7 +25,10 @@ const ( SlackSignatureVersion = "v0" ) -// SlackSignatureVerification returns a middleware that verifies Slack request signatures +// SlackSignatureVerification returns a middleware that verifies Slack request signatures. +// signingSecret is read from the DB-stored Slack config and passed in at route registration +// time. If empty (Slack not yet configured), requests are allowed through with a warning — +// dev/test only; in production the secret is always set before the routes are registered. // // This implements Slack's signature verification algorithm: // https://api.slack.com/authentication/verifying-requests-from-slack @@ -39,14 +41,12 @@ const ( // // Usage: // -// slackRoutes.Use(middleware.SlackSignatureVerification()) -func SlackSignatureVerification() gin.HandlerFunc { - signingSecret := os.Getenv("SLACK_SIGNING_SECRET") - +// slackRoutes.Use(middleware.SlackSignatureVerification(cfg.SigningSecret)) +func SlackSignatureVerification(signingSecret string) gin.HandlerFunc { // 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") + slog.Warn("Slack signing secret not configured - signature verification disabled") return func(c *gin.Context) { c.Next() } @@ -141,6 +141,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 +176,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..0391041 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(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..94e812f 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -108,23 +108,24 @@ 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, + // Bootstrap Slack HTTP event handler (bidirectional sync) if bot token is configured. + // The handler is wired to /api/v1/slack/* routes below; no persistent connection needed. + var slackEventHandler *services.SlackEventHandler + if slackCfg, err := slackConfigRepo.Get(); err == nil && + slackCfg != nil && slackCfg.BotToken != "" && chatService != nil { + h, err := services.NewSlackEventHandler( + slackCfg.BotToken, incidentSvc, chatService, userRepo, pmRepo, ) if err != nil { - slog.Error("failed to initialize slack socket mode", "error", err) + slog.Error("failed to initialize slack event handler", "error", err) slog.Warn("bidirectional Slack sync disabled - Slack will be one-way only") } else { - eventHandler.SetAIService(aiSvc) - eventHandler.Start() + h.SetAIService(aiSvc) + slackEventHandler = h } } @@ -243,6 +244,20 @@ 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. + // Signature verification runs before each handler; the group is open (no JWT) + // because Slack can't send auth tokens — it authenticates via HMAC signature. + if slackEventHandler != nil { + signingSecret := "" + if slackCfg, err := slackConfigRepo.Get(); err == nil && slackCfg != nil { + signingSecret = slackCfg.SigningSecret + } + slackGroup := v1.Group("/slack", middleware.SlackSignatureVerification(signingSecret)) + slackGroup.POST("/events", handlers.SlackEvents(slackEventHandler)) + slackGroup.POST("/interactions", handlers.SlackInteractions(slackEventHandler)) + slackGroup.POST("/commands", handlers.SlackCommands(slackEventHandler)) + } + // Local login/logout endpoints (always open — these ARE the auth actions) if localAuth != nil { v1.POST("/auth/login", middleware.RateLimitAuth(), handlers.Login(localAuth)) 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/services/slack_event_handler.go b/backend/internal/services/slack_event_handler.go index 8f99b25..5b8eb42 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. +// HandleCommand handles /incident slash commands. +// Called by the HTTP route handler after signature verification; the HTTP 200 +// response is the implicit ACK. // 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) - +func (h *SlackEventHandler) HandleCommand(cmd slack.SlashCommand) { parts := strings.Fields(cmd.Text) if len(parts) == 0 { h.sendHelpResponse(cmd) @@ -401,15 +370,9 @@ func (h *SlackEventHandler) sendHelpResponse(cmd slack.SlashCommand) { "• `/incident 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: @@ -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/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..9ea79d2 100644 --- a/frontend/src/components/SlackSetupModal.tsx +++ b/frontend/src/components/SlackSetupModal.tsx @@ -116,7 +116,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 +156,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, @@ -392,30 +390,6 @@ export function SlackSetupModal({ onClose, onConnected }: Props) { - {/* 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} /> diff --git a/slack-app-manifest.yaml b/slack-app-manifest.yaml deleted file mode 100644 index fc6250c..0000000 --- a/slack-app-manifest.yaml +++ /dev/null @@ -1,86 +0,0 @@ -# Fluidify Regen — Slack App Manifest -# -# How to use: -# 1. Replace YOUR-INSTANCE below with your Regen URL (e.g. https://regen.example.com) -# 2. Go to https://api.slack.com/apps -# 3. Click "Create New App" → "From a manifest" -# 4. Select your workspace -# 5. Paste this entire file (YAML tab) and click Next → Create -# 6. From "Basic Information", copy: -# - Signing Secret → paste into Regen Settings → Integrations → Slack -# 7. From "OAuth & Permissions" → Install to Workspace, then copy: -# - Bot User OAuth Token (xoxb-...) → paste into Regen Settings → Integrations → Slack -# -# Full setup guide: https://github.com/FluidifyAI/Regen/blob/main/README.md - -_metadata: - major_version: 1 - minor_version: 1 - -display_information: - name: Fluidify Regen - description: Incident management — create, acknowledge, and resolve incidents without leaving Slack. - background_color: "#1800ad" - long_description: | - Fluidify Regen brings your full incident lifecycle into Slack. - - When an alert fires, Regen automatically creates a dedicated incident channel, - posts a status card, and keeps your timeline in sync as the team responds. - - Use /incident to declare incidents, assign commanders, add timeline notes, and - resolve — all without switching tools. - - Self-hosted. Open source. No per-seat fees. - -features: - app_home: - home_tab_enabled: false - messages_tab_enabled: false - messages_tab_read_only_enabled: false - - bot_user: - display_name: Fluidify Regen - always_online: true - - slash_commands: - - command: /incident - url: https://YOUR-INSTANCE/api/v1/slack/commands - description: Manage incidents — new, ack, resolve, status, note, lead, list - usage_hint: "new [title] | ack | resolve | status | note [text] | lead [me|@user] | list | help" - should_escape: false - -oauth_config: - redirect_urls: - - https://YOUR-INSTANCE/api/v1/auth/slack/callback - scopes: - bot: - - channels:manage # Create and archive incident channels - - channels:read # List channels (duplicate prevention) - - channels:history # Read channel messages → timeline sync - - chat:write # Post messages and status updates - - chat:write.public # Post to public channels without joining - - users:read # Resolve user display names for timeline - - users:read.email # Email lookup for Slack → Regen account linking - - im:write # Send DMs to on-call responders - - app_mentions:read # Required for app_mention events (@regen bot) - - reactions:read # Required for reaction_added events (✅ ack, 🔴 resolve) - user: - - openid # Slack SSO login - - email # Match Slack user to Regen account - - profile # Display name for login - -settings: - event_subscriptions: - request_url: https://YOUR-INSTANCE/api/v1/slack/events - bot_events: - - message.channels # Sync channel messages → incident timeline - - app_mention # @regen bot mentions → AI answer in thread - - reaction_added # ✅ white_check_mark → ack, 🔴 red_circle → resolve - - interactivity: - is_enabled: true - request_url: https://YOUR-INSTANCE/api/v1/slack/interactions - - org_deploy_enabled: false - socket_mode_enabled: false # HTTP Events API — no WebSocket connection required - token_rotation_enabled: false