Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180import { type SlackEvent } from "@slack/web-api";import { timingSafeEqual } from "node:crypto";import type { JsonSchemaToTsProvider } from "@fastify/type-provider-json-schema-to-ts";import Fastify, { type FastifyRequest, type FastifyServerOptions } from "fastify";import type { EventsClient } from "./events/events.ts";import type { SlackClient } from "./slack/slack.ts";import { newCommandHandler } from "./commands/commands.ts";
export interface ServerDeps { eventsClient: EventsClient; slackClient: SlackClient; inviteWebhookToken: string; inviteChannel: string;}
declare module "fastify" { interface FastifyRequest { rawBody?: string; }}
export function buildServer(deps: ServerDeps, opts?: { logger?: FastifyServerOptions["logger"] }) { const app = Fastify({ logger: opts?.logger ?? { level: "info" }, }).withTypeProvider<JsonSchemaToTsProvider>(); const commandHandler = newCommandHandler({ eventsClient: deps.eventsClient, slackClient: deps.slackClient, inviteChannel: deps.inviteChannel, });
// Capture the raw request body so the Slack signature can be verified over // the exact bytes Slack signed. Fastify's default JSON parser would discard them. app.addContentTypeParser( "application/json", { parseAs: "string" }, (req: FastifyRequest, body: string, done: (err: Error | null, body?: unknown) => void) => { req.rawBody = body; try { done(null, JSON.parse(body)); } catch (err) { done(err as Error); } }, );
app.get("/health", async () => ({ status: "ok" }));
const inviteRequestSchema = { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, reason: { type: "string" }, livesHere: { type: "string" }, }, required: ["name", "email", "reason", "livesHere"], additionalProperties: false, } as const;
app.post( "/invites/slack/request", { schema: { body: inviteRequestSchema } }, async (request, reply) => { const auth = request.headers.authorization; if (!safeEqualBearer(auth, deps.inviteWebhookToken)) { request.log.warn("invites/request: missing or invalid token"); return reply.code(401).send({ error: "unauthorized" }); }
const { name, email, reason, livesHere } = request.body; request.log.info({ name, channel: deps.inviteChannel }, "invites/request: posting request");
const msg = formatInviteRequest({ name, email, reason, livesHere }); try { await deps.slackClient.sendMessage({ msg, channel: deps.inviteChannel }); } catch (err) { request.log.error({ err }, "invites/request: sendMessage failed"); return reply.code(500).send({ error: "failed to post request" }); }
reply.code(202).send({ status: "posted" }); }, );
app.post("/slack/webhook", async (request, reply) => { const timestamp = request.headers["x-slack-request-timestamp"]; const signature = request.headers["x-slack-signature"]; if (typeof timestamp !== "string" || typeof signature !== "string") { request.log.warn( { hasTimestamp: typeof timestamp === "string", hasSignature: typeof signature === "string", }, "slack webhook: missing signature headers", ); return reply.code(401).send({ error: "missing signature headers" }); }
const rawBody = request.rawBody ?? ""; const valid = deps.slackClient.verifySignature({ timestamp, rawBody, signature, onReject: (reason) => request.log.warn({ reason }, "slack webhook: signature rejected"), }); if (!valid) return reply.code(401).send({ error: "invalid signature" });
const payload = JSON.parse(rawBody) as SlackEventEnvelope;
if (payload.type === "url_verification") { request.log.info("slack webhook: url_verification challenge"); return reply.send({ challenge: payload.challenge }); }
if (payload.type !== "event_callback") { request.log.info({ type: payload.type }, "slack webhook: unhandled envelope type"); return reply.code(200).send({}); }
const event = payload.event; if (!event) { request.log.warn("slack webhook: event_callback with no event"); return reply.code(200).send({}); } request.log.info({ eventType: event.type }, "slack webhook: event_callback received");
try { switch (event.type) { case "app_mention": await commandHandler.handleBotMention(event, request.log); } } catch (err) { request.log.error({ err }, "slack webhook: command handling failed"); return reply.code(200).send({}); }
reply.code(200).send({}); });
return app;}
function safeEqualBearer(header: string | undefined, expected: string): boolean { if (typeof header !== "string") return false; const received = header.startsWith("Bearer ") ? header.slice(7) : ""; const a = Buffer.from(received, "utf8"); const b = Buffer.from(expected, "utf8"); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
function formatInviteRequest({ name, email, reason, livesHere,}: { name: string; email: string; reason: string; livesHere: string;}): string { return [ "New Slack invite request", `Name: ${name}`, `Email: ${email}`, `What brings you to devICT: ${reason}`, `Do you live in the Wichita, KS region?: ${livesHere}`, "", "Approve by replying `@bot !invite` in this thread.", ].join("\n");}
type SlackEventEnvelope = { type: string; challenge?: string; event?: SlackEvent;};