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
4 changes: 4 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/hono-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
observabilityMiddleware,
sentryMiddleware,
supabaseAuthMiddleware,
verifySlackWebhook,
verifyStripeWebhook,
} from "./middleware";
import { openAPIDocumentation } from "./openapi";
Expand Down Expand Up @@ -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);
Expand Down
70 changes: 70 additions & 0 deletions apps/api/src/integration/loops.ts
Original file line number Diff line number Diff line change
@@ -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<LoopsContact | null> {
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;
}
62 changes: 62 additions & 0 deletions apps/api/src/integration/slack.ts
Original file line number Diff line number Diff line change
@@ -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<SlackPostMessageResponse> {
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);
}
}
1 change: 1 addition & 0 deletions apps/api/src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./load-test-auth";
export * from "./observability";
export * from "./sentry";
export * from "./slack";
export * from "./supabase";
export * from "./stripe";
63 changes: 63 additions & 0 deletions apps/api/src/middleware/slack.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
125 changes: 125 additions & 0 deletions apps/api/src/routes/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(/<mailto:([^|]+)\|/);
if (mailtoMatch) {
return mailtoMatch[1];
}
const emailMatch = text.match(
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
);
return emailMatch ? emailMatch[0] : null;
}

webhook.post(
"/slack/events",
describeRoute({
tags: [API_TAGS.PRIVATE_SKIP_OPENAPI],
responses: {
200: {
description: "result",
content: {
"application/json": {
schema: resolver(WebhookSuccessSchema),
},
},
},
400: { description: "-" },
500: { description: "-" },
},
}),
async (c) => {
const rawBody = c.get("slackRawBody");
const span = c.get("sentrySpan");

let payload: z.infer<typeof SlackEventSchema>;
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);
},
);