Skip to content

Commit e275f0f

Browse files
feat: add Slack webhook to thread Loops contact status and source (#2222)
* feat: add Slack webhook to thread Loops contact Source field - Add Slack Events API webhook endpoint at /webhook/slack/events - Extract email from Loops bot messages and look up contact Source - Post threaded reply with Source field under Loops bot messages - Add Slack signature verification middleware - Add Loops API integration to fetch contact by email - Add environment variables: SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, LOOPS_API_KEY, LOOPS_SLACK_CHANNEL_ID Co-Authored-By: [email protected] <[email protected]> * fix: address coderabbit feedback - Improve email regex to handle + and other valid characters - Add timeout to Slack API requests with AbortController - Handle Slack API errors properly (check ok: false in JSON response) - Export LoopsContact interface Co-Authored-By: [email protected] <[email protected]> * fix: apply dprint formatting Co-Authored-By: [email protected] <[email protected]> * feat: add contact status categorization (paid/signed up/interested) - Add classifyContactStatus function to categorize contacts based on Source, Intent, Platform - Paid: Source = 'Stripe webhook' - Signed up: Source = 'Supabase webhook' - Interested: Source = 'LANDING_PAGE' + Intent = 'Waitlist' + Platform = 'Windows' or 'Linux' - Update Slack thread reply to show status with details Co-Authored-By: [email protected] <[email protected]> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]>
1 parent cbe9496 commit e275f0f

File tree

8 files changed

+329
-0
lines changed

8 files changed

+329
-0
lines changed

