Skip to content

Commit 38380ab

Browse files
authored
Consolidate webhooks repo into virtualcoffee.io (#1501)
Signed-off-by: Joe Karow <58997957+JoeKarow@users.noreply.github.com>
1 parent 04e1e83 commit 38380ab

26 files changed

Lines changed: 2009 additions & 60 deletions

File tree

.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,25 @@ PNPM_FLAGS=--shamefully-hoist
3434
#
3535
# readonly key for membership base:
3636
# MEMBERSHIP_AIRTABLE_API_KEY=token
37+
#
38+
# Webhooks (Slack / Zoom / co-working bot):
39+
#
40+
# These power the Netlify Functions in /netlify/functions/. Most are only needed if you're
41+
# working on those functions locally — production values live in Netlify's env settings.
42+
#
43+
# Slack app credentials (https://api.slack.com/apps):
44+
# SLACK_BOT_TOKEN=xoxb-...
45+
# SLACK_SIGNING_SECRET=...
46+
# SLACK_ANNOUNCEMENTS_CHANNEL=C...
47+
# SLACK_EVENT_ADMIN_CHANNEL=C...
48+
# SLACK_JOIN_LINK=https://join.slack.com/t/...
49+
#
50+
# Zoom app credentials (https://marketplace.zoom.us/develop/apps):
51+
# ZOOM_WEBHOOK_SECRET_TOKEN=...
52+
# ZOOM_WEBHOOK_AUTH=...
53+
# ZOOM_TUESDAYS=https://zoom.us/j/...
54+
# ZOOM_THURSDAYS=https://zoom.us/j/...
55+
#
56+
# Airtable base for co-working rooms (used by scripts/build-rooms.ts and the zoom webhook handler):
57+
# AIRTABLE_COWORKING_BASE=app...
3758
#

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,25 @@ All of the data points have mock data that is used if the required API key isn't
155155

156156
If you'd like to work on a feature that requires an API key, please reach out to a maintainer and we can probably get that going.
157157

158+
## Webhooks
159+
160+
Netlify Functions in `netlify/functions/` handle webhook events for the Slack and Zoom integrations, plus scheduled event reminders. Shared utilities and types live in `netlify/functions/_shared/`.
161+
162+
HTTP endpoints (rewrites configured in `netlify.toml`):
163+
164+
- **`/slack-events`** — Slack Events API. Currently handles `team_join` (welcome message) and `app_home_opened` (publishes the welcome view to a member's App Home).
165+
- **`/slack-interactivity`** — Slack interactivity URL. Shares the same handler as `/slack-events`; required to keep buttons in Slack messages working.
166+
- **`/zoom-meeting-webhook-handler`** — Zoom meeting webhooks. Tracks `meeting.{started,ended,participant_joined,participant_left}` for the co-working room and posts/updates Slack messages and Airtable records.
167+
- **`/join-coffee`** and **`/join-slack`** — short-link redirects to the Tuesday/Thursday Zoom rooms and the Slack invite link.
168+
169+
Scheduled functions (cron schedules declared inline via `export const config`):
170+
171+
- **`event-reminders-daily`**`0 12 * * *` (12pm UTC daily). Posts that day's events to the announcements channel; skips Mondays since the weekly reminder runs that day.
172+
- **`event-reminders-hourly`**`50 * * * *` (50 minutes past every hour). Posts upcoming events starting in the next hour.
173+
- **`event-reminders-weekly`**`0 12 * * 1` (Monday 12pm UTC). Posts the week's events.
174+
175+
The `scripts/build-rooms.ts` prebuild step pulls co-working room records from Airtable into `data/rooms.json`, which is bundled into the Zoom function via `included_files` in `netlify.toml`.
176+
158177
## Adding content
159178

160179
### Resources

data/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

netlify.toml

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,8 @@
1717
autoLaunch = false
1818
framework="next"
1919

20-
# [functions]
21-
# node_bundler = "esbuild"
22-
23-
# [functions.server]
24-
# included_files = ["app/routes/**/*.mdx"]
25-
26-
# [functions."data-members"]
27-
# external_node_modules = ["shiki"]
28-
# included_files = ["members/**/*.{js,json}"]
20+
[functions]
21+
included_files = ["data/*.json"]
2922

3023
[[headers]]
3124
for = "/_next/static/*"
@@ -218,6 +211,21 @@
218211
to = "/.netlify/functions/join-slack"
219212
status = 200
220213

214+
[[redirects]]
215+
from = "/zoom-meeting-webhook-handler"
216+
to = "/.netlify/functions/zoom-meeting-webhook-handler"
217+
status = 200
218+
219+
[[redirects]]
220+
from = "/slack-interactivity"
221+
to = "/.netlify/functions/slack"
222+
status = 200
223+
224+
[[redirects]]
225+
from = "/slack-events"
226+
to = "/.netlify/functions/slack"
227+
status = 200
228+
221229
[[redirects]]
222230
from = "/plausible/js/script.js"
223231
to = "https://plausible.io/js/script.js"

netlify/env.d.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
declare namespace NodeJS {
2+
interface ProcessEnv {
3+
// CMS
4+
CMS_URL?: string;
5+
CMS_TOKEN?: string;
6+
7+
// Slack
8+
SLACK_BOT_TOKEN?: string;
9+
SLACK_SIGNING_SECRET?: string;
10+
SLACK_ANNOUNCEMENTS_CHANNEL?: string;
11+
SLACK_EVENT_ADMIN_CHANNEL?: string;
12+
SLACK_JOIN_LINK?: string;
13+
14+
// Zoom
15+
ZOOM_WEBHOOK_SECRET_TOKEN?: string;
16+
ZOOM_WEBHOOK_AUTH?: string;
17+
ZOOM_TUESDAYS?: string;
18+
ZOOM_THURSDAYS?: string;
19+
20+
// Airtable
21+
AIRTABLE_COWORKING_BASE?: string;
22+
23+
// Test overrides
24+
TEST_SLACK_BOT_TOKEN?: string;
25+
TEST_SLACK_SIGNING_SECRET?: string;
26+
TEST_SLACK_ANNOUNCEMENTS_CHANNEL?: string;
27+
TEST_SLACK_EVENT_ADMIN_CHANNEL?: string;
28+
TEST_ZOOM_WEBHOOK_SECRET_TOKEN?: string;
29+
TEST_ZOOM_WEBHOOK_AUTH?: string;
30+
}
31+
}

netlify/functions/_shared/env.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function requireEnv(name: keyof typeof process.env): string {
2+
const value = process.env[name];
3+
if (!value) {
4+
throw new Error(`Missing required environment variable: ${name}`);
5+
}
6+
return value;
7+
}

netlify/functions/_shared/slack.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { webApi } from '@slack/bolt';
2+
3+
const SLACK_BOT_TOKEN =
4+
process.env.TEST_SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN;
5+
6+
const web = new webApi.WebClient(SLACK_BOT_TOKEN);
7+
8+
export async function postMessage(message: webApi.ChatPostMessageArguments) {
9+
return web.chat.postMessage(message);
10+
}
11+
12+
export async function updateMessage(message: webApi.ChatUpdateArguments) {
13+
return web.chat.update(message);
14+
}
15+
16+
export async function publishView(message: webApi.ViewsPublishArguments) {
17+
return web.views.publish(message);
18+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface CalendarsResponse {
2+
solspace_calendar: {
3+
calendars: Array<{ handle: string }>;
4+
};
5+
}
6+
7+
export interface CalendarEvent {
8+
id: string;
9+
title: string;
10+
startDateLocalized: string;
11+
endDateLocalized: string;
12+
eventCalendarDescription: string;
13+
eventJoinLink?: string;
14+
eventZoomHostCode?: string;
15+
eventSlackAnnouncementsChannelId?: string;
16+
}
17+
18+
export interface EventsResponse {
19+
solspace_calendar: {
20+
events: CalendarEvent[];
21+
};
22+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface Room {
2+
ZoomMeetingId: number;
3+
SlackChannelId: string;
4+
ZoomMeetingInviteUrl: string;
5+
MessageSessionStarted: string;
6+
MessageSessionEnded: string;
7+
ButtonJoin: string;
8+
ButtonStartNew: string;
9+
NoticeTitle: string;
10+
NoticeBody: string;
11+
NoticeConfirm: string;
12+
NoticeCancel: string;
13+
ContextBody?: string;
14+
record_id: string;
15+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import crypto from 'node:crypto';
2+
3+
/**
4+
* Core HMAC-SHA256 verification. Computes `v0=HMAC(secret, message)` and
5+
* performs a timing-safe comparison against the expected signature.
6+
*/
7+
export function verifyHmacSignature(
8+
secret: string,
9+
message: string,
10+
expectedSignature: string,
11+
): boolean {
12+
const computed =
13+
'v0=' +
14+
crypto.createHmac('sha256', secret).update(message, 'utf8').digest('hex');
15+
16+
if (computed.length !== expectedSignature.length) {
17+
return false;
18+
}
19+
20+
return crypto.timingSafeEqual(
21+
Buffer.from(computed, 'utf8'),
22+
Buffer.from(expectedSignature, 'utf8'),
23+
);
24+
}
25+
26+
/**
27+
* Verifies a Slack request signature. Checks timestamp staleness (>300s)
28+
* then validates the HMAC signature.
29+
*/
30+
export function verifySlackRequest(
31+
rawBody: string,
32+
headers: Headers,
33+
secret: string,
34+
): { valid: true } | { valid: false; reason: string } {
35+
const slackSignature = headers.get('x-slack-signature');
36+
const timestamp = headers.get('x-slack-request-timestamp');
37+
38+
const time = Math.floor(Date.now() / 1000);
39+
if (!timestamp || Math.abs(time - Number(timestamp)) > 300) {
40+
return { valid: false, reason: 'Ignore this request.' };
41+
}
42+
43+
const message = `v0:${timestamp}:${rawBody}`;
44+
45+
if (slackSignature && verifyHmacSignature(secret, message, slackSignature)) {
46+
return { valid: true };
47+
}
48+
49+
return { valid: false, reason: 'Verification Failed.' };
50+
}
51+
52+
/**
53+
* Verifies a Zoom webhook signature using the x-zm-signature header.
54+
*/
55+
export function verifyZoomSignature(
56+
rawBody: string,
57+
headers: Headers,
58+
secret: string,
59+
): boolean {
60+
const zmSignature = headers.get('x-zm-signature');
61+
const zmTimestamp = headers.get('x-zm-request-timestamp');
62+
63+
if (!zmSignature || !zmTimestamp) {
64+
return false;
65+
}
66+
67+
const message = `v0:${zmTimestamp}:${rawBody}`;
68+
return verifyHmacSignature(secret, message, zmSignature);
69+
}
70+
71+
/**
72+
* Computes an HMAC-SHA256 hex digest. Used for Zoom's endpoint URL
73+
* validation challenge response.
74+
*/
75+
export function hmacSha256Hex(secret: string, data: string): string {
76+
return crypto.createHmac('sha256', secret).update(data).digest('hex');
77+
}

0 commit comments

Comments
 (0)