diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 60ad1e536e..bd4ed2238b 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -27,6 +27,10 @@ export const env = createEnv({ POSTHOG_API_KEY: z.string().min(1), RESTATE_INGRESS_URL: z.url(), OVERRIDE_AUTH: z.string().optional(), + SLACK_BOT_TOKEN: z.string().optional(), + SLACK_SIGNING_SECRET: z.string().optional(), + LOOPS_API_KEY: z.string().optional(), + LOOPS_SLACK_CHANNEL_ID: z.string().optional(), }, runtimeEnv: Bun.env, emptyStringAsUndefined: true, diff --git a/apps/api/src/hono-bindings.ts b/apps/api/src/hono-bindings.ts index 97c4dafb80..7cf1e613df 100644 --- a/apps/api/src/hono-bindings.ts +++ b/apps/api/src/hono-bindings.ts @@ -9,6 +9,8 @@ export type AppBindings = { stripeEvent: Stripe.Event; stripeRawBody: string; stripeSignature: string; + slackRawBody: string; + slackTimestamp: string; sentrySpan: Sentry.Span; supabaseUserId: string | undefined; supabaseClient: SupabaseClient | undefined; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4fa6680ba7..9fd4f727cb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,7 @@ import { observabilityMiddleware, sentryMiddleware, supabaseAuthMiddleware, + verifySlackWebhook, verifyStripeWebhook, } from "./middleware"; import { openAPIDocumentation } from "./openapi"; @@ -51,6 +52,7 @@ app.use("*", (c, next) => { app.use("/chat/completions", loadTestOverride, supabaseAuthMiddleware); app.use("/webhook/stripe", verifyStripeWebhook); +app.use("/webhook/slack/events", verifySlackWebhook); if (env.NODE_ENV !== "development") { app.use("/listen", loadTestOverride, supabaseAuthMiddleware); diff --git a/apps/api/src/integration/loops.ts b/apps/api/src/integration/loops.ts new file mode 100644 index 0000000000..0b96f81e10 --- /dev/null +++ b/apps/api/src/integration/loops.ts @@ -0,0 +1,70 @@ +import { env } from "../env"; + +export interface LoopsContact { + id: string; + email: string; + source?: string; + intent?: string; + platform?: string; + firstName?: string; + lastName?: string; + userGroup?: string; + subscribed: boolean; + createdAt: string; + updatedAt: string; +} + +export type ContactStatus = "paid" | "signed up" | "interested" | "unknown"; + +export function classifyContactStatus(contact: LoopsContact): ContactStatus { + const { source, intent, platform } = contact; + + if (source === "Stripe webhook") { + return "paid"; + } + + if (source === "Supabase webhook") { + return "signed up"; + } + + if ( + source === "LANDING_PAGE" && + intent === "Waitlist" && + (platform === "Windows" || platform === "Linux") + ) { + return "interested"; + } + + return "unknown"; +} + +export async function getContactByEmail( + email: string, +): Promise { + if (!env.LOOPS_API_KEY) { + throw new Error("LOOPS_API_KEY not configured"); + } + + const response = await fetch( + `https://app.loops.so/api/v1/contacts/find?email=${encodeURIComponent(email)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${env.LOOPS_API_KEY}`, + }, + }, + ); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`Failed to fetch contact: ${response.statusText}`); + } + + const contacts = await response.json(); + if (Array.isArray(contacts) && contacts.length > 0) { + return contacts[0] as LoopsContact; + } + return null; +} diff --git a/apps/api/src/integration/slack.ts b/apps/api/src/integration/slack.ts new file mode 100644 index 0000000000..b5cebda28d --- /dev/null +++ b/apps/api/src/integration/slack.ts @@ -0,0 +1,62 @@ +import { env } from "../env"; + +interface SlackPostMessageResponse { + ok: boolean; + channel?: string; + ts?: string; + error?: string; +} + +const SLACK_TIMEOUT_MS = 5000; + +export async function postThreadReply( + channel: string, + threadTs: string, + text: string, +): Promise { + if (!env.SLACK_BOT_TOKEN) { + throw new Error("SLACK_BOT_TOKEN not configured"); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SLACK_TIMEOUT_MS); + + try { + const response = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + Authorization: `Bearer ${env.SLACK_BOT_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + channel, + thread_ts: threadTs, + text, + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `Failed to post Slack message: ${response.status} ${response.statusText}`, + ); + } + + const result: SlackPostMessageResponse = await response.json(); + + if (!result.ok) { + throw new Error(`Slack API error: ${result.error || "unknown error"}`); + } + + return result; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + `Slack API request timed out after ${SLACK_TIMEOUT_MS}ms`, + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/apps/api/src/middleware/index.ts b/apps/api/src/middleware/index.ts index 54e003bc5d..9f3773a859 100644 --- a/apps/api/src/middleware/index.ts +++ b/apps/api/src/middleware/index.ts @@ -1,5 +1,6 @@ export * from "./load-test-auth"; export * from "./observability"; export * from "./sentry"; +export * from "./slack"; export * from "./supabase"; export * from "./stripe"; diff --git a/apps/api/src/middleware/slack.ts b/apps/api/src/middleware/slack.ts new file mode 100644 index 0000000000..e4c56f45ec --- /dev/null +++ b/apps/api/src/middleware/slack.ts @@ -0,0 +1,63 @@ +import * as Sentry from "@sentry/bun"; +import { createMiddleware } from "hono/factory"; + +import { env } from "../env"; + +export const verifySlackWebhook = createMiddleware<{ + Variables: { + slackRawBody: string; + slackTimestamp: string; + }; +}>(async (c, next) => { + if (!env.SLACK_SIGNING_SECRET) { + return c.text("slack_signing_secret_not_configured", 500); + } + + const signature = c.req.header("X-Slack-Signature"); + const timestamp = c.req.header("X-Slack-Request-Timestamp"); + + if (!signature || !timestamp) { + return c.text("missing_slack_signature", 400); + } + + const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5; + if (Number.parseInt(timestamp) < fiveMinutesAgo) { + return c.text("slack_request_too_old", 400); + } + + const body = await c.req.text(); + + try { + const sigBaseString = `v0:${timestamp}:${body}`; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(env.SLACK_SIGNING_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signatureBuffer = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(sigBaseString), + ); + const computedSignature = `v0=${Array.from(new Uint8Array(signatureBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("")}`; + + if (computedSignature !== signature) { + return c.text("invalid_slack_signature", 400); + } + + c.set("slackRawBody", body); + c.set("slackTimestamp", timestamp); + await next(); + } catch (err) { + Sentry.captureException(err, { + tags: { webhook: "slack", step: "signature_verification" }, + }); + const message = err instanceof Error ? err.message : "unknown_error"; + return c.text(message, 400); + } +}); diff --git a/apps/api/src/routes/webhook.ts b/apps/api/src/routes/webhook.ts index 1a98acea53..757b7621f3 100644 --- a/apps/api/src/routes/webhook.ts +++ b/apps/api/src/routes/webhook.ts @@ -7,6 +7,8 @@ import { z } from "zod"; import { syncBillingBridge } from "../billing"; import { env } from "../env"; import type { AppBindings } from "../hono-bindings"; +import { classifyContactStatus, getContactByEmail } from "../integration/loops"; +import { postThreadReply } from "../integration/slack"; import { stripeSync } from "../integration/stripe-sync"; import { API_TAGS } from "./constants"; @@ -78,3 +80,126 @@ webhook.post( return c.json({ ok: true }, 200); }, ); + +const SlackEventSchema = z.object({ + type: z.string(), + challenge: z.string().optional(), + event: z + .object({ + type: z.string(), + channel: z.string().optional(), + ts: z.string().optional(), + text: z.string().optional(), + bot_id: z.string().optional(), + user: z.string().optional(), + }) + .optional(), +}); + +function extractEmailFromLoopsMessage(text: string): string | null { + const mailtoMatch = text.match(/ { + const rawBody = c.get("slackRawBody"); + const span = c.get("sentrySpan"); + + let payload: z.infer; + try { + payload = SlackEventSchema.parse(JSON.parse(rawBody)); + } catch { + return c.json({ error: "invalid_payload" }, 400); + } + + if (payload.type === "url_verification" && payload.challenge) { + return c.json({ challenge: payload.challenge }, 200); + } + + if (payload.type !== "event_callback" || !payload.event) { + return c.json({ ok: true }, 200); + } + + const event = payload.event; + span?.setAttribute("slack.event_type", event.type); + + if (event.type !== "message" || !event.bot_id) { + return c.json({ ok: true }, 200); + } + + if ( + env.LOOPS_SLACK_CHANNEL_ID && + event.channel !== env.LOOPS_SLACK_CHANNEL_ID + ) { + return c.json({ ok: true }, 200); + } + + if ( + !event.text || + !event.text.includes("was added to your account") || + !event.ts || + !event.channel + ) { + return c.json({ ok: true }, 200); + } + + const email = extractEmailFromLoopsMessage(event.text); + if (!email) { + return c.json({ ok: true }, 200); + } + + try { + const contact = await getContactByEmail(email); + if (!contact) { + return c.json({ ok: true }, 200); + } + + const status = classifyContactStatus(contact); + const source = contact.source || "Unknown"; + const details = [ + `Source: ${source}`, + contact.intent ? `Intent: ${contact.intent}` : null, + contact.platform ? `Platform: ${contact.platform}` : null, + ] + .filter(Boolean) + .join(", "); + + await postThreadReply( + event.channel, + event.ts, + `Status: ${status} (${details})`, + ); + } catch (error) { + Sentry.captureException(error, { + tags: { webhook: "slack", step: "loops_source_thread" }, + extra: { email }, + }); + return c.json({ error: "failed_to_process" }, 500); + } + + return c.json({ ok: true }, 200); + }, +);