Spaces:
Running
Running
File size: 7,207 Bytes
5f138d4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | 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) : 3000;
/**
* 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}`);
});
|