Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ import {
revokeSlackToken,
signOauthState,
verifyOauthState,
verifySlackSignature,
} from "./slack-oauth.js";
import {
processSlackEvent,
type SlackEventEnvelope,
} from "./slack-events-handler.js";
import {
getActiveWorkspaceForTeam,
getActiveWorkspaceForUser,
Expand Down Expand Up @@ -533,7 +538,13 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
});

server.addHook("preParsing", async (request, _reply, payload) => {
if (!request.url?.startsWith("/webhooks/")) return payload;
// Captures the raw body for routes that need to verify a request signature
// against the bytes (HMAC). Stripe webhooks and Slack events both need it.
const url = request.url ?? "";
const needsRawBody =
url.startsWith("/webhooks/") ||
url.startsWith("/slack/events");
if (!needsRawBody) return payload;
const chunks: Buffer[] = [];
for await (const chunk of payload) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
Expand Down Expand Up @@ -907,6 +918,12 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
// key, carries the originating Reflect user_id).
if (request.method === "GET" && path === "/slack/oauth/callback") return;

// Slack events webhook is hit by slack.com via Server-to-Server. Auth is
// the X-Slack-Signature header verified against the workspace's signing
// secret (HMAC-SHA256 over the raw body + timestamp). Verified inside
// the route handler.
if (request.method === "POST" && path === "/slack/events") return;

const header = request.headers.authorization;

if (!header || !header.startsWith("Bearer ")) {
Expand Down Expand Up @@ -2368,6 +2385,69 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
},
);

// POST /slack/events
// Public route — verified via X-Slack-Signature header against the active
// Slack config's signing secret. Slack imposes a 3s ack deadline; we reply
// 200 immediately and run the agent work as a fire-and-forget Promise.
// url_verification gets a synchronous {challenge} response.
server.post(
"/slack/events",
{
config: { rateLimit: { max: 600, timeWindow: "1 minute" } },
},
async (request, reply) => {
const cfg = getActiveSlackConfig();
if (!cfg) {
reply.code(503);
return { error: "Slack OAuth is not configured on this instance" };
}
const rawBody =
(request as unknown as { rawBody?: string }).rawBody ??
(typeof request.body === "string"
? request.body
: JSON.stringify(request.body ?? {}));
const ts = request.headers["x-slack-request-timestamp"];
const sig = request.headers["x-slack-signature"];
if (typeof ts !== "string" || typeof sig !== "string") {
reply.code(401);
return { error: "Missing Slack signature headers" };
}
const valid = verifySlackSignature({
signingSecret: cfg.signingSecret,
timestamp: ts,
signature: sig,
rawBody,
});
if (!valid) {
reply.code(401);
return { error: "Invalid Slack signature" };
}

let envelope: SlackEventEnvelope;
try {
envelope =
typeof request.body === "object" && request.body !== null
? (request.body as SlackEventEnvelope)
: (JSON.parse(rawBody) as SlackEventEnvelope);
} catch {
reply.code(400);
return { error: "Invalid JSON" };
}

const result = processSlackEvent(db, envelope, {
onAsyncError: (err) =>
request.log.error({ err }, "[slack-events] async handler error"),
});

if (result.kind === "url_verification") {
return { challenge: result.challenge };
}
// event_callback ack or ignored: respond 200 with a tiny body Slack
// discards. Don't return the body's identifying details.
return { ok: true };
},
);

// ===========================================================================
// POST /memories -- Create a memory (user path)
// ===========================================================================
Expand Down
153 changes: 153 additions & 0 deletions src/slack-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Thin typed wrappers around the Slack Web API endpoints we use.
* Keep these dependency-free and side-effect free so they're easy to mock in
* tests. All error handling surfaces a discriminated union; callers decide
* whether to retry, give up, or surface to the user.
*/

const SLACK_API = "https://slack.com/api";

export type SlackResult<T> = { ok: true; data: T } | { ok: false; error: string };

interface SlackUserInfoResult {
email: string | null;
realName: string | null;
displayName: string | null;
isBot: boolean;
isDeleted: boolean;
}

/**
* Looks up a Slack user by their Slack user ID. Returns the email (which we
* use to match to a Reflect user) plus a couple of display fields. Email may
* be null if the workspace admin has revoked the `users:read.email` scope.
*/
export async function slackUsersInfo(
botToken: string,
slackUserId: string,
): Promise<SlackResult<SlackUserInfoResult>> {
const params = new URLSearchParams({ user: slackUserId });
let res: Response;
try {
res = await fetch(`${SLACK_API}/users.info?${params.toString()}`, {
method: "GET",
headers: {
Authorization: `Bearer ${botToken}`,
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
},
});
} catch (err) {
return { ok: false, error: `network: ${err instanceof Error ? err.message : err}` };
}
let data: {
ok?: boolean;
error?: string;
user?: {
profile?: { email?: string | null; real_name?: string | null; display_name?: string | null };
is_bot?: boolean;
deleted?: boolean;
};
};
try {
data = (await res.json()) as typeof data;
} catch (err) {
return { ok: false, error: `parse: ${err instanceof Error ? err.message : err}` };
}
if (!data.ok) {
return { ok: false, error: data.error ?? "users.info returned ok=false" };
}
const user = data.user ?? {};
return {
ok: true,
data: {
email: user.profile?.email ?? null,
realName: user.profile?.real_name ?? null,
displayName: user.profile?.display_name ?? null,
isBot: user.is_bot === true,
isDeleted: user.deleted === true,
},
};
}

interface PostMessageResult {
ts: string;
channel: string;
}

/**
* Posts a message to a channel or DM. If `threadTs` is set, posts as a reply
* in that thread.
*/
export async function slackChatPostMessage(
botToken: string,
options: { channel: string; text: string; threadTs?: string | null },
): Promise<SlackResult<PostMessageResult>> {
const body: Record<string, unknown> = {
channel: options.channel,
text: options.text,
};
if (options.threadTs) body.thread_ts = options.threadTs;

let res: Response;
try {
res = await fetch(`${SLACK_API}/chat.postMessage`, {
method: "POST",
headers: {
Authorization: `Bearer ${botToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
} catch (err) {
return { ok: false, error: `network: ${err instanceof Error ? err.message : err}` };
}
let data: { ok?: boolean; error?: string; ts?: string; channel?: string };
try {
data = (await res.json()) as typeof data;
} catch (err) {
return { ok: false, error: `parse: ${err instanceof Error ? err.message : err}` };
}
if (!data.ok || !data.ts || !data.channel) {
return { ok: false, error: data.error ?? "chat.postMessage returned ok=false" };
}
return { ok: true, data: { ts: data.ts, channel: data.channel } };
}

/**
* Posts an ephemeral message visible only to one user in a channel. We use
* this for refusals when the requester's email doesn't match a Reflect user
* — keeps the channel clean for everyone else.
*/
export async function slackPostEphemeral(
botToken: string,
options: { channel: string; user: string; text: string },
): Promise<SlackResult<{ messageTs: string }>> {
const body = {
channel: options.channel,
user: options.user,
text: options.text,
};
let res: Response;
try {
res = await fetch(`${SLACK_API}/chat.postEphemeral`, {
method: "POST",
headers: {
Authorization: `Bearer ${botToken}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
} catch (err) {
return { ok: false, error: `network: ${err instanceof Error ? err.message : err}` };
}
let data: { ok?: boolean; error?: string; message_ts?: string };
try {
data = (await res.json()) as typeof data;
} catch (err) {
return { ok: false, error: `parse: ${err instanceof Error ? err.message : err}` };
}
if (!data.ok) {
return { ok: false, error: data.error ?? "chat.postEphemeral returned ok=false" };
}
return { ok: true, data: { messageTs: data.message_ts ?? "" } };
}
Loading
Loading