diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..0ef5c6b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,124 @@ +# Plan — Slack invite approval flow + +Tracks to `TASK.md`. Steps are ordered; each has a verify step. Stop and confirm +with Seth after each. + +## Step 0 — Decisions locked (open questions from TASK.md) + +**✅ Confirmed 2026-07-26.** Decisions below are locked. Form's "What brings you +to devICT" field is included as `reason` in the payload. + +- **Email lookup:** bot stores the email on the message it posts, then reads it + back from `conversations.replies` on approve. Need a `getThreadParent` method + on `SlackClient`. Parsing message text is the fallback only if Slack strips + structured data. +- **Incoming webhook auth:** shared secret in env `SLACK_INVITE_WEBHOOK_TOKEN`, + checked as a bearer header. Separate from Slack signing secret. +- **Channel gating:** new env `SLACK_INVITE_CHANNEL` (channel id). `!invite` + only acts when the mention is in that channel. +- **Endpoint:** `POST /invites/request`, JSON `{ name, email, reason }` + (`reason` = the form's "What brings you to devICT" field). +- **Stretch flags:** out of scope for this task. + +**Verify:** ✅ Seth acks the decisions (2026-07-26). + +--- + +## Step 1 — Config + SlackClient additions + +- `src/config.ts`: add `SLACK_INVITE_WEBHOOK_TOKEN`, `SLACK_INVITE_CHANNEL` (required, + fail fast like existing vars). +- `src/slack/slack.ts`: add `getThreadParent(args: { channel, thread_ts })` + returning the parent message (`{ text }` at minimum). Wraps + `client.conversations.replies`. +- `test/server.test.ts`: extend `fakes()` slackClient with `getThreadParent` + stub. + +**Verify:** ✅ Typecheck passes; no new test failures (3 pre-existing +failures on main are unrelated: `formatEvents` format strings and a +`message`-vs-`app_mention` dispatch test). + +--- + +## Step 2 — Invite request endpoint + +- New route `POST /invites/request` in `src/server.ts`: + - Auth: require header `Authorization: Bearer `, + constant-time compare. 401 on miss/match failure, logged. + - Body schema `{ name: string, email: string, reason: string }`. + - Posts a message to `SLACK_INVITE_CHANNEL` with name, email, and reason. + Email must be recoverable on approve — encode it in the message text in a + known, parseable form (e.g. `Email: ` line); `getThreadParent` is + the primary read-back path. + - 202 on success; errors logged, 500. +- Reuse `sendMessage` rather than adding a new SlackClient method — keep client + surface minimal. + +**Verify:** ✅ Typecheck passes. 4 new tests pass: 401 on missing token, +401 on bad token, 202 + message-to-channel on success (asserts name, email, +reason, and `!invite` instruction all present), 500 on sendMessage failure. +Only the 3 pre-existing main-branch failures remain. + +--- + +## Step 3 — `!invite` approve command + +- `src/commands/commands.ts`: + - Extend `Command` union with `InviteCmd = { type: "invite"; channel; + thread }`. + - In `msgToCommand`: match `!invite` (only when `thread_ts` is present). + - Gate: only dispatch `invite` when `msg.channel === config channel id`. + Needs the channel id passed into `newCommandHandler` deps. +- `src/commands/invites.ts` (new, mirrors `events.ts`): + - Calls `slackClient.getThreadParent({ channel, thread_ts })`. + - Extract email from the parent message (parse the known format we posted). + - Calls `slackClient.inviteUser({ email })`. + - Replies in-thread with success/failure via `sendMessage`. +- `src/commands/commands.ts` `CommandHandlerDeps`: add the channel id. + +**Verify:** ✅ Typecheck passes. 8 new tests pass: extractEmail unit tests +(3) + server dispatch tests (5) covering happy path, channel gating, +no-thread guard, lookup failure, no-email-found, and inviteUser error. +Only the 3 pre-existing main-branch failures remain. + +--- + +## Step 4 — Wire main.ts + docs + +- `src/main.ts`: pass new config into server/command handler deps. +- `README.md`: short section on the invite flow (endpoint contract, env vars, + approvals channel, `!invite` in-thread). +- `.env.example`: add `SLACK_INVITE_WEBHOOK_TOKEN=` and `SLACK_INVITE_CHANNEL=`. + +**Verify:** `npm run typecheck && npm test && npm run lint` all pass; Seth +reviews README. + +**Status:** ✅ Typecheck clean, tests pass (3 pre-existing failures only), +lint clean (1 pre-existing unused-import warning on main). README updated. +⚠️ `.env.example` update **blocked** by the secrets guard — every tool that +touches a `.env*` path is rejected. Needs Seth to add manually: +``` +SLACK_INVITE_WEBHOOK_TOKEN=asdf +SLACK_INVITE_CHANNEL=C0 +``` + +--- + +## Out of scope / later + +- Banned-users / spam heuristics (stretch goal from note). +- Google Form → webhook bridge wiring (Seth's side; bot only defines the + payload contract). +- Migrating `inviteUser` off the legacy `users.admin.invite` endpoint. + +--- + +## Follow-up — mailto extraction fix + test cleanup (done) + +- Root cause of `invalid_email`: Slack auto-linkifies emails to + ``; the backtick-wrap prevention had a `\'` typo + (apostrophe, not closing backtick) so it never worked. Fixed by letting + Slack linkify and unwrapping `` in `extractEmail`. +- Also fixed 7 stale failing tests (route rename fallout, app_mention-only + dispatch, formatEvents expectations). Suite is green: 31/31. +- Commits: 788cab5 (mailto fix), 85900fb (stale tests). diff --git a/README.md b/README.md index 23830fb..f409d3d 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,20 @@ Set up as a simple slack app webhook receiver. ## Testing Test at the request level, mocking / intercepting HTTP requests made. + +## Slack invite approval flow + +The Google Form invite request posts to `POST /invites/slack/request`: + +``` +Authorization: Bearer $SLACK_INVITE_WEBHOOK_TOKEN +{ "name": "Jane Doe", "email": "jane@example.com", "reason": "community" } +``` + +The bot posts the request to the private `$SLACK_INVITE_CHANNEL` (members of +which are the only people allowed to invite). Approve by replying +`@bot !invite` in the request's thread — the bot looks up the email from the +parent message and sends the Slack invite, then replies in-thread with the +result. + +`!invite` only works in the invite channel and must be in a thread. diff --git a/TASK.md b/TASK.md new file mode 100644 index 0000000..bad7556 --- /dev/null +++ b/TASK.md @@ -0,0 +1,58 @@ +# Bot handles Slack invites + +Source note: `/Users/sethetter/obsidian/brain/0_Projects/devICT/2607261945 bot handles slack invites.md` + +## Problem + +Today the devICT Slack invite request form (Google Form) emails Seth on each +submission; he manually invites people. We want the bot to mediate this so any +authorized member of a private channel can approve, not just Seth. + +## Goals + +- New webhook endpoint on the bot API that receives Google Form submissions. +- On submission, bot posts the request (name, email, etc.) to a private Slack + channel whose members are the only people allowed to invite others. +- Approve by replying `@bot !invite` in the thread under the request message. + Bot looks up the email from the request message and sends the invite, then + replies in-thread with the result. + +## Non-goals + +- Replacing the Google Form itself. +- Self-serve invites (approval always goes through a human in the channel). +- Changing the existing `!events` command or webhook auth. + +## Open questions / decisions + +1. **Email lookup on approve.** The bot must recover the email from the request + message when `@bot !invite` fires in its thread. Options: + - (a) Slack message metadata on the posted message, read back via + `conversations.replies`. Structured, robust. **Recommend.** + - (b) Parse the email out of the posted message text (bot-authored, so + deterministic). Zero new API surface. Fallback if (a) is fiddly. + - (c) Persist `{ messageTs: email }` in a store. Adds state mgmt; rejected. +2. **Incoming webhook auth.** The Slack signature does not apply (payload is + from the Google Form, not Slack). Plan: shared-secret bearer header + (`SLACK_INVITE_WEBHOOK_TOKEN`) checked on the new endpoint. +3. **Channel config.** New env var `SLACK_INVITE_CHANNEL` (channel id of the + private approvals channel). `!invite` only acts when fired in that channel. +4. **Form → webhook bridge.** Out of bot scope, but we must publish a payload + contract the form sender (Apps Script / Make / Zapier) uses. Proposed: + `POST /invites/request` with `{ name, email, ...? }`. +5. **Existing invite mechanism.** `slackClient.inviteUser` uses the legacy + `users.admin.invite` endpoint via raw fetch + admin token. It's existing and + works; leave as-is. +6. **Stretch: flag problematic requests.** Defer to a later task. Needs a + banned-emails source and spam heuristics; scope undefined. + +## Relevant paths + +- `src/server.ts` — Fastify app, routes, Slack webhook + signature verify +- `src/commands/commands.ts` — `app_mention` → command dispatch (`!events`) +- `src/commands/events.ts` — reference command handler +- `src/slack/slack.ts` — `SlackClient` (`sendMessage`, `inviteUser`, + `verifySignature`); add `getThreadParent` here +- `src/slack/signature.ts` — Slack request signing verify (reused pattern) +- `src/config.ts` — env loading +- `test/server.test.ts` — request-level tests, signature fixture pattern diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 5b446d9..a154bbb 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -2,6 +2,7 @@ import type { AppMentionEvent } from "@slack/web-api"; import type { FastifyBaseLogger } from "fastify"; import type { EventsClient } from "../events/events.ts"; import { handleEventsCommand, type EventsCmd } from "./events.ts"; +import { handleInviteCommand, type InviteCmd } from "./invites.ts"; import type { SlackClient } from "../slack/slack.ts"; interface CommandHandler { @@ -11,9 +12,10 @@ interface CommandHandler { interface CommandHandlerDeps { eventsClient: EventsClient; slackClient: SlackClient; + inviteChannel: string; } -type Command = EventsCmd; +type Command = EventsCmd | InviteCmd; export function newCommandHandler(deps: CommandHandlerDeps): CommandHandler { function msgToCommand(msg: AppMentionEvent): Command | null { @@ -21,6 +23,9 @@ export function newCommandHandler(deps: CommandHandlerDeps): CommandHandler { if (msgSansBot?.startsWith("!events")) { return { type: "events", channel: msg.channel, thread: msg.thread_ts ?? msg.ts }; } + if (msgSansBot?.startsWith("!invite") && msg.thread_ts) { + return { type: "invite", channel: msg.channel, thread: msg.thread_ts }; + } return null; } @@ -36,6 +41,10 @@ export function newCommandHandler(deps: CommandHandlerDeps): CommandHandler { switch (cmd.type) { case "events": await handleEventsCommand({ cmd, ...deps, log }); + break; + case "invite": + await handleInviteCommand({ cmd, slackClient: deps.slackClient, inviteChannel: deps.inviteChannel, log }); + break; } }, }; diff --git a/src/commands/invites.ts b/src/commands/invites.ts new file mode 100644 index 0000000..283f1e2 --- /dev/null +++ b/src/commands/invites.ts @@ -0,0 +1,68 @@ +import type { FastifyBaseLogger } from "fastify"; +import type { SlackClient } from "../slack/slack.ts"; + +export type InviteCmd = { type: "invite"; channel: string; thread: string }; + +export async function handleInviteCommand({ + cmd, + slackClient, + inviteChannel, + log, +}: { + cmd: InviteCmd; + slackClient: SlackClient; + inviteChannel: string; + log: FastifyBaseLogger; +}): Promise { + if (cmd.channel !== inviteChannel) { + log.info({ channel: cmd.channel }, "invite: ignored outside invite channel"); + return; + } + + let parentText: string; + try { + parentText = (await slackClient.getThreadParent({ channel: cmd.channel, thread_ts: cmd.thread })).text; + } catch (err) { + log.error({ err, channel: cmd.channel, thread: cmd.thread }, "invite: getThreadParent failed"); + await slackClient.sendMessage({ + msg: "Couldn't look up the invite request. Try again or invite manually.", + channel: cmd.channel, + thread_ts: cmd.thread, + }); + return; + } + + const email = extractEmail(parentText); + if (!email) { + log.warn({ channel: cmd.channel, thread: cmd.thread }, "invite: no email found in parent message"); + await slackClient.sendMessage({ + msg: "Couldn't find an email in the request message. Invite manually.", + channel: cmd.channel, + thread_ts: cmd.thread, + }); + return; + } + + try { + await slackClient.inviteUser({ email }); + log.info({ email, channel: cmd.channel }, "invite: sent"); + await slackClient.sendMessage({ + msg: `Invited \`${email}\``, + channel: cmd.channel, + thread_ts: cmd.thread, + }); + } catch (err) { + log.error({ err, email, channel: cmd.channel }, "invite: inviteUser failed"); + await slackClient.sendMessage({ + msg: `Invite failed for \`${email}\`: ${(err as Error).message}`, + channel: cmd.channel, + thread_ts: cmd.thread, + }); + } +} + +export function extractEmail(text: string): string | undefined { + // Slack auto-linkifies emails in message text to . + const match = text.match(/^Email:\s*(?:]+)(?:\|[^>]*)?>|(.+))$/m); + return (match?.[1] ?? match?.[2])?.trim().replace(/^`|`$/g, ""); +} diff --git a/src/config.ts b/src/config.ts index bfceb5d..c9f041e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,7 +14,17 @@ export function loadConfig() { throw new Error("SLACK_ADMIN_TOKEN is required"); } - return { SLACK_SIGNING_SECRET, SLACK_TOKEN, SLACK_ADMIN_TOKEN }; + const SLACK_INVITE_WEBHOOK_TOKEN = process.env.SLACK_INVITE_WEBHOOK_TOKEN; + if (!SLACK_INVITE_WEBHOOK_TOKEN) { + throw new Error("SLACK_INVITE_WEBHOOK_TOKEN is required"); + } + + const SLACK_INVITE_CHANNEL = process.env.SLACK_INVITE_CHANNEL; + if (!SLACK_INVITE_CHANNEL) { + throw new Error("SLACK_INVITE_CHANNEL is required"); + } + + return { SLACK_SIGNING_SECRET, SLACK_TOKEN, SLACK_ADMIN_TOKEN, SLACK_INVITE_WEBHOOK_TOKEN, SLACK_INVITE_CHANNEL }; } export type AppConfig = ReturnType; diff --git a/src/main.ts b/src/main.ts index 1fa1d6d..27bc5d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,7 +13,12 @@ const slackClient = makeSlackClient({ signingSecret: config.SLACK_SIGNING_SECRET, }); -const app = buildServer({ eventsClient, slackClient }); +const app = buildServer({ + eventsClient, + slackClient, + inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, + inviteChannel: config.SLACK_INVITE_CHANNEL, +}); try { await app.listen({ port: 3000, host: "0.0.0.0" }); diff --git a/src/server.ts b/src/server.ts index 080069b..96e869e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import { 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"; @@ -8,6 +9,8 @@ import { newCommandHandler } from "./commands/commands.ts"; export interface ServerDeps { eventsClient: EventsClient; slackClient: SlackClient; + inviteWebhookToken: string; + inviteChannel: string; } declare module "fastify" { @@ -23,6 +26,7 @@ export function buildServer(deps: ServerDeps, opts?: { logger?: FastifyServerOpt 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 @@ -42,6 +46,42 @@ export function buildServer(deps: ServerDeps, opts?: { logger?: FastifyServerOpt app.get("/health", async () => ({ status: "ok" })); + const inviteRequestSchema = { + type: "object", + properties: { + name: { type: "string" }, + email: { type: "string" }, + reason: { type: "string" }, + }, + required: ["name", "email", "reason"], + 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 } = request.body; + request.log.info({ name, channel: deps.inviteChannel }, "invites/request: posting request"); + + const msg = formatInviteRequest({ name, email, reason }); + 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"]; @@ -100,6 +140,34 @@ export function buildServer(deps: ServerDeps, opts?: { logger?: FastifyServerOpt 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, +}: { + name: string; + email: string; + reason: string; +}): string { + return [ + "New Slack invite request", + `Name: ${name}`, + `Email: ${email}`, + `What brings you to devICT: ${reason}`, + "", + "Approve by replying `@bot !invite` in this thread.", + ].join("\n"); +} + type SlackEventEnvelope = { type: string; challenge?: string; diff --git a/src/slack/slack.ts b/src/slack/slack.ts index 55e6779..db9601d 100644 --- a/src/slack/slack.ts +++ b/src/slack/slack.ts @@ -4,6 +4,7 @@ import { verifySlackSignature } from "./signature.ts"; export interface SlackClient { sendMessage(args: { msg: string; channel: string; thread_ts?: string }): Promise; inviteUser(args: { email: string }): Promise; + getThreadParent(args: { channel: string; thread_ts: string }): Promise<{ text: string }>; verifySignature(args: { timestamp: string; rawBody: string; @@ -51,6 +52,15 @@ export function makeSlackClient({ } }, + async getThreadParent({ channel, thread_ts }): Promise<{ text: string }> { + const resp = await client.conversations.replies({ channel, ts: thread_ts }); + const parent = resp.messages?.find((m) => m.ts === thread_ts); + if (!parent?.text) { + throw new Error(`thread parent not found for ${channel}/${thread_ts}`); + } + return { text: parent.text }; + }, + verifySignature({ timestamp, rawBody, signature, onReject }): boolean { return verifySlackSignature({ signingSecret, timestamp, rawBody, signature, onReject }); }, diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..e393020 --- /dev/null +++ b/test.ts @@ -0,0 +1,21 @@ +(async () => { + const resp = await fetch("https://bot.devict.org/invites/slack/request", { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer 6e7ec9404c0b59a56b972c5235ccbb8f`, + }, + body: JSON.stringify({ + name: "test", + email: "sethetter+test@gmail.com", + reason: "testing", + }), + }); + + if (!resp.ok) { + throw new Error(`failed: (${resp.status}) ${await resp.text()}`); + } + + console.log(`success!`); + console.log(await resp.text()); +})(); diff --git a/test/events.test.ts b/test/events.test.ts index 0f5bde9..1feef74 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -60,20 +60,20 @@ test("trimSeriesEvents: keeps earliest occurrence of a shared series id", () => }); test("formatEvents: formats name and Chicago-localized date/time", () => { - // 2026-07-16T12:00:00Z..13:00:00Z -> 07/16 07:00a - 08:00a America/Chicago + // 2026-07-16T12:00:00Z..13:00:00Z -> 7/16, 7:00a - 8:00a America/Chicago const events = [{ name: "Code & Coffee", startDate: zdt("2026-07-16T12:00:00Z"), endDate: zdt("2026-07-16T13:00:00Z") }]; - assert.equal(formatEvents(events), "- Code & Coffee (07/16 07:00a - 08:00a)"); + assert.equal(formatEvents(events), "- Code & Coffee (7/16, 7:00a - 8:00a)"); }); test("formatEvents: handles midnight and noon in America/Chicago", () => { - // 2026-07-16T00:00:00Z..01:00:00Z -> 07/15 07:00p - 08:00p (previous day, CDT) - // 2026-07-16T18:00:00Z..19:00:00Z -> 07/16 01:00p - 02:00p + // 2026-07-16T00:00:00Z..01:00:00Z -> 7/15, 7:00p - 8:00p (previous day, CDT) + // 2026-07-16T18:00:00Z..19:00:00Z -> 7/16, 1:00p - 2:00p const events = [ { name: "midnight UTC", startDate: zdt("2026-07-16T00:00:00Z"), endDate: zdt("2026-07-16T01:00:00Z") }, { name: "noon-ish UTC", startDate: zdt("2026-07-16T18:00:00Z"), endDate: zdt("2026-07-16T19:00:00Z") }, ]; assert.equal( formatEvents(events), - "- midnight UTC (07/15 07:00p - 08:00p)\n- noon-ish UTC (07/16 01:00p - 02:00p)", + "- midnight UTC (7/15, 7:00p - 8:00p)\n- noon-ish UTC (7/16, 1:00p - 2:00p)", ); }); diff --git a/test/invites.test.ts b/test/invites.test.ts new file mode 100644 index 0000000..7f5425f --- /dev/null +++ b/test/invites.test.ts @@ -0,0 +1,37 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { extractEmail } from "../src/commands/invites.ts"; + +test("extractEmail: pulls email from a posted request message", () => { + const text = [ + "New Slack invite request", + "Name: Jane Doe", + "Email: jane@example.com", + "What brings you to devICT: community", + "", + "Approve by replying `@bot !invite` in this thread.", + ].join("\n"); + assert.equal(extractEmail(text), "jane@example.com"); +}); + +test("extractEmail: returns undefined when no Email: line", () => { + assert.equal(extractEmail("just some text\nno email here"), undefined); +}); + +test("extractEmail: trims surrounding whitespace", () => { + assert.equal(extractEmail("Email: spaced@example.com "), "spaced@example.com"); +}); + +test("extractEmail: strips inline-code backticks", () => { + assert.equal(extractEmail("Email: `tick@example.com`"), "tick@example.com"); +}); + +test("extractEmail: unwraps Slack's mailto link format", () => { + const text = "Name: Jane Doe\nEmail: \nWhat brings you: community"; + assert.equal(extractEmail(text), "jane@example.com"); +}); + +test("extractEmail: unwraps mailto link without display part", () => { + const text = "Email: "; + assert.equal(extractEmail(text), "sethetter+test@gmail.com"); +}); diff --git a/test/server.test.ts b/test/server.test.ts index 903c61b..38d8b69 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createHmac } from "node:crypto"; -import { buildServer } from "../src/server.ts"; +import { buildServer, type ServerDeps } from "../src/server.ts"; import type { AppConfig } from "../src/config.ts"; import type { EventsClient } from "../src/events/events.ts"; import { verifySlackSignature } from "../src/slack/signature.ts"; @@ -11,9 +11,11 @@ const config: AppConfig = { SLACK_SIGNING_SECRET: "test-secret", SLACK_TOKEN: "xoxb-test", SLACK_ADMIN_TOKEN: "xoxp-test", + SLACK_INVITE_WEBHOOK_TOKEN: "invite-secret", + SLACK_INVITE_CHANNEL: "C-invites", }; -function fakes(): { eventsClient: EventsClient; slackClient: SlackClient } { +function fakes(): ServerDeps { return { eventsClient: { async getUpcomingEvents() { @@ -35,6 +37,9 @@ function fakes(): { eventsClient: EventsClient; slackClient: SlackClient } { async inviteUser() { /* no-op */ }, + async getThreadParent() { + return { text: "" }; + }, verifySignature({ timestamp, rawBody, signature }) { return verifySlackSignature({ signingSecret: config.SLACK_SIGNING_SECRET, @@ -44,6 +49,8 @@ function fakes(): { eventsClient: EventsClient; slackClient: SlackClient } { }); }, }, + inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, + inviteChannel: config.SLACK_INVITE_CHANNEL, }; } @@ -118,6 +125,9 @@ test("POST /slack/webhook dispatches a message command", async () => { async inviteUser() { /* no-op */ }, + async getThreadParent() { + return { text: "" }; + }, verifySignature({ timestamp, rawBody, signature }) { return verifySlackSignature({ signingSecret: config.SLACK_SIGNING_SECRET, @@ -127,9 +137,9 @@ test("POST /slack/webhook dispatches a message command", async () => { }); }, }; - const app = buildServer({ eventsClient: fakes().eventsClient, slackClient }, { logger: false }); + const app = buildServer({ eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, { logger: false }); - const event = { type: "message", text: "!events", channel: "C1", ts: "1700000000.000100" }; + const event = { type: "app_mention", text: "<@U0> !events", channel: "C1", ts: "1700000000.000100" }; const body = JSON.stringify({ type: "event_callback", event }); const ts = Math.floor(Date.now() / 1000); const res = await app.inject({ @@ -148,3 +158,339 @@ test("POST /slack/webhook dispatches a message command", async () => { assert.equal(sent?.thread_ts, "1700000000.000100"); await app.close(); }); + +test("POST /invites/request rejects missing Authorization header", async () => { + const app = buildServer(fakes(), { logger: false }); + const res = await app.inject({ + method: "POST", + url: "/invites/slack/request", + headers: { "content-type": "application/json" }, + payload: JSON.stringify({ name: "Jane", email: "jane@example.com", reason: "friends" }), + }); + assert.equal(res.statusCode, 401); + await app.close(); +}); + +test("POST /invites/request rejects an invalid token", async () => { + const app = buildServer(fakes(), { logger: false }); + const res = await app.inject({ + method: "POST", + url: "/invites/slack/request", + headers: { + "content-type": "application/json", + authorization: "Bearer wrong-token", + }, + payload: JSON.stringify({ name: "Jane", email: "jane@example.com", reason: "friends" }), + }); + assert.equal(res.statusCode, 401); + await app.close(); +}); + +test("POST /invites/request posts the request to the invite channel and returns 202", async () => { + let sent: { msg: string; channel: string; thread_ts?: string } | undefined; + const slackClient: SlackClient = { + async sendMessage(args) { + sent = args; + }, + async inviteUser() { + /* no-op */ + }, + async getThreadParent() { + return { text: "" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const res = await app.inject({ + method: "POST", + url: "/invites/slack/request", + headers: { + "content-type": "application/json", + authorization: `Bearer ${config.SLACK_INVITE_WEBHOOK_TOKEN}`, + }, + payload: JSON.stringify({ name: "Jane Doe", email: "jane@example.com", reason: "community" }), + }); + assert.equal(res.statusCode, 202); + assert.equal(sent?.channel, "C-invites"); + assert.ok(sent?.msg.includes("Email: jane@example.com"), "message contains the email"); + assert.ok(sent?.msg.includes("Name: Jane Doe"), "message contains the name"); + assert.ok(sent?.msg.includes("community"), "message contains the reason"); + assert.ok(sent?.msg.includes("!invite"), "message tells the approver how to approve"); + await app.close(); +}); + +test("POST /invites/request returns 500 when sendMessage fails", async () => { + const slackClient: SlackClient = { + async sendMessage() { + throw new Error("slack down"); + }, + async inviteUser() { + /* no-op */ + }, + async getThreadParent() { + return { text: "" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const res = await app.inject({ + method: "POST", + url: "/invites/slack/request", + headers: { + "content-type": "application/json", + authorization: `Bearer ${config.SLACK_INVITE_WEBHOOK_TOKEN}`, + }, + payload: JSON.stringify({ name: "Jane", email: "jane@example.com", reason: "x" }), + }); + assert.equal(res.statusCode, 500); + await app.close(); +}); + +function signEvent(event: Record): { body: string; ts: number; headers: Record } { + const body = JSON.stringify({ type: "event_callback", event }); + const ts = Math.floor(Date.now() / 1000); + return { body, ts, headers: { + "content-type": "application/json", + "x-slack-request-timestamp": String(ts), + "x-slack-signature": sign(config.SLACK_SIGNING_SECRET, ts, body), + } }; +} + +test("!invite in invite channel thread invites the user from the parent message", async () => { + let invitedEmail: string | undefined; + let reply: { msg: string; channel: string; thread_ts?: string } | undefined; + const slackClient: SlackClient = { + async sendMessage(args) { + reply = args; + }, + async inviteUser({ email }) { + invitedEmail = email; + }, + async getThreadParent() { + // Slack auto-linkifies emails in message text to . + return { text: "Name: Jane Doe\nEmail: \nWhat brings you: community" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const event = { type: "app_mention", text: "<@U0> !invite", channel: config.SLACK_INVITE_CHANNEL, ts: "1700000000.000200", thread_ts: "1700000000.000100" }; + const { body, headers } = signEvent(event); + const res = await app.inject({ method: "POST", url: "/slack/webhook", headers, payload: body }); + + assert.equal(res.statusCode, 200); + assert.equal(invitedEmail, "jane@example.com"); + assert.equal(reply?.channel, config.SLACK_INVITE_CHANNEL); + assert.equal(reply?.thread_ts, "1700000000.000100"); + assert.match(reply?.msg ?? "", /Invited `jane@example\.com`/); + await app.close(); +}); + +test("!invite outside the invite channel is ignored", async () => { + let invited = false; + const slackClient: SlackClient = { + async sendMessage() {}, + async inviteUser() { + invited = true; + }, + async getThreadParent() { + return { text: "Email: jane@example.com" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const event = { type: "app_mention", text: "<@U0> !invite", channel: "C-elsewhere", ts: "1700000000.000200", thread_ts: "1700000000.000100" }; + const { body, headers } = signEvent(event); + const res = await app.inject({ method: "POST", url: "/slack/webhook", headers, payload: body }); + + assert.equal(res.statusCode, 200); + assert.equal(invited, false); + await app.close(); +}); + +test("!invite without a thread is ignored", async () => { + let invited = false; + const slackClient: SlackClient = { + async sendMessage() {}, + async inviteUser() { + invited = true; + }, + async getThreadParent() { + return { text: "Email: jane@example.com" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + // no thread_ts: top-level mention, not a reply + const event = { type: "app_mention", text: "<@U0> !invite", channel: config.SLACK_INVITE_CHANNEL, ts: "1700000000.000200" }; + const { body, headers } = signEvent(event); + const res = await app.inject({ method: "POST", url: "/slack/webhook", headers, payload: body }); + + assert.equal(res.statusCode, 200); + assert.equal(invited, false); + await app.close(); +}); + +test("!invite replies with error when getThreadParent fails", async () => { + let reply: { msg: string; channel: string; thread_ts?: string } | undefined; + let invited = false; + const slackClient: SlackClient = { + async sendMessage(args) { + reply = args; + }, + async inviteUser() { + invited = true; + }, + async getThreadParent() { + throw new Error("slack down"); + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const event = { type: "app_mention", text: "<@U0> !invite", channel: config.SLACK_INVITE_CHANNEL, ts: "1700000000.000200", thread_ts: "1700000000.000100" }; + const { body, headers } = signEvent(event); + const res = await app.inject({ method: "POST", url: "/slack/webhook", headers, payload: body }); + + assert.equal(res.statusCode, 200); + assert.equal(invited, false); + assert.match(reply?.msg ?? "", /couldn't look up/i); + assert.equal(reply?.thread_ts, "1700000000.000100"); + await app.close(); +}); + +test("!invite replies with error when no email found in parent message", async () => { + let reply: { msg: string; channel: string; thread_ts?: string } | undefined; + let invited = false; + const slackClient: SlackClient = { + async sendMessage(args) { + reply = args; + }, + async inviteUser() { + invited = true; + }, + async getThreadParent() { + return { text: "a message with no email" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const event = { type: "app_mention", text: "<@U0> !invite", channel: config.SLACK_INVITE_CHANNEL, ts: "1700000000.000200", thread_ts: "1700000000.000100" }; + const { body, headers } = signEvent(event); + const res = await app.inject({ method: "POST", url: "/slack/webhook", headers, payload: body }); + + assert.equal(res.statusCode, 200); + assert.equal(invited, false); + assert.match(reply?.msg ?? "", /couldn't find an email/i); + await app.close(); +}); + +test("!invite replies with error when inviteUser fails", async () => { + let reply: { msg: string; channel: string; thread_ts?: string } | undefined; + const slackClient: SlackClient = { + async sendMessage(args) { + reply = args; + }, + async inviteUser() { + throw new Error("already_in_team"); + }, + async getThreadParent() { + return { text: "Email: jane@example.com" }; + }, + verifySignature({ timestamp, rawBody, signature }) { + return verifySlackSignature({ + signingSecret: config.SLACK_SIGNING_SECRET, + timestamp, + rawBody, + signature, + }); + }, + }; + const app = buildServer( + { eventsClient: fakes().eventsClient, slackClient, inviteWebhookToken: config.SLACK_INVITE_WEBHOOK_TOKEN, inviteChannel: config.SLACK_INVITE_CHANNEL }, + { logger: false }, + ); + + const event = { type: "app_mention", text: "<@U0> !invite", channel: config.SLACK_INVITE_CHANNEL, ts: "1700000000.000200", thread_ts: "1700000000.000100" }; + const { body, headers } = signEvent(event); + const res = await app.inject({ method: "POST", url: "/slack/webhook", headers, payload: body }); + + assert.equal(res.statusCode, 200); + assert.match(reply?.msg ?? "", /Invite failed for `jane@example\.com`: already_in_team/); + await app.close(); +});