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}`); });