diff --git a/functions/zoom-meeting-webhook-handler/airtable.js b/functions/zoom-meeting-webhook-handler/airtable.js deleted file mode 100644 index 1356e2b..0000000 --- a/functions/zoom-meeting-webhook-handler/airtable.js +++ /dev/null @@ -1,35 +0,0 @@ -// returns a roomInstance record, or undefined. -// Will retry 5 times, pausing 1 second between tries. -async function findRoomInstance(room, base, instanceId) { - 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) { - 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; -} - -module.exports = { findRoomInstance }; diff --git a/functions/zoom-meeting-webhook-handler/index.js b/functions/zoom-meeting-webhook-handler/index.js index 76fa365..5a0edf6 100644 --- a/functions/zoom-meeting-webhook-handler/index.js +++ b/functions/zoom-meeting-webhook-handler/index.js @@ -1,193 +1,166 @@ require('dotenv').config(); -const crypto = require('crypto'); +const { WebClient } = require("@slack/web-api"); -const { updateMeetingStatus, updateMeetingAttendence } = require('./slack'); +const SLACK_COWORKING_CHANNEL_ID = process.env.SLACK_COWORKING_CHANNEL_ID; +const SLACK_COWORKING_CHANNEL_NAME = process.env.SLACK_COWORKING_CHANNEL_NAME; -const rooms = require('../../data/rooms.json'); +const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN; -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_COWORKING_JOIN_URL = process.env.ZOOM_COWORKING_JOIN_URL; +const ZOOM_DESKTOP_COWORKING_APP_JOIN_URL = process.env.ZOOM_DESKTOP_COWORKING_APP_JOIN_URL; -const ZOOM_SECRET = - process.env.TEST_ZOOM_WEBHOOK_SECRET_TOKEN || - process.env.ZOOM_WEBHOOK_SECRET_TOKEN; +const slack = new WebClient(SLACK_BOT_TOKEN); -const ZOOM_AUTH = - process.env.TEST_ZOOM_WEBHOOK_AUTH || process.env.ZOOM_WEBHOOK_AUTH; -const handler = async function (event, context) { - try { - /** - * verification. zoom will either send an authorization header or a x-zm-signature header - */ +function parseBody(event) { + let body = event.body || ""; + if (event.isBase64Encoded) body = Buffer.from(body, "base64").toString("utf8"); + const ct = (event.headers?.["content-type"] || event.headers?.["Content-Type"] || "").toLowerCase(); - let authorized = false; + if (ct.includes("application/json")) return body ? JSON.parse(body) : {}; + if (ct.includes("application/x-www-form-urlencoded")) { + return Object.fromEntries(new URLSearchParams(body)); + } + // try JSON, else return raw string + try { return body ? JSON.parse(body) : {}; } catch { return { raw: body }; } +} - if (event.headers['x-zm-signature']) { - const message = `v0:${event.headers['x-zm-request-timestamp']}:${event.body}`; - const hashForVerify = crypto - .createHmac('sha256', ZOOM_SECRET) - .update(message) - .digest('hex'); +async function handleStartCall() { + // Create a Slack call and post a call block + const created = await slack.calls.add({ + title: "co-working-room", + external_unique_id: "0xDEADBEEF", + join_url: ZOOM_COWORKING_JOIN_URL, + desktop_app_join_url: ZOOM_DESKTOP_COWORKING_APP_JOIN_URL, + }); - const signature = `v0=${hashForVerify}`; + const call_id = created?.call?.id; + await slack.chat.postMessage({ + channel: SLACK_COWORKING_CHANNEL_NAME, + blocks: [{ type: "call", call_id }], + }); - console.log('message'); - console.log(message); - console.log('signature'); - console.log(signature); - console.log('x-zm-signature'); - console.log(event.headers['x-zm-signature']); + return call_id; +} - if (event.headers['x-zm-signature'] === signature) { - authorized = true; - } - } else { - if (event.headers.authorization === ZOOM_AUTH) { - authorized = true; - } - } - if (!authorized) { - console.log('Unauthorized', event); - return { - statusCode: 401, - body: '', - }; - } +function handleValidation(zoomEvent) { + const plainToken = zoomEvent?.payload?.plainToken || ""; + const encryptedToken = crypto + .createHmac("sha256", ZOOM_WEBHOOK_SECRET_TOKEN) + .update(plainToken) + .digest("hex"); + return { plainToken, encryptedToken }; +} - const request = JSON.parse(event.body); - - if (request.event == 'endpoint.url_validation') { - const hashForValidate = crypto - .createHmac('sha256', ZOOM_SECRET) - .update(request.payload.plainToken) - .digest('hex'); - return { - statusCode: 200, - body: JSON.stringify({ - plainToken: request.payload.plainToken, - encryptedToken: hashForValidate, - }), - }; - } - // check our meeting ID. The meeting ID never changes, but the uuid is different for each instance - - const room = rooms.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 Airtable = require('airtable'); - const base = new Airtable().base(process.env.AIRTABLE_COWORKING_BASE); - - const { findRoomInstance } = require('./airtable'); - - switch (request.event) { - case EVENT_PARTICIPANT_JOINED: - case EVENT_PARTICIPANT_LEFT: - let roomInstance = await findRoomInstance( - room, - base, - request.payload.object.uuid - ); - - if (roomInstance) { - // create room event record - console.log(`found room instance ${roomInstance.getId()}`); - - const updatedMeeting = await updateMeetingAttendence( - room, - roomInstance.get('slack_thread_timestamp'), - 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: - let roomInstanceEnd = await findRoomInstance( - room, - base, - request.payload.object.uuid - ); - - if (roomInstanceEnd) { - const slackedEnd = await updateMeetingStatus( - room, - roomInstanceEnd.get('slack_thread_timestamp') - ); - - // 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; +async function getCallIdFromChannel() { + const resp = await slack.conversations.history({ channel: SLACK_COWORKING_CHANNEL_ID, limit: 1 }); + const msg = resp?.messages?.[0]; + const block0 = msg?.blocks?.[0]; + // Slack “call” block may expose call_id directly or inside the block payload + if (block0?.call_id) return block0.call_id; + if (block0?.call?.v1?.id) return block0.call.v1.id; + throw new Error("Could not determine current call_id from channel history."); +} + + +async function addParticipantToCall(zoomEvent) { + const slackUser = toSlackUser(zoomEvent); + const call_id = await getCallIdFromChannel(); + await slack.calls.participants.add({ id: call_id, users: [slackUser] }); +} + + +async function removeParticipantFromCall(zoomEvent) { + const slackUser = toSlackUser(zoomEvent); + const call_id = await getCallIdFromChannel(); + await slack.calls.participants.remove({ id: call_id, users: [slackUser] }); +} + + +function toSlackUser(zoomEvent) { + const zoomDisplayName = zoomEvent?.payload?.object?.participant?.user_name; + return { + external_id: zoomDisplayName, + display_name: zoomDisplayName, + }; +} + + +function isSlashCommand(event) { + const body = parseBody(event); + return typeof body?.command === "string" && body.command.startsWith("/"); +} + + +function getSlashCommand(event) { + const body = parseBody(event); + return typeof body?.command === "string" && body.command; +} + + +function getSlashText(event) { + const body = parseBody(event); + return typeof body?.text === "string" && body.text; +} + + +async function getActiveParticipants() { + const resp = await slack.conversations.history({ channel: SLACK_COWORKING_CHANNEL_ID, limit: 1 }); + return resp?.messages?.[0]?.blocks?.[0]?.call?.v1?.active_participants ?? []; +} + + +async function endCall() { + const call_id = await getCallIdFromChannel(); + await slack.calls.end({ id: call_id }); +} + + +exports.handler = async function(event) { + if (isSlashCommand(event)) { + const slashCommand = getSlashCommand(event); + if (slashCommand === "/co-working-room") { + const slashText = getSlashText(event); + if (slashText == "end") { + await endCall(); } - } else { - console.log('meeting ID is not co-working meeting'); + const call_id = await handleStartCall(); + return { statusCode: 200, body: JSON.stringify(call_id) } } + } + + // Zoom webhooks + const zoomEvent = parseBody(event); + const zoomEventName = zoomEvent?.event; + if (zoomEventName === "endpoint.url_validation") { return { statusCode: 200, - body: '', - }; - } catch (error) { - // output to netlify function log - console.log(error); - return { - statusCode: 500, - // Could be a custom message or object i.e. JSON.stringify(err) - body: JSON.stringify({ msg: error.message }), - }; + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(handleValidation(zoomEvent)) + } + } + + else if (zoomEventName === "meeting.participant_joined") { + await addParticipantToCall(zoomEvent); + return { statusCode: 204 }; } + + else if (zoomEventName === "meeting.participant_left") { + await removeParticipantFromCall(zoomEvent); + const active = await getActiveParticipants(); + if (active.length === 0) { + await endCall(); + } + return { statusCode: 204 }; + } + + return { + statusCode: 200 + }; }; -module.exports = { handler }; diff --git a/functions/zoom-meeting-webhook-handler/slack.js b/functions/zoom-meeting-webhook-handler/slack.js deleted file mode 100644 index fd56dc2..0000000 --- a/functions/zoom-meeting-webhook-handler/slack.js +++ /dev/null @@ -1,101 +0,0 @@ -require('dotenv').config(); - -const { postMessage, updateMessage } = require('../../util/slack'); - -// 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 - -async function updateMeetingStatus(room, timestamp) { - const message = { - channel: room.SlackChannelId, - text: timestamp ? room.MessageSessionEnded : room.MessageSessionStarted, - unfurl_links: false, - unfurl_media: false, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: timestamp - ? room.MessageSessionEnded - : room.MessageSessionStarted, - }, - accessory: { - type: 'button', - text: { - type: 'plain_text', - text: timestamp ? room.ButtonStartNew : room.ButtonJoin, - emoji: true, - }, - value: 'join_meeting', - url: room.ZoomMeetingInviteUrl, - action_id: 'button-action', - style: 'primary', - confirm: { - title: { - type: 'plain_text', - text: room.NoticeTitle, - }, - text: { - type: 'mrkdwn', - text: room.NoticeBody, - }, - confirm: { - type: 'plain_text', - text: room.NoticeConfirm, - }, - deny: { - type: 'plain_text', - text: room.NoticeCancel, - }, - }, - }, - }, - ...(room.ContextBody - ? [ - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: room.ContextBody, - }, - ], - }, - ] - : []), - ], - }; - - // console.log(JSON.stringify(message)); - - const result = timestamp - ? await updateMessage({ ...message, ts: timestamp }) - : await postMessage(message); - - console.log( - `Successfully send message ${result.ts} in conversation ${room.SlackChannelId}` - ); - - return result; -} - -async function updateMeetingAttendence(room, thread_ts, zoomRequest) { - 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, - }); - - console.log( - `Successfully send message ${result.ts} in conversation ${room.SlackChannelId}` - ); - - return result; -} - -module.exports = { updateMeetingStatus, updateMeetingAttendence };