clienttarget / src /index.ts
iDevBuddy
fix: Default fallback port to 7860 for Hugging Face Spaces compatibility
1dc857d
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}`);
});