apps/api/src/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export const env = createEnv({
2727
POSTHOG_API_KEY: z.string().min(1),
2828
RESTATE_INGRESS_URL: z.url(),
2929
OVERRIDE_AUTH: z.string().optional(),
30+
SLACK_BOT_TOKEN: z.string().optional(),
31+
SLACK_SIGNING_SECRET: z.string().optional(),
32+
LOOPS_API_KEY: z.string().optional(),
33+
LOOPS_SLACK_CHANNEL_ID: z.string().optional(),
3034
},
3135
runtimeEnv: Bun.env,
3236
emptyStringAsUndefined: true,

apps/api/src/hono-bindings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type AppBindings = {
99
stripeEvent: Stripe.Event;
1010
stripeRawBody: string;
1111
stripeSignature: string;
12+
slackRawBody: string;
13+
slackTimestamp: string;
1214
sentrySpan: Sentry.Span;
1315
supabaseUserId: string | undefined;
1416
supabaseClient: SupabaseClient | undefined;

apps/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
observabilityMiddleware,
1717
sentryMiddleware,
1818
supabaseAuthMiddleware,
19+
verifySlackWebhook,
1920
verifyStripeWebhook,
2021
} from "./middleware";
2122
import { openAPIDocumentation } from "./openapi";
@@ -51,6 +52,7 @@ app.use("*", (c, next) => {
5152

5253
app.use("/chat/completions", loadTestOverride, supabaseAuthMiddleware);
5354
app.use("/webhook/stripe", verifyStripeWebhook);
55+
app.use("/webhook/slack/events", verifySlackWebhook);
5456

5557
if (env.NODE_ENV !== "development") {
5658
app.use("/listen", loadTestOverride, supabaseAuthMiddleware);

apps/api/src/integration/loops.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { env } from "../env";
2+
3+
export interface LoopsContact {
4+
id: string;
5+
email: string;
6+
source?: string;
7+
intent?: string;
8+
platform?: string;
9+
firstName?: string;
10+
lastName?: string;
11+
userGroup?: string;
12+
subscribed: boolean;
13+
createdAt: string;
14+
updatedAt: string;
15+
}
16+
17+
export type ContactStatus = "paid" | "signed up" | "interested" | "unknown";
18+
19+
export function classifyContactStatus(contact: LoopsContact): ContactStatus {
20+
const { source, intent, platform } = contact;
21+
22+
if (source === "Stripe webhook") {
23+
return "paid";
24+
}
25+
26+
if (source === "Supabase webhook") {
27+
return "signed up";
28+
}
29+
30+
if (
31+
source === "LANDING_PAGE" &&
32+
intent === "Waitlist" &&
33+
(platform === "Windows" || platform === "Linux")
34+
) {
35+
return "interested";
36+
}
37+
38+
return "unknown";
39+
}
40+
41+
export async function getContactByEmail(
42+
email: string,
43+
): Promise<LoopsContact | null> {
44+
if (!env.LOOPS_API_KEY) {
45+
throw new Error("LOOPS_API_KEY not configured");
46+
}
47+
48+
const response = await fetch(
49+
`https://app.loops.so/api/v1/contacts/find?email=${encodeURIComponent(email)}`,
50+
{
51+
method: "GET",
52+
headers: {
53+
Authorization: `Bearer ${env.LOOPS_API_KEY}`,
54+
},
55+
},
56+
);
57+
58+
if (!response.ok) {
59+
if (response.status === 404) {
60+
return null;
61+
}
62+
throw new Error(`Failed to fetch contact: ${response.statusText}`);
63+
}
64+
65+
const contacts = await response.json();
66+
if (Array.isArray(contacts) && contacts.length > 0) {
67+
return contacts[0] as LoopsContact;
68+
}
69+
return null;
70+
}

apps/api/src/integration/slack.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { env } from "../env";
2+
3+
interface SlackPostMessageResponse {
4+
ok: boolean;
5+
channel?: string;
6+
ts?: string;
7+
error?: string;
8+
}
9+
10+
const SLACK_TIMEOUT_MS = 5000;
11+
12+
export async function postThreadReply(
13+
channel: string,
14+
threadTs: string,
15+
text: string,
16+
): Promise<SlackPostMessageResponse> {
17+
if (!env.SLACK_BOT_TOKEN) {
18+
throw new Error("SLACK_BOT_TOKEN not configured");
19+
}
20+
21+
const controller = new AbortController();
22+
const timeoutId = setTimeout(() => controller.abort(), SLACK_TIMEOUT_MS);
23+
24+
try {
25+
const response = await fetch("https://slack.com/api/chat.postMessage", {
26+
method: "POST",
27+
headers: {
28+
Authorization: `Bearer ${env.SLACK_BOT_TOKEN}`,
29+
"Content-Type": "application/json",
30+
},
31+
body: JSON.stringify({
32+
channel,
33+
thread_ts: threadTs,
34+
text,
35+
}),
36+
signal: controller.signal,
37+
});
38+
39+
if (!response.ok) {
40+
throw new Error(
41+
`Failed to post Slack message: ${response.status} ${response.statusText}`,
42+
);
43+
}
44+
45+
const result: SlackPostMessageResponse = await response.json();
46+
47+
if (!result.ok) {
48+
throw new Error(`Slack API error: ${result.error || "unknown error"}`);
49+
}
50+
51+
return result;
52+
} catch (error) {
53+
if (error instanceof Error && error.name === "AbortError") {
54+
throw new Error(
55+
`Slack API request timed out after ${SLACK_TIMEOUT_MS}ms`,
56+
);
57+
}
58+
throw error;
59+
} finally {
60+
clearTimeout(timeoutId);
61+
}
62+
}

apps/api/src/middleware/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./load-test-auth";
22
export * from "./observability";
33
export * from "./sentry";
4+
export * from "./slack";
45
export * from "./supabase";
56
export * from "./stripe";

apps/api/src/middleware/slack.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as Sentry from "@sentry/bun";
2+
import { createMiddleware } from "hono/factory";
3+
4+
import { env } from "../env";
5+
6+
export const verifySlackWebhook = createMiddleware<{
7+
Variables: {
8+
slackRawBody: string;
9+
slackTimestamp: string;
10+
};
11+
}>(async (c, next) => {
12+
if (!env.SLACK_SIGNING_SECRET) {
13+
return c.text("slack_signing_secret_not_configured", 500);
14+
}
15+
16+
const signature = c.req.header("X-Slack-Signature");
17+
const timestamp = c.req.header("X-Slack-Request-Timestamp");
18+
19+
if (!signature || !timestamp) {
20+
return c.text("missing_slack_signature", 400);
21+
}
22+
23+
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
24+
if (Number.parseInt(timestamp) < fiveMinutesAgo) {
25+
return c.text("slack_request_too_old", 400);
26+
}
27+
28+
const body = await c.req.text();
29+
30+
try {
31+
const sigBaseString = `v0:${timestamp}:${body}`;
32+
const encoder = new TextEncoder();
33+
const key = await crypto.subtle.importKey(
34+
"raw",
35+
encoder.encode(env.SLACK_SIGNING_SECRET),
36+
{ name: "HMAC", hash: "SHA-256" },
37+
false,
38+
["sign"],
39+
);
40+
const signatureBuffer = await crypto.subtle.sign(
41+
"HMAC",
42+
key,
43+
encoder.encode(sigBaseString),
44+
);
45+
const computedSignature = `v0=${Array.from(new Uint8Array(signatureBuffer))
46+
.map((b) => b.toString(16).padStart(2, "0"))
47+
.join("")}`;
48+
49+
if (computedSignature !== signature) {
50+
return c.text("invalid_slack_signature", 400);
51+
}
52+
53+
c.set("slackRawBody", body);
54+
c.set("slackTimestamp", timestamp);
55+
await next();
56+
} catch (err) {
57+
Sentry.captureException(err, {
58+
tags: { webhook: "slack", step: "signature_verification" },
59+
});
60+
const message = err instanceof Error ? err.message : "unknown_error";
61+
return c.text(message, 400);
62+
}
63+
});

apps/api/src/routes/webhook.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { z } from "zod";
77
import { syncBillingBridge } from "../billing";
88
import { env } from "../env";
99
import type { AppBindings } from "../hono-bindings";
10+
import { classifyContactStatus, getContactByEmail } from "../integration/loops";
11+
import { postThreadReply } from "../integration/slack";
1012
import { stripeSync } from "../integration/stripe-sync";
1113
import { API_TAGS } from "./constants";
1214

@@ -78,3 +80,126 @@ webhook.post(
7880
return c.json({ ok: true }, 200);
7981
},
8082
);
83+
84+
const SlackEventSchema = z.object({
85+
type: z.string(),
86+
challenge: z.string().optional(),
87+
event: z
88+
.object({
89+
type: z.string(),
90+
channel: z.string().optional(),
91+
ts: z.string().optional(),
92+
text: z.string().optional(),
93+
bot_id: z.string().optional(),
94+
user: z.string().optional(),
95+
})
96+
.optional(),
97+
});
98+
99+
function extractEmailFromLoopsMessage(text: string): string | null {
100+
const mailtoMatch = text.match(/<mailto:([^|]+)\|/);
101+
if (mailtoMatch) {
102+
return mailtoMatch[1];
103+
}
104+
const emailMatch = text.match(
105+
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
106+
);
107+
return emailMatch ? emailMatch[0] : null;
108+
}
109+
110+
webhook.post(
111+
"/slack/events",
112+
describeRoute({
113+
tags: [API_TAGS.PRIVATE_SKIP_OPENAPI],
114+
responses: {
115+
200: {
116+
description: "result",
117+
content: {
118+
"application/json": {
119+
schema: resolver(WebhookSuccessSchema),
120+
},
121+
},
122+
},
123+
400: { description: "-" },
124+
500: { description: "-" },
125+
},
126+
}),
127+
async (c) => {
128+
const rawBody = c.get("slackRawBody");
129+
const span = c.get("sentrySpan");
130+
131+
let payload: z.infer<typeof SlackEventSchema>;
132+
try {
133+
payload = SlackEventSchema.parse(JSON.parse(rawBody));
134+
} catch {
135+
return c.json({ error: "invalid_payload" }, 400);
136+
}
137+
138+
if (payload.type === "url_verification" && payload.challenge) {
139+
return c.json({ challenge: payload.challenge }, 200);
140+
}
141+
142+
if (payload.type !== "event_callback" || !payload.event) {
143+
return c.json({ ok: true }, 200);
144+
}
145+
146+
const event = payload.event;
147+
span?.setAttribute("slack.event_type", event.type);
148+
149+
if (event.type !== "message" || !event.bot_id) {
150+
return c.json({ ok: true }, 200);
151+
}
152+
153+
if (
154+
env.LOOPS_SLACK_CHANNEL_ID &&
155+
event.channel !== env.LOOPS_SLACK_CHANNEL_ID
156+
) {
157+
return c.json({ ok: true }, 200);
158+
}
159+
160+
if (
161+
!event.text ||
162+
!event.text.includes("was added to your account") ||
163+
!event.ts ||
164+
!event.channel
165+
) {
166+
return c.json({ ok: true }, 200);
167+
}
168+
169+
const email = extractEmailFromLoopsMessage(event.text);
170+
if (!email) {
171+
return c.json({ ok: true }, 200);
172+
}
173+
174+
try {
175+
const contact = await getContactByEmail(email);
176+
if (!contact) {
177+
return c.json({ ok: true }, 200);
178+
}
179+
180+
const status = classifyContactStatus(contact);
181+
const source = contact.source || "Unknown";
182+
const details = [
183+
`Source: ${source}`,
184+
contact.intent ? `Intent: ${contact.intent}` : null,
185+
contact.platform ? `Platform: ${contact.platform}` : null,
186+
]
187+
.filter(Boolean)
188+
.join(", ");
189+
190+
await postThreadReply(
191+
event.channel,
192+
event.ts,
193+
`Status: ${status} (${details})`,
194+
);
195+
} catch (error) {
196+
Sentry.captureException(error, {
197+
tags: { webhook: "slack", step: "loops_source_thread" },
198+
extra: { email },
199+
});
200+
return c.json({ error: "failed_to_process" }, 500);
201+
}
202+
203+
return c.json({ ok: true }, 200);
204+
},
205+
);

0 commit comments

Comments
 (0)