diff --git a/.env.example b/.env.example index e7f11f580..48c4c18f1 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,25 @@ PNPM_FLAGS=--shamefully-hoist # # readonly key for membership base: # MEMBERSHIP_AIRTABLE_API_KEY=token +# +# Webhooks (Slack / Zoom / co-working bot): +# +# These power the Netlify Functions in /netlify/functions/. Most are only needed if you're +# working on those functions locally — production values live in Netlify's env settings. +# +# Slack app credentials (https://api.slack.com/apps): +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_SIGNING_SECRET=... +# SLACK_ANNOUNCEMENTS_CHANNEL=C... +# SLACK_EVENT_ADMIN_CHANNEL=C... +# SLACK_JOIN_LINK=https://join.slack.com/t/... +# +# Zoom app credentials (https://marketplace.zoom.us/develop/apps): +# ZOOM_WEBHOOK_SECRET_TOKEN=... +# ZOOM_WEBHOOK_AUTH=... +# ZOOM_TUESDAYS=https://zoom.us/j/... +# ZOOM_THURSDAYS=https://zoom.us/j/... +# +# Airtable base for co-working rooms (used by scripts/build-rooms.ts and the zoom webhook handler): +# AIRTABLE_COWORKING_BASE=app... # \ No newline at end of file diff --git a/README.md b/README.md index 582b3a7f6..232440272 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,25 @@ All of the data points have mock data that is used if the required API key isn't 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. +## Webhooks + +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/`. + +HTTP endpoints (rewrites configured in `netlify.toml`): + +- **`/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). +- **`/slack-interactivity`** — Slack interactivity URL. Shares the same handler as `/slack-events`; required to keep buttons in Slack messages working. +- **`/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. +- **`/join-coffee`** and **`/join-slack`** — short-link redirects to the Tuesday/Thursday Zoom rooms and the Slack invite link. + +Scheduled functions (cron schedules declared inline via `export const config`): + +- **`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. +- **`event-reminders-hourly`** — `50 * * * *` (50 minutes past every hour). Posts upcoming events starting in the next hour. +- **`event-reminders-weekly`** — `0 12 * * 1` (Monday 12pm UTC). Posts the week's events. + +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`. + ## Adding content ### Resources diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netlify.toml b/netlify.toml index 7ac082e90..b79aa1192 100644 --- a/netlify.toml +++ b/netlify.toml @@ -17,15 +17,8 @@ autoLaunch = false framework="next" -# [functions] -# node_bundler = "esbuild" - -# [functions.server] -# included_files = ["app/routes/**/*.mdx"] - -# [functions."data-members"] -# external_node_modules = ["shiki"] -# included_files = ["members/**/*.{js,json}"] +[functions] + included_files = ["data/*.json"] [[headers]] for = "/_next/static/*" @@ -218,6 +211,21 @@ to = "/.netlify/functions/join-slack" status = 200 +[[redirects]] + from = "/zoom-meeting-webhook-handler" + to = "/.netlify/functions/zoom-meeting-webhook-handler" + status = 200 + +[[redirects]] + from = "/slack-interactivity" + to = "/.netlify/functions/slack" + status = 200 + +[[redirects]] + from = "/slack-events" + to = "/.netlify/functions/slack" + status = 200 + [[redirects]] from = "/plausible/js/script.js" to = "https://plausible.io/js/script.js" diff --git a/netlify/env.d.ts b/netlify/env.d.ts new file mode 100644 index 000000000..1f32cfc7b --- /dev/null +++ b/netlify/env.d.ts @@ -0,0 +1,31 @@ +declare namespace NodeJS { + interface ProcessEnv { + // CMS + CMS_URL?: string; + CMS_TOKEN?: string; + + // Slack + SLACK_BOT_TOKEN?: string; + SLACK_SIGNING_SECRET?: string; + SLACK_ANNOUNCEMENTS_CHANNEL?: string; + SLACK_EVENT_ADMIN_CHANNEL?: string; + SLACK_JOIN_LINK?: string; + + // Zoom + ZOOM_WEBHOOK_SECRET_TOKEN?: string; + ZOOM_WEBHOOK_AUTH?: string; + ZOOM_TUESDAYS?: string; + ZOOM_THURSDAYS?: string; + + // Airtable + AIRTABLE_COWORKING_BASE?: string; + + // Test overrides + TEST_SLACK_BOT_TOKEN?: string; + TEST_SLACK_SIGNING_SECRET?: string; + TEST_SLACK_ANNOUNCEMENTS_CHANNEL?: string; + TEST_SLACK_EVENT_ADMIN_CHANNEL?: string; + TEST_ZOOM_WEBHOOK_SECRET_TOKEN?: string; + TEST_ZOOM_WEBHOOK_AUTH?: string; + } +} diff --git a/netlify/functions/_shared/env.ts b/netlify/functions/_shared/env.ts new file mode 100644 index 000000000..8c8c5681f --- /dev/null +++ b/netlify/functions/_shared/env.ts @@ -0,0 +1,7 @@ +export function requireEnv(name: keyof typeof process.env): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} diff --git a/netlify/functions/_shared/slack.ts b/netlify/functions/_shared/slack.ts new file mode 100644 index 000000000..608395684 --- /dev/null +++ b/netlify/functions/_shared/slack.ts @@ -0,0 +1,18 @@ +import { webApi } from '@slack/bolt'; + +const SLACK_BOT_TOKEN = + process.env.TEST_SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN; + +const web = new webApi.WebClient(SLACK_BOT_TOKEN); + +export async function postMessage(message: webApi.ChatPostMessageArguments) { + return web.chat.postMessage(message); +} + +export async function updateMessage(message: webApi.ChatUpdateArguments) { + return web.chat.update(message); +} + +export async function publishView(message: webApi.ViewsPublishArguments) { + return web.views.publish(message); +} diff --git a/netlify/functions/_shared/types/cms.ts b/netlify/functions/_shared/types/cms.ts new file mode 100644 index 000000000..f1f3e30d0 --- /dev/null +++ b/netlify/functions/_shared/types/cms.ts @@ -0,0 +1,22 @@ +export interface CalendarsResponse { + solspace_calendar: { + calendars: Array<{ handle: string }>; + }; +} + +export interface CalendarEvent { + id: string; + title: string; + startDateLocalized: string; + endDateLocalized: string; + eventCalendarDescription: string; + eventJoinLink?: string; + eventZoomHostCode?: string; + eventSlackAnnouncementsChannelId?: string; +} + +export interface EventsResponse { + solspace_calendar: { + events: CalendarEvent[]; + }; +} diff --git a/netlify/functions/_shared/types/room.ts b/netlify/functions/_shared/types/room.ts new file mode 100644 index 000000000..8ce90030f --- /dev/null +++ b/netlify/functions/_shared/types/room.ts @@ -0,0 +1,15 @@ +export interface Room { + ZoomMeetingId: number; + SlackChannelId: string; + ZoomMeetingInviteUrl: string; + MessageSessionStarted: string; + MessageSessionEnded: string; + ButtonJoin: string; + ButtonStartNew: string; + NoticeTitle: string; + NoticeBody: string; + NoticeConfirm: string; + NoticeCancel: string; + ContextBody?: string; + record_id: string; +} diff --git a/netlify/functions/_shared/verify.ts b/netlify/functions/_shared/verify.ts new file mode 100644 index 000000000..96bfa1b54 --- /dev/null +++ b/netlify/functions/_shared/verify.ts @@ -0,0 +1,77 @@ +import crypto from 'node:crypto'; + +/** + * Core HMAC-SHA256 verification. Computes `v0=HMAC(secret, message)` and + * performs a timing-safe comparison against the expected signature. + */ +export function verifyHmacSignature( + secret: string, + message: string, + expectedSignature: string, +): boolean { + const computed = + 'v0=' + + crypto.createHmac('sha256', secret).update(message, 'utf8').digest('hex'); + + if (computed.length !== expectedSignature.length) { + return false; + } + + return crypto.timingSafeEqual( + Buffer.from(computed, 'utf8'), + Buffer.from(expectedSignature, 'utf8'), + ); +} + +/** + * Verifies a Slack request signature. Checks timestamp staleness (>300s) + * then validates the HMAC signature. + */ +export function verifySlackRequest( + rawBody: string, + headers: Headers, + secret: string, +): { valid: true } | { valid: false; reason: string } { + const slackSignature = headers.get('x-slack-signature'); + const timestamp = headers.get('x-slack-request-timestamp'); + + const time = Math.floor(Date.now() / 1000); + if (!timestamp || Math.abs(time - Number(timestamp)) > 300) { + return { valid: false, reason: 'Ignore this request.' }; + } + + const message = `v0:${timestamp}:${rawBody}`; + + if (slackSignature && verifyHmacSignature(secret, message, slackSignature)) { + return { valid: true }; + } + + return { valid: false, reason: 'Verification Failed.' }; +} + +/** + * Verifies a Zoom webhook signature using the x-zm-signature header. + */ +export function verifyZoomSignature( + rawBody: string, + headers: Headers, + secret: string, +): boolean { + const zmSignature = headers.get('x-zm-signature'); + const zmTimestamp = headers.get('x-zm-request-timestamp'); + + if (!zmSignature || !zmTimestamp) { + return false; + } + + const message = `v0:${zmTimestamp}:${rawBody}`; + return verifyHmacSignature(secret, message, zmSignature); +} + +/** + * Computes an HMAC-SHA256 hex digest. Used for Zoom's endpoint URL + * validation challenge response. + */ +export function hmacSha256Hex(secret: string, data: string): string { + return crypto.createHmac('sha256', secret).update(data).digest('hex'); +} diff --git a/netlify/functions/event-reminders-daily/index.ts b/netlify/functions/event-reminders-daily/index.ts new file mode 100644 index 000000000..beab76cac --- /dev/null +++ b/netlify/functions/event-reminders-daily/index.ts @@ -0,0 +1,162 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { DateTime } from 'luxon'; +import { postMessage } from '../_shared/slack'; +import slackify from 'slackify-html'; +import { requireEnv } from '../_shared/env'; +import type { Config } from '@netlify/functions'; +import type { CalendarsResponse, EventsResponse } from '../_shared/types/cms'; + +const SLACK_ANNOUNCEMENTS_CHANNEL = + process.env.TEST_SLACK_ANNOUNCEMENTS_CHANNEL || + requireEnv('SLACK_ANNOUNCEMENTS_CHANNEL'); + +const DEFAULT_SLACK_EVENT_CHANNEL = 'C017WAKN883'; + +const calendarsQuery = gql` + query getCalendars { + solspace_calendar { + calendars { + handle + } + } + } +`; + +function createEventsQuery(calendars: CalendarsResponse) { + return gql` + query getEvents($rangeStart: String!, $rangeEnd: String!) { + solspace_calendar { + events(rangeStart: $rangeStart, rangeEnd: $rangeEnd) { + id + title + startDateLocalized + endDateLocalized + ${calendars.solspace_calendar.calendars.map( + ({ handle }) => ` + ... on ${handle}_Event { + eventCalendarDescription + eventJoinLink + eventZoomHostCode + eventSlackAnnouncementsChannelId + id + } + `, + )} + } + } + } +`; +} + +export default async (req: Request) => { + const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + + const rangeStart = DateTime.now().setZone('America/New_York').toISO(); + const rangeEnd = DateTime.now() + .setZone('America/New_York') + .plus({ days: 1 }) + .toISO(); + + console.log('Fetching events', rangeStart, rangeEnd); + + try { + const calendarsResponse = + await graphQLClient.request(calendarsQuery); + + const eventsResponse = await graphQLClient.request( + createEventsQuery(calendarsResponse), + { + rangeStart, + rangeEnd, + }, + ); + + const eventsList = eventsResponse.solspace_calendar.events; + if (eventsList && eventsList.length) { + const dayCheck = new Date(); + if (dayCheck.getDay() === 1) { + // don't run this one on monday, since the weekly one runs on monday + return; + } + + const dailyMessage = { + channel: SLACK_ANNOUNCEMENTS_CHANNEL, + text: `Today's events are: ${eventsList + .map((event) => { + return `${event.title}: ${DateTime.fromISO( + event.startDateLocalized, + ).toFormat('EEEE, fff')}`; + }) + .join(', ')}`, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'header' as const, + text: { + type: 'plain_text' as const, + text: "📆 Today's Events Are:", + emoji: true, + }, + }, + ...eventsList.reduce[]>((list, event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + return [ + ...list, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: slackify(event.eventCalendarDescription), + }, + ], + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `ℹ️ Link to join will be posted in <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }> about 10 minutes before the event starts.`, + }, + ], + }, + { + type: 'divider', + }, + ]; + }, []), + ], + }; + + await postMessage(dailyMessage); + } + + return new Response(null, { status: 200 }); + } catch (e) { + console.error(e); + return new Response(null, { status: 500 }); + } +}; + +export const config: Config = { + schedule: '0 12 * * *', +}; diff --git a/netlify/functions/event-reminders-hourly/index.ts b/netlify/functions/event-reminders-hourly/index.ts new file mode 100644 index 000000000..d1f87f281 --- /dev/null +++ b/netlify/functions/event-reminders-hourly/index.ts @@ -0,0 +1,290 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { DateTime } from 'luxon'; +import { postMessage } from '../_shared/slack'; +import slackify from 'slackify-html'; +import { requireEnv } from '../_shared/env'; +import type { Config } from '@netlify/functions'; +import type { CalendarsResponse, EventsResponse } from '../_shared/types/cms'; + +const SLACK_ANNOUNCEMENTS_CHANNEL = + process.env.TEST_SLACK_ANNOUNCEMENTS_CHANNEL || + requireEnv('SLACK_ANNOUNCEMENTS_CHANNEL'); + +const SLACK_EVENT_ADMIN_CHANNEL = + process.env.TEST_SLACK_EVENT_ADMIN_CHANNEL || + requireEnv('SLACK_EVENT_ADMIN_CHANNEL'); + +const DEFAULT_SLACK_EVENT_CHANNEL = 'C017WAKN883'; + +const calendarsQuery = gql` + query getCalendars { + solspace_calendar { + calendars { + handle + } + } + } +`; + +function createEventsQuery(calendars: CalendarsResponse) { + return gql` + query getEvents($rangeStart: String!, $rangeEnd: String!) { + solspace_calendar { + events(rangeStart: $rangeStart, rangeEnd: $rangeEnd) { + id + title + startDateLocalized + endDateLocalized + ${calendars.solspace_calendar.calendars.map( + ({ handle }) => ` + ... on ${handle}_Event { + eventCalendarDescription + eventJoinLink + eventZoomHostCode + id + eventSlackAnnouncementsChannelId + } + `, + )} + } + } + } +`; +} + +export default async (req: Request) => { + const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + + const rangeStart = DateTime.now() + .setZone('America/New_York') + .set({ hour: 0 }) + .toISO(); + const rangeEnd = DateTime.now() + .setZone('America/New_York') + .plus({ hours: 1 }) + .toISO(); + + console.log('Fetching events', rangeStart, rangeEnd); + + try { + const calendarsResponse = + await graphQLClient.request(calendarsQuery); + + const eventsResponse = await graphQLClient.request( + createEventsQuery(calendarsResponse), + { + rangeStart, + rangeEnd, + }, + ); + + const eventsList = eventsResponse.solspace_calendar.events; + if (eventsList && eventsList.length) { + // filter out past events + const now = DateTime.now(); + const filteredList = eventsList.filter((event) => { + return now < DateTime.fromISO(event.startDateLocalized); + }); + + if (filteredList.length) { + const hourlyMessages = filteredList.map((event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + + const blocks: Record[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: '⏰ Starting Soon:', + emoji: true, + }, + }, + ]; + + const titleBlock: Record = { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }; + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) === 'http' + ) { + titleBlock.accessory = { + type: 'button', + text: { + type: 'plain_text', + text: 'Join Event', + emoji: true, + }, + value: `join_event_${event.id}`, + url: event.eventJoinLink, + action_id: 'button-join-event', + }; + } + + blocks.push(titleBlock); + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) !== 'http' + ) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Location:* ${event.eventJoinLink}`, + }, + }); + } + + blocks.push( + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: slackify(event.eventCalendarDescription), + }, + ], + }, + { + type: 'divider', + }, + ); + + return { + channel: + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL, + text: `Starting soon: ${event.title}: ${eventDate.toFormat( + 'EEEE, fff', + )}`, + unfurl_links: false, + unfurl_media: false, + blocks, + }; + }); + + const hourlyAdminMessage = { + channel: SLACK_EVENT_ADMIN_CHANNEL, + text: `Starting soon: ${filteredList + .map((event) => { + return `${event.title}: ${DateTime.fromISO( + event.startDateLocalized, + ).toFormat('EEEE, fff')}`; + }) + .join(', ')}`, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'header' as const, + text: { + type: 'plain_text' as const, + text: '⏰ Starting Soon:', + emoji: true, + }, + }, + ...filteredList.reduce[]>( + (list, event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + + const titleBlock: Record = { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${ + event.title + }*\n`, + }, + }; + + if ( + event.eventJoinLink && + event.eventJoinLink.substring(0, 4) === 'http' + ) { + titleBlock.accessory = { + type: 'button', + text: { + type: 'plain_text', + text: 'Join Event', + emoji: true, + }, + value: `join_event_${event.id}`, + url: event.eventJoinLink, + action_id: 'button-join-event', + }; + } + + return [ + ...list, + titleBlock, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Location:* ${event.eventJoinLink}`, + }, + }, + ...(event.eventZoomHostCode + ? [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Host Code:* ${event.eventZoomHostCode}`, + }, + }, + ] + : []), + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Announcement posted to:* <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }>`, + }, + }, + { + type: 'divider', + }, + ]; + }, + [], + ), + ], + }; + + await postMessage(hourlyAdminMessage); + + await Promise.all( + hourlyMessages.map((message) => postMessage(message)), + ); + } + } + return new Response(null, { status: 200 }); + } catch (e) { + console.error(e); + return new Response(null, { status: 500 }); + } +}; + +export const config: Config = { + schedule: '50 * * * *', +}; diff --git a/netlify/functions/event-reminders-weekly/index.ts b/netlify/functions/event-reminders-weekly/index.ts new file mode 100644 index 000000000..c446d4c9c --- /dev/null +++ b/netlify/functions/event-reminders-weekly/index.ts @@ -0,0 +1,153 @@ +import { GraphQLClient, gql } from 'graphql-request'; +import { DateTime } from 'luxon'; +import { postMessage } from '../_shared/slack'; +import { requireEnv } from '../_shared/env'; +import type { Config } from '@netlify/functions'; +import type { CalendarsResponse, EventsResponse } from '../_shared/types/cms'; + +const SLACK_ANNOUNCEMENTS_CHANNEL = + process.env.TEST_SLACK_ANNOUNCEMENTS_CHANNEL || + requireEnv('SLACK_ANNOUNCEMENTS_CHANNEL'); + +const DEFAULT_SLACK_EVENT_CHANNEL = 'C017WAKN883'; + +const calendarsQuery = gql` + query getCalendars { + solspace_calendar { + calendars { + handle + } + } + } +`; + +function createEventsQuery(calendars: CalendarsResponse) { + return gql` + query getEvents($rangeStart: String!, $rangeEnd: String!) { + solspace_calendar { + events(rangeStart: $rangeStart, rangeEnd: $rangeEnd) { + id + title + startDateLocalized + endDateLocalized + ${calendars.solspace_calendar.calendars.map( + ({ handle }) => ` + ... on ${handle}_Event { + eventCalendarDescription + eventJoinLink + eventZoomHostCode + eventSlackAnnouncementsChannelId + id + } + `, + )} + } + } + } +`; +} + +export default async (req: Request) => { + const graphQLClient = new GraphQLClient(`${process.env.CMS_URL}/api`, { + headers: { + Authorization: `bearer ${process.env.CMS_TOKEN}`, + }, + }); + + const rangeStart = DateTime.now() + .setZone('America/New_York') + .set({ hour: 0 }) + .toISO(); + const rangeEnd = DateTime.now() + .setZone('America/New_York') + .plus({ weeks: 1 }) + .toISO(); + + console.log('Fetching events', rangeStart, rangeEnd); + + try { + const calendarsResponse = + await graphQLClient.request(calendarsQuery); + + const eventsResponse = await graphQLClient.request( + createEventsQuery(calendarsResponse), + { + rangeStart, + rangeEnd, + }, + ); + + const eventsList = eventsResponse.solspace_calendar.events; + if (eventsList && eventsList.length) { + const weeklyMessage = { + channel: SLACK_ANNOUNCEMENTS_CHANNEL, + text: `This weeks events are: ${eventsList + .map((event) => { + return `${event.title}: ${DateTime.fromISO( + event.startDateLocalized, + ).toFormat('EEEE, fff')}`; + }) + .join(', ')}`, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'header' as const, + text: { + type: 'plain_text' as const, + text: "📆 This Week's Events Are:", + emoji: true, + }, + }, + ...eventsList.map((event) => { + const eventDate = DateTime.fromISO(event.startDateLocalized); + // TODO - colate these by date + return { + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: `** in <#${ + event.eventSlackAnnouncementsChannelId || + DEFAULT_SLACK_EVENT_CHANNEL + }>\n${event.title}`, + }, + }; + }), + { + type: 'context' as const, + elements: [ + { + type: 'mrkdwn' as const, + text: `ℹ️ Links to join will be posted in the specified channel about 10 minutes before the event starts.`, + }, + ], + }, + { + type: 'divider' as const, + }, + { + type: 'context' as const, + elements: [ + { + type: 'mrkdwn' as const, + text: `See details and more events at !`, + }, + ], + }, + ], + }; + + await postMessage(weeklyMessage); + } + return new Response(null, { status: 200 }); + } catch (e) { + console.error(e); + return new Response(null, { status: 500 }); + } +}; + +export const config: Config = { + schedule: '0 12 * * 1', +}; diff --git a/netlify/functions/join-coffee.js b/netlify/functions/join-coffee.js deleted file mode 100644 index f6f63bcca..000000000 --- a/netlify/functions/join-coffee.js +++ /dev/null @@ -1,26 +0,0 @@ -exports.handler = async function (event, context) { - const { code, day } = event.queryStringParameters; - - if (!code || !(day === 'tuesday' || day === 'thursday')) { - return { - statusCode: 401, - body: 'Invalid request.', - }; - } - - console.log(`Joining ${day}: ${code}`); - - // return { - // statusCode: 200, - // body: '', - // }; - return { - statusCode: 302, - headers: { - Location: - day === 'tuesday' - ? process.env.ZOOM_TUESDAYS - : process.env.ZOOM_THURSDAYS, - }, - }; -}; diff --git a/netlify/functions/join-coffee.ts b/netlify/functions/join-coffee.ts new file mode 100644 index 000000000..1758dace7 --- /dev/null +++ b/netlify/functions/join-coffee.ts @@ -0,0 +1,18 @@ +import { requireEnv } from './_shared/env'; + +export default async (req: Request) => { + const url = new URL(req.url); + const code = url.searchParams.get('code'); + const day = url.searchParams.get('day'); + + if (!code || !(day === 'tuesday' || day === 'thursday')) { + return new Response('Invalid request.', { status: 401 }); + } + + console.log(`Joining ${day}: ${code}`); + + const target = + day === 'tuesday' ? requireEnv('ZOOM_TUESDAYS') : requireEnv('ZOOM_THURSDAYS'); + + return Response.redirect(target, 302); +}; diff --git a/netlify/functions/join-slack.js b/netlify/functions/join-slack.js deleted file mode 100644 index 1abd29500..000000000 --- a/netlify/functions/join-slack.js +++ /dev/null @@ -1,23 +0,0 @@ -exports.handler = async function (event, context) { - const { code } = event.queryStringParameters; - - if (!code) { - return { - statusCode: 401, - body: 'Invalid request.', - }; - } - - console.log(`Joining slack: ${code}`); - - // return { - // statusCode: 200, - // body: '', - // }; - return { - statusCode: 302, - headers: { - Location: process.env.SLACK_JOIN_LINK, - }, - }; -}; diff --git a/netlify/functions/join-slack.ts b/netlify/functions/join-slack.ts new file mode 100644 index 000000000..590f2eca2 --- /dev/null +++ b/netlify/functions/join-slack.ts @@ -0,0 +1,13 @@ +import { requireEnv } from './_shared/env'; + +export default async (req: Request) => { + const code = new URL(req.url).searchParams.get('code'); + + if (!code) { + return new Response('Invalid request.', { status: 401 }); + } + + console.log(`Joining slack: ${code}`); + + return Response.redirect(requireEnv('SLACK_JOIN_LINK'), 302); +}; diff --git a/netlify/functions/slack/index.ts b/netlify/functions/slack/index.ts new file mode 100644 index 000000000..af521086e --- /dev/null +++ b/netlify/functions/slack/index.ts @@ -0,0 +1,128 @@ +import querystring from 'node:querystring'; +import { App } from '@slack/bolt'; +import type { Receiver, ReceiverEvent } from '@slack/bolt'; +import { verifySlackRequest } from '../_shared/verify'; +import { requireEnv } from '../_shared/env'; +import * as messages from './messages'; + +const SLACK_BOT_TOKEN = + process.env.TEST_SLACK_BOT_TOKEN || requireEnv('SLACK_BOT_TOKEN'); +const SLACK_SIGNING_SECRET = + process.env.TEST_SLACK_SIGNING_SECRET || requireEnv('SLACK_SIGNING_SECRET'); + +/** + * Custom Bolt receiver for Netlify Functions. + * Handles signature verification, body parsing (JSON + form-encoded), + * url_verification challenges, and ReceiverEvent construction. + */ +class NetlifyReceiver implements Receiver { + private app?: App; + private signingSecret: string; + + constructor(signingSecret: string) { + this.signingSecret = signingSecret; + } + + init(app: App) { + this.app = app; + } + + start() { + return Promise.resolve(); + } + + stop() { + return Promise.resolve(); + } + + async handleRequest(req: Request): Promise { + const rawBody = await req.text(); + const contentType = req.headers.get('content-type') ?? ''; + const body = this.parseBody(rawBody, contentType); + + // Slack sends this once when configuring the Events API URL + if (body.type === 'url_verification') { + return new Response(JSON.stringify({ challenge: body.challenge }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const isValid = verifySlackRequest(rawBody, req.headers, this.signingSecret); + if (!isValid.valid) { + console.log('Failed validation:', isValid.reason); + return new Response(isValid.reason, { status: 401 }); + } + + let storedResponse: string | undefined; + + const event: ReceiverEvent = { + body, + ack: async (response) => { + if (typeof response === 'undefined' || response == null) { + storedResponse = ''; + } else if (typeof response === 'string') { + storedResponse = response; + } else { + storedResponse = JSON.stringify(response); + } + }, + retryNum: req.headers.get('x-slack-retry-num') + ? Number(req.headers.get('x-slack-retry-num')) + : undefined, + retryReason: req.headers.get('x-slack-retry-reason') ?? undefined, + }; + + try { + await this.app!.processEvent(event); + + if (storedResponse !== undefined) { + return new Response(storedResponse, { + status: 200, + headers: storedResponse + ? { 'Content-Type': 'application/json' } + : undefined, + }); + } + } catch (error) { + console.error('Error processing Slack event:', error); + return new Response('Internal server error', { status: 500 }); + } + + return new Response('', { status: 404 }); + } + + private parseBody( + rawBody: string, + contentType: string, + ): Record { + if (contentType.includes('application/x-www-form-urlencoded')) { + const parsed = querystring.parse(rawBody); + if (typeof parsed.payload === 'string') { + return JSON.parse(parsed.payload); + } + return parsed; + } + return JSON.parse(rawBody); + } +} + +const receiver = new NetlifyReceiver(SLACK_SIGNING_SECRET); + +const app = new App({ + token: SLACK_BOT_TOKEN, + receiver, + processBeforeResponse: true, +}); + +app.event('team_join', async ({ event, client }) => { + const msg = messages.welcome({ event }); + await client.chat.postMessage(msg); +}); + +app.event('app_home_opened', async ({ event, client }) => { + const view = messages.appHome({ event }); + await client.views.publish(view); +}); + +export default async (req: Request) => receiver.handleRequest(req); diff --git a/netlify/functions/slack/messages.ts b/netlify/functions/slack/messages.ts new file mode 100644 index 000000000..00ae8402e --- /dev/null +++ b/netlify/functions/slack/messages.ts @@ -0,0 +1,161 @@ +interface SlackUser { + id: string; + name: string; +} + +interface TeamJoinEvent { + user: SlackUser; +} + +interface AppHomeOpenedEvent { + user: string; +} + +function getWelcomeBlocks(user?: SlackUser) { + return [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:wave: Hey ${ + user ? `@${user.name}` : `there` + }, welcome to Virtual Coffee -- fondly referred to as VC around this space.`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ":heart: Before doing anything else, please first take a moment to read our . Our Code of Conduct is in effect at any Virtual Coffee function, including direct messages. If you have experienced or witnessed violations to Virtual Coffee's Code of Conduct, please use our to let us know.", + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'header', + text: { + type: 'plain_text', + text: 'Now for the fun part!', + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'We have a lot going on here, but here are some places you might want to start:', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `:white_check_mark: Head over to #welcome and introduce yourself to the rest of the group! Let us know what you like to do in your freetime, what you're doing in tech, and a random fact about your life!`, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':dart: Check out #monthly-challenge to see what the community is working on together right now.', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':mega: The #announcements channel has the most recent news on events and initiatives happening in the community.', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ":computer: Our #co-working-room is a zoom room that's open all day, every day for members to quietly work, pair on solving problems, or just say hello.", + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ':question: #help-and-pairing is the space for asking questions about any and all tech related topics. But if you have a general question, we have a really welcoming community, so feel free to throw it in the channel that looks best.', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + { + type: 'divider', + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: ' ', + }, + }, + // Member ids (use ids in case username changes): + // - Julia: U01JXQGMSUC + // - Kirk: U01577R42TS + // - Bekah: U014HT3RNCU + // - Dan: U0157K5MUPJ + // - Meg: U01B9NQF2PR + { + type: 'section', + text: { + type: 'mrkdwn', + text: ":heart: And remember, you can always message one of our community maintainers, <@U014HT3RNCU>, <@U0157K5MUPJ>, <@U01577R42TS>, <@U01JXQGMSUC>, or <@U01B9NQF2PR> for any help and support you may need. \n\n *We're happy to have you here!*", + }, + }, + ]; +} + +export function appHome({ event }: { event: AppHomeOpenedEvent }) { + return { + user_id: event.user, + view: { + type: 'home' as const, + blocks: getWelcomeBlocks(), + }, + }; +} + +export function welcome({ event }: { event: TeamJoinEvent }) { + return { + link_names: true, + unfurl_links: false, + unfurl_media: false, + channel: event.user.id, + text: `:wave: Hey @${event.user.name}, welcome to Virtual Coffee -- fondly referred to as VC around this space.`, + blocks: getWelcomeBlocks(event.user), + }; +} diff --git a/netlify/functions/zoom-meeting-webhook-handler/airtable.ts b/netlify/functions/zoom-meeting-webhook-handler/airtable.ts new file mode 100644 index 000000000..b13c61838 --- /dev/null +++ b/netlify/functions/zoom-meeting-webhook-handler/airtable.ts @@ -0,0 +1,42 @@ +import type { Room } from '../_shared/types/room'; +import type Airtable from 'airtable'; + +type AirtableBase = ReturnType['base']>; + +// returns a roomInstance record, or undefined. +// Will retry 5 times, pausing 1 second between tries. +export async function findRoomInstance( + room: Room, + base: AirtableBase, + instanceId: string, +) { + async function tryFind() { + const resultArray = await base('room_instances') + .select({ + // Selecting the first 1 records in Grid view: + maxRecords: 1, + view: 'Grid view', + filterByFormula: `AND(RoomZoomMeetingId='${room.ZoomMeetingId}',instance_uuid='${instanceId}')`, + }) + .firstPage(); + + return resultArray[0]; + } + function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + let roomInstance = await tryFind(); + let count = 0; + while (count < 5 && !roomInstance) { + count++; + await sleep(400 * count); + roomInstance = await tryFind(); + } + + if (!roomInstance) { + console.log(`room instance ${instanceId} not found`); + } + + return roomInstance; +} diff --git a/netlify/functions/zoom-meeting-webhook-handler/index.ts b/netlify/functions/zoom-meeting-webhook-handler/index.ts new file mode 100644 index 000000000..5a1968910 --- /dev/null +++ b/netlify/functions/zoom-meeting-webhook-handler/index.ts @@ -0,0 +1,165 @@ +import Airtable from 'airtable'; +import { updateMeetingStatus, updateMeetingAttendance } from './slack'; +import { findRoomInstance } from './airtable'; +import { requireEnv } from '../_shared/env'; +import { verifyZoomSignature, hmacSha256Hex } from '../_shared/verify'; +import type { Room } from '../_shared/types/room'; + +import rooms from '../../../data/rooms.json' with { type: 'json' }; +const typedRooms = rooms as Room[]; + +const EVENT_MEETING_STARTED = 'meeting.started'; +const EVENT_MEETING_ENDED = 'meeting.ended'; +const EVENT_PARTICIPANT_JOINED = 'meeting.participant_joined'; +const EVENT_PARTICIPANT_LEFT = 'meeting.participant_left'; + +const ZOOM_SECRET = + process.env.TEST_ZOOM_WEBHOOK_SECRET_TOKEN || + requireEnv('ZOOM_WEBHOOK_SECRET_TOKEN'); + +const ZOOM_AUTH = + process.env.TEST_ZOOM_WEBHOOK_AUTH || requireEnv('ZOOM_WEBHOOK_AUTH'); + +export default async (req: Request) => { + try { + const rawBody = await req.text(); + + /** + * verification. zoom will either send an authorization header or a x-zm-signature header + */ + + const authorized = + verifyZoomSignature(rawBody, req.headers, ZOOM_SECRET) || + req.headers.get('authorization') === ZOOM_AUTH; + + if (!authorized) { + console.log('Unauthorized'); + return new Response('', { status: 401 }); + } + + const request = JSON.parse(rawBody); + + if (request.event == 'endpoint.url_validation') { + const hashForValidate = hmacSha256Hex( + ZOOM_SECRET, + request.payload.plainToken, + ); + return new Response( + JSON.stringify({ + plainToken: request.payload.plainToken, + encryptedToken: hashForValidate, + }), + { status: 200 }, + ); + } + + // check our meeting ID. The meeting ID never changes, but the uuid is different for each instance + + const room = typedRooms.find( + (room) => room.ZoomMeetingId === request.payload.object.id, + ); + console.log('incoming request'); + console.log('request payload'); + console.log(request.payload.object); + console.log('request event'); + console.log(request.event); + + if (room) { + const base = new Airtable().base(requireEnv('AIRTABLE_COWORKING_BASE')); + + switch (request.event) { + case EVENT_PARTICIPANT_JOINED: + case EVENT_PARTICIPANT_LEFT: { + const roomInstance = await findRoomInstance( + room, + base, + request.payload.object.uuid, + ); + + if (roomInstance) { + // create room event record + console.log(`found room instance ${roomInstance.getId()}`); + + await updateMeetingAttendance( + room, + roomInstance.get('slack_thread_timestamp') as string, + request, + ); + } + + break; + } + + case EVENT_MEETING_STARTED: { + // post message to Slack and get result + console.log('posting update'); + const result = await updateMeetingStatus(room); + console.log('done posting update'); + + // create new room instance + const created = await base('room_instances').create({ + instance_uuid: request.payload.object.uuid, + slack_thread_timestamp: result.ts, + start_time: request.payload.object.start_time, + room_record: [room.record_id], + }); + + if (!created) { + throw new Error('no record created'); + } + + console.log(`room_event created: ${created.getId()}`); + + break; + } + + case EVENT_MEETING_ENDED: { + const roomInstanceEnd = await findRoomInstance( + room, + base, + request.payload.object.uuid, + ); + + if (roomInstanceEnd) { + await updateMeetingStatus( + room, + roomInstanceEnd.get('slack_thread_timestamp') as string, + ); + + // update room instance + const updated = await base('room_instances').update( + roomInstanceEnd.getId(), + { + end_time: request.payload.object.end_time, + }, + ); + + if (!updated) { + throw new Error('no record updated'); + } + + console.log(`room_event updated: ${updated.getId()}`); + } + + break; + } + + default: + break; + } + } else { + console.log('meeting ID is not co-working meeting'); + } + + return new Response('', { status: 200 }); + } catch (error) { + // output to netlify function log + console.log(error); + return new Response( + JSON.stringify({ + msg: error instanceof Error ? error.message : String(error), + }), + { status: 500 }, + ); + } +}; diff --git a/netlify/functions/zoom-meeting-webhook-handler/slack.ts b/netlify/functions/zoom-meeting-webhook-handler/slack.ts new file mode 100644 index 000000000..33ff87638 --- /dev/null +++ b/netlify/functions/zoom-meeting-webhook-handler/slack.ts @@ -0,0 +1,116 @@ +import { postMessage, updateMessage } from '../_shared/slack'; +import type { Room } from '../_shared/types/room'; + +interface ZoomWebhookRequest { + event: string; + payload: { + object: { + participant: { + user_name: string; + }; + }; + }; +} + +// timestamp: if we have a timestamp, that means we've ended the meeting and are trying to update the message +// otherwise, post a new message + +export async function updateMeetingStatus(room: Room, timestamp?: string) { + const message = { + channel: room.SlackChannelId, + text: timestamp ? room.MessageSessionEnded : room.MessageSessionStarted, + unfurl_links: false, + unfurl_media: false, + blocks: [ + { + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: timestamp + ? room.MessageSessionEnded + : room.MessageSessionStarted, + }, + accessory: { + type: 'button' as const, + text: { + type: 'plain_text' as const, + text: timestamp ? room.ButtonStartNew : room.ButtonJoin, + emoji: true, + }, + value: 'join_meeting', + url: room.ZoomMeetingInviteUrl, + action_id: 'button-action', + style: 'primary' as const, + confirm: { + title: { + type: 'plain_text' as const, + text: room.NoticeTitle, + }, + text: { + type: 'mrkdwn' as const, + text: room.NoticeBody, + }, + confirm: { + type: 'plain_text' as const, + text: room.NoticeConfirm, + }, + deny: { + type: 'plain_text' as const, + text: room.NoticeCancel, + }, + }, + }, + }, + ...(room.ContextBody + ? [ + { + type: 'context' as const, + elements: [ + { + type: 'mrkdwn' as const, + text: room.ContextBody, + }, + ], + }, + ] + : []), + ], + }; + + // These calls never use background mode, so the result is always a Slack API response + const result = timestamp + ? await updateMessage({ ...message, ts: timestamp }) + : await postMessage(message); + + const slackResult = result as { ts?: string }; + + console.log( + `Successfully send message ${slackResult.ts} in conversation ${room.SlackChannelId}`, + ); + + return slackResult; +} + +export async function updateMeetingAttendance( + room: Room, + thread_ts: string, + zoomRequest: ZoomWebhookRequest, +) { + const username = zoomRequest.payload.object.participant.user_name; + const result = await postMessage({ + thread_ts, + text: + zoomRequest.event === 'meeting.participant_joined' + ? `${username} has joined!` + : `${username} has left. We'll miss you!`, + channel: room.SlackChannelId, + }); + + const slackResult = result as { ts?: string }; + + console.log( + `Successfully send message ${slackResult.ts} in conversation ${room.SlackChannelId}`, + ); + + return slackResult; +} diff --git a/package.json b/package.json index 9cbdc80b6..df7fd359c 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ "format": "npx prettier --write --ignore-unknown --list-different \"**/*\"", "lint": "eslint", "build-member-files": "tsx scripts/loadMemberFiles.js", + "build-rooms": "tsx scripts/build-rooms.ts", "local-dev": "cross-env NODE_ENV=development pnpm netlify dev", "build": "next build", "start": "next start", - "prebuild": "pnpm build-member-files", + "prebuild": "pnpm build-member-files && pnpm build-rooms", + "pretypecheck": "pnpm build-rooms", + "typecheck": "tsc --noEmit", "watch": "npm-watch", "dev": "pnpm build-member-files && concurrently \"pnpm watch\" \"pnpm local-dev\"" }, @@ -28,8 +31,10 @@ "@imgix/js-core": "^3.8.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", + "@netlify/functions": "^5.1.5", "@next/mdx": "15.5.18", "@sindresorhus/slugify": "^2.2.1", + "@slack/bolt": "^4.6.0", "@types/mdx": "^2.0.13", "airtable": "^0.12.2", "bootstrap": "^4.6.2", @@ -61,6 +66,7 @@ "remark-rehype": "^10.1.0", "require-dir": "^1.2.0", "sanitize-html": "^2.17.0", + "slackify-html": "^1.0.1", "unified": "^10.1.2" }, "devDependencies": { @@ -74,9 +80,11 @@ "@types/react-dom": "^19.2.3", "@types/require-dir": "^1.0.4", "@types/sanitize-html": "^2.16.0", + "@types/slackify-html": "^1.0.8", "all-contributors-cli": "^6.26.1", "concurrently": "^9.2.1", "cross-env": "^10.1.0", + "dotenv": "^16.0.2", "esbuild": "^0.25.12", "eslint": "^9.39.2", "eslint-config-next": "15.5.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e440b7a9..dc41e39b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,18 @@ importers: '@mdx-js/react': specifier: ^3.1.1 version: 3.1.1(@types/react@19.2.7)(react@19.2.3) + '@netlify/functions': + specifier: ^5.1.5 + version: 5.2.0 '@next/mdx': specifier: 15.5.18 version: 15.5.18(@mdx-js/loader@3.1.1(webpack@5.102.0(esbuild@0.25.12)(postcss@8.5.10)))(@mdx-js/react@3.1.1(@types/react@19.2.7)(react@19.2.3)) '@sindresorhus/slugify': specifier: ^2.2.1 version: 2.2.1 + '@slack/bolt': + specifier: ^4.6.0 + version: 4.7.2 '@types/mdx': specifier: ^2.0.13 version: 2.0.13 @@ -125,6 +131,9 @@ importers: sanitize-html: specifier: ^2.17.0 version: 2.17.0 + slackify-html: + specifier: ^1.0.1 + version: 1.0.1 unified: specifier: ^10.1.2 version: 10.1.2 @@ -159,6 +168,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.0 version: 2.16.0 + '@types/slackify-html': + specifier: ^1.0.8 + version: 1.0.8 all-contributors-cli: specifier: ^6.26.1 version: 6.26.1 @@ -168,6 +180,9 @@ importers: cross-env: specifier: ^10.1.0 version: 10.1.0 + dotenv: + specifier: ^16.0.2 + version: 16.6.1 esbuild: specifier: ^0.25.12 version: 0.25.12 @@ -1140,6 +1155,10 @@ packages: resolution: {integrity: sha512-28hNylsGcdxq6fngFXIShxVhamzw2K5twEx7pQaYFDjj52OLHjd0L+vPZ9HYUaeOoHuJgq8ir3RoVgITwcY+3g==} engines: {node: '>=18.14.0'} + '@netlify/functions@5.2.0': + resolution: {integrity: sha512-Pj93qeQd1tkQ5xm9gWJZmBf/1riLYqYHc0OzFukrJomrj82Ott53Rr/Q88H1ms5cF+P5QXRKWmA2JSxSybKfjA==} + engines: {node: '>=18.0.0'} + '@netlify/git-utils@6.0.2': resolution: {integrity: sha512-ASp8T6ZAxL5OE0xvTTn5+tIBua5F8ruLH7oYtI/m2W/8rYb9V3qvNeenf9SnKlGj1xv6mPv8l7Tc93kmBLLofw==} engines: {node: '>=18.14.0'} @@ -1270,6 +1289,10 @@ packages: resolution: {integrity: sha512-XOWlZ2wPpdRKkAOcQbjIf/Qz7L4RjcSVINVNQ9p3F6U8V6KSEOsB3fPrc6Ly8EOeJioHUepRPuzHzJE/7V5EsA==} engines: {node: ^18.14.0 || >=20} + '@netlify/types@2.6.0': + resolution: {integrity: sha512-yD20EizHJDQxajJ66Vo8RTwLwR2jMNVxufPG8MHd2AScX8jW4z0VPnnJHArq2GYPFTFZRHmiAhDrXr5m8zof6w==} + engines: {node: ^18.14.0 || >=20} + '@netlify/zip-it-and-ship-it@14.1.14': resolution: {integrity: sha512-33w50VcYLZ7RpUCFvl+n8JoLRGSVKerbH6cXtVjzA7un9JSkJWZQVS3nDmWYbq6OR0VnS1LGn7r+/ll6pSOvCg==} engines: {node: '>=18.14.0'} @@ -1635,6 +1658,32 @@ packages: resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} engines: {node: '>=12'} + '@slack/bolt@4.7.2': + resolution: {integrity: sha512-ALHtaS2iaP2WAWgX08yXsoCxEDitC6AqZs26ot6smXJQzBFMM4slVP+w3blLwzUV551xZ/+9RlBmWHsZDJJ5HA==} + engines: {node: '>=18', npm: '>=8.6.0'} + peerDependencies: + '@types/express': ^5.0.0 + + '@slack/logger@4.0.1': + resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/oauth@3.0.5': + resolution: {integrity: sha512-exqFQySKhNDptWYSWhvRUJ4/+ndu2gayIy7vg/JfmJq3wGtGdHk531P96fAZyBm5c1Le3yaPYqv92rL4COlU3A==} + engines: {node: '>=18', npm: '>=8.6.0'} + + '@slack/socket-mode@2.0.7': + resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.21.0': + resolution: {integrity: sha512-ZLMsKnD5KLRPmhFEoGoBQUD5Pc2bH3xFc5ygHlioEc0WmLGyZGoGCtMff4rpejrFnptrhfxcKpWxW4r3g39R0A==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.15.2': + resolution: {integrity: sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -1702,6 +1751,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/leaflet.markercluster@1.5.6': resolution: {integrity: sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==} @@ -1749,12 +1801,18 @@ packages: '@types/require-dir@1.0.4': resolution: {integrity: sha512-wZlfKxz4F97pMVAa9ztjm0TnaK4rH0COO9EE7AkMbvuIcXkrlFZQm2naZpITFN+PBqJ8vgxPV2+M6TzxWUQUSg==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} '@types/sanitize-html@2.16.0': resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/slackify-html@1.0.8': + resolution: {integrity: sha512-wA1YZkD/MyxXfLphMiUPOqPQOVle31z6sQFa0lQFTZUT3QbgmWKOenh4oetheasCt+Kmi/oh5VrdAEM/EgaXSQ==} + '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} @@ -1767,6 +1825,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -2090,6 +2151,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -2301,6 +2366,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2319,6 +2387,9 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2384,6 +2455,10 @@ packages: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2630,6 +2705,10 @@ packages: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2689,6 +2768,10 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -2699,6 +2782,10 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} @@ -2888,6 +2975,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -3338,6 +3429,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -3361,6 +3455,10 @@ packages: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + ext-list@2.2.2: resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} engines: {node: '>=0.10.0'} @@ -3505,6 +3603,10 @@ packages: resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way@8.2.2: resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} engines: {node: '>=14'} @@ -3547,6 +3649,15 @@ packages: debug: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3559,6 +3670,10 @@ packages: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -3575,6 +3690,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + from2@2.3.0: resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} @@ -3846,12 +3965,19 @@ packages: html-element-attributes@2.3.0: resolution: {integrity: sha512-RJv2v3BBaYSc0ODHwT0sqWI+2lFs6DATBvCRnW20BDmULxoAWvfT6r28uL8LcW1a9/eqUl+1DccUOJzw00qVXQ==} + html-entities@1.4.0: + resolution: {integrity: sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==} + html-void-elements@2.0.1: resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser@1.7.7: + resolution: {integrity: sha512-zpK66ifkT0fauyFh2Mulrq4AqGTucxGtOhZ8OjkbSfcCpkqQEI8qRkY0tSQSJNAQ4HUZkgWaU4fK4EH6SVH9PQ==} + engines: {node: '>=0.1.33'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3863,6 +3989,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-middleware@2.0.9: resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} engines: {node: '>=12.0.0'} @@ -4061,6 +4191,9 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-error-instance@2.0.0: resolution: {integrity: sha512-5RuM+oFY0P5MRa1nXJo6IcTx9m2VyXYhRtb4h0olsi2GHci4bqZ6akHk+GmCYvDrAR9yInbiYdr2pnoqiOMw/Q==} engines: {node: '>=16.17.0'} @@ -4158,6 +4291,9 @@ packages: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4603,12 +4739,20 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-options@3.0.4: resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} engines: {node: '>=10'} @@ -4793,6 +4937,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4914,6 +5062,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -5157,6 +5309,10 @@ packages: resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} engines: {node: '>=18'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -5185,14 +5341,26 @@ packages: resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} engines: {node: '>=18'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + p-reduce@3.0.0: resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==} engines: {node: '>=12'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-retry@6.2.1: resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} engines: {node: '>=16.17'} + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-timeout@5.1.0: resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==} engines: {node: '>=12'} @@ -5287,6 +5455,9 @@ packages: path-to-regexp@0.1.12: resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@6.0.0: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} @@ -5477,6 +5648,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + ps-list@8.1.1: resolution: {integrity: sha512-OPS9kEJYVmiO48u/B9qneqhkMvgCxT+Tm28VCEJpheTpl8cJ0ffZRRNgS5mrQRTrX5yRTpaJ+hRDeefXYmmorQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5506,6 +5681,10 @@ packages: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5764,6 +5943,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -5870,10 +6053,18 @@ packages: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serve-static@1.16.2: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -5942,6 +6133,9 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + slackify-html@1.0.1: + resolution: {integrity: sha512-9e5Wo8Z2QSORedN6vqImnjIUwaHI8mpjeQQfXBcIcvIewoJ9SGB56MN2FVIPt6ACn+g4gLsQZHeGXwe5VQMnzA==} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -6024,6 +6218,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -6378,6 +6576,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} engines: {node: '>=18.0.0'} @@ -6399,6 +6601,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -7768,6 +7974,10 @@ snapshots: - rollup - supports-color + '@netlify/functions@5.2.0': + dependencies: + '@netlify/types': 2.6.0 + '@netlify/git-utils@6.0.2': dependencies: execa: 8.0.1 @@ -7875,6 +8085,8 @@ snapshots: '@netlify/types@2.2.0': {} + '@netlify/types@2.6.0': {} + '@netlify/zip-it-and-ship-it@14.1.14': dependencies: '@babel/parser': 7.28.5 @@ -8300,6 +8512,70 @@ snapshots: dependencies: escape-string-regexp: 5.0.0 + '@slack/bolt@4.7.2': + dependencies: + '@slack/logger': 4.0.1 + '@slack/oauth': 3.0.5 + '@slack/socket-mode': 2.0.7 + '@slack/types': 2.21.0 + '@slack/web-api': 7.15.2 + axios: 1.16.0 + express: 5.2.1 + path-to-regexp: 8.4.2 + raw-body: 3.0.1 + tsscmp: 1.0.6 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@slack/logger@4.0.1': + dependencies: + '@types/node': 24.12.4 + + '@slack/oauth@3.0.5': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.2 + '@types/jsonwebtoken': 9.0.10 + '@types/node': 24.12.4 + jsonwebtoken: 9.0.2 + transitivePeerDependencies: + - debug + + '@slack/socket-mode@2.0.7': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.15.2 + '@types/node': 24.12.4 + '@types/ws': 8.18.1 + eventemitter3: 5.0.4 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + + '@slack/types@2.21.0': {} + + '@slack/web-api@7.15.2': + dependencies: + '@slack/logger': 4.0.1 + '@slack/types': 2.21.0 + '@types/node': 24.12.4 + '@types/retry': 0.12.0 + axios: 1.16.0 + eventemitter3: 5.0.4 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@so-ric/colorspace@1.1.6': dependencies: color: 5.0.3 @@ -8373,6 +8649,11 @@ snapshots: '@types/json5@0.0.29': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.4 + '@types/leaflet.markercluster@1.5.6': dependencies: '@types/leaflet': 1.9.21 @@ -8404,7 +8685,6 @@ snapshots: '@types/node@24.12.4': dependencies: undici-types: 7.16.0 - optional: true '@types/normalize-package-data@2.4.4': {} @@ -8420,12 +8700,16 @@ snapshots: '@types/require-dir@1.0.4': {} + '@types/retry@0.12.0': {} + '@types/retry@0.12.2': {} '@types/sanitize-html@2.16.0': dependencies: htmlparser2: 8.0.2 + '@types/slackify-html@1.0.8': {} + '@types/triple-beam@1.3.5': {} '@types/ungap__structured-clone@1.2.0': {} @@ -8434,6 +8718,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.4 + '@types/yauzl@2.10.3': dependencies: '@types/node': 24.10.4 @@ -8929,6 +9217,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -9182,6 +9475,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} atomically@2.1.0: @@ -9200,6 +9495,14 @@ snapshots: axe-core@4.11.4: {} + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} b4a@1.7.3: {} @@ -9261,6 +9564,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.0 + iconv-lite: 0.7.1 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bootstrap@4.6.2(jquery@3.7.1)(popper.js@1.16.1): @@ -9504,6 +9821,10 @@ snapshots: colors@1.4.0: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@10.0.1: {} @@ -9563,12 +9884,16 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.1.0: {} + content-type@1.0.5: {} cookie-es@1.2.2: {} cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} + cookie@0.7.1: {} cookie@0.7.2: {} @@ -9746,6 +10071,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -10418,6 +10745,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -10490,6 +10819,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + ext-list@2.2.2: dependencies: mime-db: 1.54.0 @@ -10668,6 +11030,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + find-my-way@8.2.2: dependencies: fast-deep-equal: 3.1.3 @@ -10709,6 +11082,8 @@ snapshots: optionalDependencies: debug: 4.4.3(supports-color@5.5.0) + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -10720,6 +11095,14 @@ snapshots: form-data-encoder@2.1.4: {} + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + format@0.2.2: {} formdata-polyfill@4.0.10: @@ -10730,6 +11113,8 @@ snapshots: fresh@0.5.2: {} + fresh@2.0.0: {} + from2@2.3.0: dependencies: inherits: 2.0.4 @@ -11107,6 +11492,8 @@ snapshots: html-element-attributes@2.3.0: {} + html-entities@1.4.0: {} + html-void-elements@2.0.1: {} htmlparser2@8.0.2: @@ -11116,6 +11503,8 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + htmlparser@1.7.7: {} + http-cache-semantics@4.2.0: {} http-errors@1.8.1: @@ -11134,6 +11523,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-middleware@2.0.9(debug@4.4.3): dependencies: '@types/http-proxy': 1.17.17 @@ -11394,6 +11791,8 @@ snapshots: is-docker@3.0.0: {} + is-electron@2.2.2: {} + is-error-instance@2.0.0: {} is-extglob@2.1.1: {} @@ -11462,6 +11861,8 @@ snapshots: is-plain-object@5.0.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -12022,10 +12423,14 @@ snapshots: media-typer@0.3.0: {} + media-typer@1.1.0: {} + memoize-one@6.0.0: {} merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge-options@3.0.4: dependencies: is-plain-obj: 2.1.0 @@ -12397,6 +12802,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} @@ -12497,6 +12906,8 @@ snapshots: negotiator@0.6.3: {} + negotiator@1.0.0: {} + neo-async@2.6.2: optional: true @@ -12889,6 +13300,8 @@ snapshots: dependencies: p-map: 7.0.3 + p-finally@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -12915,14 +13328,28 @@ snapshots: p-map@7.0.3: {} + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + p-reduce@3.0.0: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-retry@6.2.1: dependencies: '@types/retry': 0.12.2 is-network-error: 1.3.0 retry: 0.13.1 + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + p-timeout@5.1.0: {} p-timeout@6.1.4: {} @@ -13006,6 +13433,8 @@ snapshots: path-to-regexp@0.1.12: {} + path-to-regexp@8.4.2: {} + path-type@6.0.0: {} pathe@1.1.2: {} @@ -13199,6 +13628,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + ps-list@8.1.1: {} pstree.remy@1.1.8: {} @@ -13225,6 +13656,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -13577,6 +14012,16 @@ snapshots: rfdc@1.4.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} run-async@2.4.1: {} @@ -13693,6 +14138,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serve-static@1.16.2: dependencies: encodeurl: 2.0.0 @@ -13702,6 +14163,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-blocking@2.0.0: {} set-cookie-parser@2.7.2: {} @@ -13809,6 +14279,11 @@ snapshots: dependencies: semver: 7.7.3 + slackify-html@1.0.1: + dependencies: + html-entities: 1.4.0 + htmlparser: 1.7.7 + slash@5.1.0: {} slashes@3.0.12: {} @@ -13879,6 +14354,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: @@ -14264,6 +14741,8 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + tsx@4.21.0: dependencies: esbuild: 0.27.2 @@ -14284,6 +14763,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c5417db2..704836d83 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ onlyBuiltDependencies: + - '@parcel/watcher' - esbuild - netlify-cli + - sharp + - unix-dgram + - unrs-resolver diff --git a/scripts/build-rooms.ts b/scripts/build-rooms.ts new file mode 100644 index 000000000..86405d804 --- /dev/null +++ b/scripts/build-rooms.ts @@ -0,0 +1,33 @@ +import 'dotenv/config'; +import Airtable from 'airtable'; +import { writeFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; + +const outDir = path.join('.', 'data'); +const outFile = path.join(outDir, 'rooms.json'); + +async function main() { + mkdirSync(outDir, { recursive: true }); + + if (!process.env.AIRTABLE_COWORKING_BASE) { + console.warn( + '[build-rooms] AIRTABLE_COWORKING_BASE not set — writing empty rooms.json. The zoom-meeting-webhook-handler will not match any meetings until this is configured.', + ); + writeFileSync(outFile, '[]\n'); + return; + } + + console.log('Building rooms'); + const base = new Airtable().base(process.env.AIRTABLE_COWORKING_BASE); + const results = await base('rooms').select().all(); + + const rooms = results.map((record) => ({ + ...record.fields, + record_id: record.id, + })); + + writeFileSync(outFile, JSON.stringify(rooms, null, 2)); + console.log(`Done building ${rooms.length} rooms`); +} + +main();