Spaces:
Running
Running
| import http from "http"; | |
| import crypto from "crypto"; | |
| import querystring from "querystring"; | |
| import { handleSlackCommand, type SlackCommand } from "./slack/slack-commands"; | |
| import { getEnv } from "./shared/config/env"; | |
| import { logger } from "./shared/utils/logger"; | |
| // Initialize environment | |
| const env = getEnv(); | |
| const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 7860; | |
| /** | |
| * Verify Slack request signature to ensure it comes from Slack. | |
| */ | |
| function verifySlackSignature( | |
| timestamp: string, | |
| rawBody: string, | |
| signature: string, | |
| signingSecret: string | |
| ): boolean { | |
| if (!signingSecret) { | |
| logger.warn("SLACK_SIGNING_SECRET is not configured. Skipping signature verification."); | |
| return true; | |
| } | |
| // Prevent replay attacks (5 minute threshold) | |
| const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5; | |
| if (parseInt(timestamp, 10) < fiveMinutesAgo) { | |
| logger.warn("Slack request timestamp is too old. Potential replay attack."); | |
| return false; | |
| } | |
| const sigBaseString = `v0:${timestamp}:${rawBody}`; | |
| const mySignature = "v0=" + crypto | |
| .createHmac("sha256", signingSecret) | |
| .update(sigBaseString, "utf8") | |
| .digest("hex"); | |
| try { | |
| return crypto.timingSafeEqual(Buffer.from(mySignature, "utf8"), Buffer.from(signature, "utf8")); | |
| } catch (err) { | |
| return false; | |
| } | |
| } | |
| // Start HTTP Server | |
| const server = http.createServer((req, res) => { | |
| if (req.method === "POST" && req.url === "/slack/commands") { | |
| let body = ""; | |
| req.on("data", (chunk) => { | |
| body += chunk.toString(); | |
| }); | |
| req.on("end", async () => { | |
| const timestamp = req.headers["x-slack-request-timestamp"] as string; | |
| const signature = req.headers["x-slack-signature"] as string; | |
| const signingSecret = env.SLACK_SIGNING_SECRET; | |
| // Verify signature | |
| if (signingSecret && (!timestamp || !signature || !verifySlackSignature(timestamp, body, signature, signingSecret))) { | |
| logger.error("Slack signature verification failed."); | |
| res.writeHead(401, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Unauthorized request signature" })); | |
| return; | |
| } | |
| try { | |
| const parsed = querystring.parse(body); | |
| const commandStr = parsed.command as string; | |
| const textStr = (parsed.text as string) || ""; | |
| const userIdStr = parsed.user_id as string; | |
| const channelIdStr = parsed.channel_id as string; | |
| logger.info({ command: commandStr, text: textStr, userId: userIdStr }, "📬 Received Slack command"); | |
| const cmd: SlackCommand = { | |
| command: commandStr, | |
| text: textStr, | |
| userId: userIdStr, | |
| channelId: channelIdStr, | |
| }; | |
| // Call the command handler | |
| const replyText = await handleSlackCommand(cmd); | |
| // Send response back to Slack | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| response_type: "in_channel", // Make it visible in the channel | |
| text: replyText, | |
| }) | |
| ); | |
| } catch (err: any) { | |
| logger.error({ err }, "Error processing Slack command"); | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end( | |
| JSON.stringify({ | |
| response_type: "ephemeral", | |
| text: `❌ Error executing command: ${err.message || err}`, | |
| }) | |
| ); | |
| } | |
| }); | |
| } else if (req.method === "POST" && req.url === "/slack/events") { | |
| let body = ""; | |
| req.on("data", (chunk) => { | |
| body += chunk.toString(); | |
| }); | |
| req.on("end", async () => { | |
| const timestamp = req.headers["x-slack-request-timestamp"] as string; | |
| const signature = req.headers["x-slack-signature"] as string; | |
| const signingSecret = env.SLACK_SIGNING_SECRET; | |
| // Verify signature | |
| if (signingSecret && (!timestamp || !signature || !verifySlackSignature(timestamp, body, signature, signingSecret))) { | |
| logger.error("Slack event signature verification failed."); | |
| res.writeHead(401, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Unauthorized request signature" })); | |
| return; | |
| } | |
| try { | |
| const payload = JSON.parse(body); | |
| // 1. Handle URL Verification Challenge from Slack | |
| if (payload.type === "url_verification") { | |
| logger.info("Handling Slack URL verification challenge"); | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ challenge: payload.challenge })); | |
| return; | |
| } | |
| // 2. Handle Event Callback | |
| if (payload.type === "event_callback") { | |
| const event = payload.event; | |
| if (event) { | |
| // Ignore bot messages to prevent infinite loops | |
| const isBot = event.bot_id || event.subtype === "bot_message" || !event.user; | |
| if (isBot) { | |
| res.writeHead(200); | |
| res.end(); | |
| return; | |
| } | |
| const channelType = event.channel_type; | |
| const eventType = event.type; | |
| // We handle DMs (message.im or channel ID starts with 'D') or direct mentions (app_mention) | |
| const isDM = eventType === "message" && (channelType === "im" || event.channel.startsWith("D")); | |
| const isMention = eventType === "app_mention"; | |
| if (isDM || isMention) { | |
| let text = event.text || ""; | |
| // Strip bot tag if it's a mention | |
| if (isMention) { | |
| text = text.replace(/<@U[A-Z0-9]+>/g, "").trim(); | |
| } | |
| const userId = event.user; | |
| const channelId = event.channel; | |
| const threadTs = event.thread_ts || event.ts; | |
| logger.info( | |
| { eventType, channelId, userId, text }, | |
| "💬 Received natural language chatbot event" | |
| ); | |
| // Respond HTTP 200 OK to Slack immediately to prevent retries (timeout threshold is 3s) | |
| res.writeHead(200); | |
| res.end(); | |
| // Route to natural language agent in background | |
| const { handleSlackChat } = await import("./slack/slack-agent"); | |
| handleSlackChat(text, userId, channelId, threadTs).catch((err) => { | |
| logger.error({ err }, "Error in handleSlackChat background worker"); | |
| }); | |
| return; | |
| } | |
| } | |
| } | |
| // Default response for unhandled events | |
| res.writeHead(200); | |
| res.end(); | |
| } catch (err: any) { | |
| logger.error({ err }, "Error processing Slack event"); | |
| res.writeHead(500, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: err.message || err })); | |
| } | |
| }); | |
| } else { | |
| // Healthcheck or default route | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ status: "healthy", service: "slack-command-listener" })); | |
| } | |
| }); | |
| server.listen(PORT, "0.0.0.0", () => { | |
| logger.info(`🔌 Slack Command Listener server running on port ${PORT}`); | |
| }); | |