diff --git a/bot_zoom_obf_flow/README.md b/bot_zoom_obf_flow/README.md index c1e635e..91d815b 100644 --- a/bot_zoom_obf_flow/README.md +++ b/bot_zoom_obf_flow/README.md @@ -6,7 +6,7 @@ This example demonstrates how to implement the Zoom OBF token flow to allow Reca The OBF ties a bot's lifetime in a meeting directly to a specific user in that meeting. The bot can only be in the meeting as long as its "parent" user is. This enables: -- Appearing as your Zoom app via Zoom's native UI +- Appearing as your Zoom app via Zoom's native UI > **πŸ“˜ For complete documentation, see:** [Zoom Native Bots (OBF)](https://docs.recall.ai/docs/zoom-obf-tokens) @@ -70,10 +70,10 @@ The OBF ties a bot's lifetime in a meeting directly to a specific user in that m ## Prerequisites -- [Zoom General App](https://developers.zoom.us/docs/integrations/create/) with scope: `user:read:token` and **Meeting SDK** enabled -- [ngrok](https://ngrok.com/) for exposing your local server -- [Node.js](https://nodejs.org/) 18+ -- Custom SDK credentials enabled in your Recall workspace (contact Recall support to enable) +- [Zoom General App](https://developers.zoom.us/docs/integrations/create/) with scope: `user:read:token` and **Meeting SDK** enabled +- [ngrok](https://ngrok.com/) for exposing your local server +- [Node.js](https://nodejs.org/) 18+ +- Custom SDK credentials enabled in your Recall workspace (contact Recall support to enable) ## Setup @@ -89,7 +89,7 @@ Copy the domain (e.g. `abc123.ngrok-free.app`). 1. Go to the [Zoom App Marketplace](https://marketplace.zoom.us/develop/create) 2. Create a **General App** with OAuth -3. Set the OAuth Redirect URL to: `https://YOUR_NGROK_DOMAIN/zoom/oauth/callback` +3. Set the OAuth Redirect URL to: `https://YOUR_NGROK_DOMAIN/zoom/oauth/callback` (Note: this will also be used in your .env in step 4) 4. Add the scope: `user:read:token` 5. In the **Embed** section, enable **Meeting SDK** 6. Copy the **Client ID** and **Client Secret** @@ -99,7 +99,7 @@ Copy the domain (e.g. `abc123.ngrok-free.app`). 1. Navigate to **Meeting Bot Setup** > **Zoom** in the Recall dashboard 2. Paste your Zoom app's Client ID and Client Secret -### 3. Set up env variables +### 4. Set up env variables ```bash cp .env.sample .env @@ -159,16 +159,16 @@ curl -X POST "https://RECALL_REGION.recall.ai/api/v1/bot/" \ **Note**: -- Replace `RECALL_REGION`, `RECALL_API_KEY`, and `YOUR_MEETING_URL` with your own - values. -- Replace `YOUR_NGROK_DOMAIN` with your ngrok domain (e.g. `somehash.ngrok-free.app`). -- The bot will join the meeting on behalf of the OAuth user. +- Replace `RECALL_REGION`, `RECALL_API_KEY`, and `YOUR_MEETING_URL` with your own + values. +- Replace `YOUR_NGROK_DOMAIN` with your ngrok domain (e.g. `somehash.ngrok-free.app`). +- The bot will join the meeting on behalf of the OAuth user. ## Important OBF Behavior -- **Short-lived & single-use**: OBF tokens should be minted just-in-time (in the `/zoom/obf` endpoint) when launching a bot -- **Parent user required**: The bot can't join until the parent user has already joined the meeting -- **Linked lifetime**: If the parent user leaves, the bot's will also be removed from the call by Zoom +- **Short-lived & single-use**: OBF tokens should be minted just-in-time (in the `/zoom/obf` endpoint) when launching a bot +- **Parent user required**: The bot can't join until the parent user has already joined the meeting +- **Linked lifetime**: If the parent user leaves, the bot's will also be removed from the call by Zoom ## API Endpoints diff --git a/bot_zoom_obf_flow/src/api/zoom_obf.ts b/bot_zoom_obf_flow/src/api/zoom_obf.ts index 6e8c6e3..6ae1272 100644 --- a/bot_zoom_obf_flow/src/api/zoom_obf.ts +++ b/bot_zoom_obf_flow/src/api/zoom_obf.ts @@ -5,10 +5,7 @@ import { z } from "zod"; import { env } from "../config/env"; /** - * Generate a Zoom OBF token. - * This is the token that is used to start a Zoom meeting and/or authenticate a participant in a Zoom meeting, - * allowing them to join meetings as authenticated participants (e.g. signed-in users). - * You can generate a new OBF token as long as you have a valid access token. + * Generate a Zoom OBF token given a meeting ID. */ export async function zoom_obf(args: { meeting_id: string }): Promise<{ obf_token: string }> { const { meeting_id } = z.object({ meeting_id: z.string() }).parse(args); diff --git a/bot_zoom_obf_flow/src/index.ts b/bot_zoom_obf_flow/src/index.ts index 4f3e903..94684cc 100644 --- a/bot_zoom_obf_flow/src/index.ts +++ b/bot_zoom_obf_flow/src/index.ts @@ -103,8 +103,6 @@ Server is running on port ${env.PORT} To get started, open the following URL in your browser: https://${process.env.NGROK_DOMAIN ?? "NGROK_DOMAIN"}/zoom/oauth -After you complete the OAuth flow, you can then create a bot. - - Ensure that \`zoom.obf_token_url="https://${process.env.NGROK_DOMAIN ?? "NGROK_DOMAIN"}/zoom/obf"\` is set in the bot's configuration. - - You can create a bot using the \`run.sh\` script. See the README for more details. +After you complete the OAuth flow, you can then create a bot using the \`run.sh\` script. See the README for more details. `); }); \ No newline at end of file diff --git a/bot_zoom_skip_waiting_room/.env.sample b/bot_zoom_skip_waiting_room/.env.sample new file mode 100644 index 0000000..32faac6 --- /dev/null +++ b/bot_zoom_skip_waiting_room/.env.sample @@ -0,0 +1,9 @@ +ZOOM_OAUTH_APP_CLIENT_ID=ZOOM_OAUTH_APP_CLIENT_ID +ZOOM_OAUTH_APP_CLIENT_SECRET=ZOOM_OAUTH_APP_CLIENT_SECRET +ZOOM_OAUTH_APP_REDIRECT_URI=ZOOM_OAUTH_APP_REDIRECT_URI # Includes the https:// protocol if using ngrok + +# Optional if using the run.sh script to launch a bot +RECALL_API_KEY=RECALL_API_KEY +RECALL_REGION=RECALL_REGION # e.g. us-west-2, us-east-1, eu-central-1, ap-northeast-1 +MEETING_URL=MEETING_URL # e.g. https://us06web.zoom.us/j/1234567890 +NGROK_DOMAIN=NGROK_DOMAIN # e.g. if your ngrok URL is https://1a8d23b7ab2d.ngrok-free.app, drop the protocol and set to 1a8d23b7ab2d.ngrok-free.app \ No newline at end of file diff --git a/bot_zoom_skip_waiting_room/README.md b/bot_zoom_skip_waiting_room/README.md new file mode 100644 index 0000000..4f84549 --- /dev/null +++ b/bot_zoom_skip_waiting_room/README.md @@ -0,0 +1,192 @@ +# Zoom Join Token Flow for Skipping Waiting Rooms + +This sample app shows how to skip the Zoom waiting room by generating a Zoom join token for local recording just before the bot joins the meeting. + +> ⚠️ **Before you start** +> +> - This flow uses the Zoom SDK, so you also need an OBF token callback in addition to the join token callback. +> - This flow requires a feature flag on your Recall workspace; contact Recall support to enable it. + +## Important Join Token Behavior + +- **Short-lived & single-use**: Join tokens should be minted just-in-time (in the `/zoom/join-token` endpoint) when launching a bot +- **Meeting-scoped**: A join token is valid for a specific meeting ID, so pass the correct `meeting_id` every time + +## Prerequisites + +- [Zoom General App](https://developers.zoom.us/docs/integrations/create/) +- [ngrok](https://ngrok.com/) for exposing your local server +- [Node.js](https://nodejs.org/) 18+ +- Custom SDK credentials enabled in your Recall workspace (contact Recall support to enable) + +## Setup + +### 1. Start ngrok + +```bash +ngrok http 4000 +``` + +Copy the domain (e.g. `abc123.ngrok-free.app`). + +### 2. Create a Zoom General App + +1. Go to the [Zoom App Marketplace](https://marketplace.zoom.us/develop/create) +2. Create a **General App** with OAuth +3. Set the OAuth Redirect URL to: `https://YOUR_NGROK_DOMAIN/zoom/oauth/callback` (Note: this will also be used in your .env in step 4) +4. Add the scope: `meeting:read:local_recording_token` and `user:read:token` +5. In the **Embed** section, enable **Meeting SDK** +6. Copy the **Client ID** and **Client Secret** + +### 3. Add SDK credentials to Recall + +1. Navigate to **Meeting Bot Setup** > **Zoom** in the Recall dashboard +2. Paste your Zoom app's Client ID and Client Secret + +### 4. Set up env variables + +```bash +cp .env.sample .env +``` + +Then fill out the variables in the `.env` file, including the ngrok domain from step 1. + +### 5. Start the server + +Open this directory in a new terminal and run: + +```bash +npm install +npm run dev +``` + +This will start a server on port 4000. + +### 6. Complete the OAuth flow + +Open your browser and navigate to: + +``` +https://YOUR_NGROK_DOMAIN/zoom/oauth +``` + +Follow the prompts to authorize your Zoom app. After authorizing, the refresh token will be saved to `output/zoom_oauth_refresh_token.txt`. + +### 7. Create a bot + +Once you complete step 6, you can then create a bot using the `run.sh` script or manually with curl. + +#### Option A: Using run.sh (recommended) + +In a new terminal, run the script: + +```bash +chmod +x run.sh +./run.sh +``` + +This will create a bot and paste the response in the terminal. + +#### Option B: Using curl + +```bash +curl -X POST "https://RECALL_REGION.recall.ai/api/v1/bot/" \ + -H "Authorization: YOUR_RECALL_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "meeting_url": "YOUR_ZOOM_MEETING_URL", + "zoom": { + "join_token_url": "https://YOUR_NGROK_DOMAIN/zoom/join-token?meeting_id=ZOOM_MEETING_ID", + "obf_token_url": "https://YOUR_NGROK_DOMAIN/zoom/obf-token?meeting_id=ZOOM_MEETING_ID" + } + }' +``` + +**Note**: + +- Replace `RECALL_REGION`, `RECALL_API_KEY`, `YOUR_MEETING_URL`, and `ZOOM_MEETING_ID` with your own + values. +- Replace `YOUR_NGROK_DOMAIN` with your ngrok domain (e.g. `somehash.ngrok-free.app`). +- The bot will join the meeting on behalf of the OAuth user. + +## How It Works + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Client β”‚ β”‚ Server β”‚ β”‚ Zoom β”‚ β”‚ Recall β”‚ +β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ β”‚ + β”‚ 1. GET /zoom/oauth β”‚ β”‚ + │───────────────▢│ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ Redirect to Zoom OAuth β”‚ β”‚ + │◀───────────────│ β”‚ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ Authorize app β”‚ β”‚ + │────────────────────────────────▢│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ Callback with code β”‚ β”‚ + │◀────────────────────────────────│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ 2. GET /zoom/oauth/callback β”‚ β”‚ + │───────────────▢│ β”‚ β”‚ + β”‚ β”‚ Exchange for tokens β”‚ + β”‚ │───────────────▢│ β”‚ + β”‚ β”‚ { refresh_token } β”‚ + β”‚ │◀───────────────│ β”‚ + β”‚ β”‚ (stored locally) β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ ════════════════════════════════════════════════ β”‚ + β”‚ Later, when creating a bot: β”‚ + β”‚ ════════════════════════════════════════════════ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ 3. POST /api/v1/bot β”‚ + β”‚ β”‚ { zoom: { join_token_url, obf_token_url } } β”‚ + β”‚ │────────────────────────────────▢│ + β”‚ β”‚ β”‚ β”‚ + β”‚ ════════════════════════════════════════════════ β”‚ + β”‚ When bot joins call: β”‚ + β”‚ ════════════════════════════════════════════════ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ 4. GET /zoom/join-token β”‚ + β”‚ │◀────────────────────────────────│ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Refresh access token β”‚ + β”‚ │───────────────▢│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Get join token β”‚ β”‚ + β”‚ │───────────────▢│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ join_token β”‚ β”‚ + β”‚ │◀───────────────│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Return join token β”‚ + β”‚ │────────────────────────────────▢│ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ 5. GET /zoom/obf-token β”‚ + β”‚ │◀────────────────────────────────│ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Refresh access token β”‚ + β”‚ │───────────────▢│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Get OBF token β”‚ β”‚ + β”‚ │───────────────▢│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ obf_token β”‚ β”‚ + β”‚ │◀───────────────│ β”‚ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Return OBF token β”‚ + β”‚ │────────────────────────────────▢│ + β”‚ β”‚ β”‚ β”‚ + β”‚ β”‚ Bot joins meeting β”‚ + β”‚ β”‚ β”‚ β”‚ +``` + +## API Endpoints + +| Endpoint | Description | +| -------------------------- | ------------------------------------------------------ | +| `GET /zoom/oauth` | Initiates Zoom OAuth flow | +| `GET /zoom/oauth/callback` | Handles OAuth callback, stores refresh token | +| `GET /zoom/join-token` | Returns a join token (called by Recall when bot joins) | +| `GET /zoom/obf-token` | Returns an OBF token (required support callback) | diff --git a/bot_zoom_skip_waiting_room/package.json b/bot_zoom_skip_waiting_room/package.json new file mode 100644 index 0000000..e75434a --- /dev/null +++ b/bot_zoom_skip_waiting_room/package.json @@ -0,0 +1,20 @@ +{ + "name": "bot_zoom_skip_waiting_room", + "version": "1.0.0", + "description": "Allow a bot to skip the waiting room of a Zoom meeting using a join token", + "main": "index.ts", + "scripts": { + "dev": "ts-node src/index.ts" + }, + "author": "Gerry Saporito", + "license": "MIT", + "devDependencies": { + "@types/node": "^24.10.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "dependencies": { + "dotenv": "^17.2.3", + "zod": "^4.1.13" + } +} \ No newline at end of file diff --git a/bot_zoom_skip_waiting_room/run.sh b/bot_zoom_skip_waiting_room/run.sh new file mode 100755 index 0000000..c9e6366 --- /dev/null +++ b/bot_zoom_skip_waiting_room/run.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +DOTENV_FILE="${DOTENV_FILE:-.env}" +if [ -f "$DOTENV_FILE" ]; then + # shellcheck source=/dev/null + source "$DOTENV_FILE" +fi + +: "${RECALL_REGION:?RECALL_REGION is required (us-west-2, us-east-1, eu-central-1, ap-northeast-1)}" +: "${RECALL_API_KEY:?RECALL_API_KEY is required}" +: "${MEETING_URL:?MEETING_URL is required (Zoom/Meet URL)}" +: "${NGROK_DOMAIN:?NGROK_DOMAIN is required (ngrok.io host without scheme)}" + +# Extract meeting ID from Zoom URL (matches zoom.us/j/123, zoom.com/s/123, etc.) +MEETING_ID=$(echo "${MEETING_URL}" | grep -oE 'zoom\.(us|com)/(j|s|wc/join)/([0-9]+)' | grep -oE '[0-9]+$') +if [ -z "${MEETING_ID}" ]; then + echo "Error: Could not extract meeting ID from URL: ${MEETING_URL}" >&2 + exit 1 +fi + +curl --request POST \ + --url https://${RECALL_REGION}.recall.ai/api/v1/bot/ \ + --header "Authorization: ${RECALL_API_KEY}" \ + --header "accept: application/json" \ + --header "content-type: application/json" \ + --data @- < { + const { meeting_id } = z.object({ meeting_id: z.string() }).parse(args); + const { access_token } = await get_zoom_oauth_access_token(); + return generate_zoom_join_token({ access_token, meeting_id }); +} + +/** + * Get the Zoom OAuth access token. + * This is the token that is used to authenticate requests to the Zoom API. + * You can generate a new access token as long as you have a valid refresh token. + */ +export async function get_zoom_oauth_access_token(): Promise<{ access_token: string, refresh_token: string }> { + // Get the refresh token from storage + const file_path = path.join(cwd(), "output/zoom_oauth_refresh_token.txt"); + const refresh_token = fs.readFileSync(file_path, "utf8").trim(); + if (!refresh_token) throw new Error("No refresh token found. Generate a new one by calling the /zoom/oauth endpoint."); + + // Refresh the access token + const token = Buffer + .from(`${env.ZOOM_OAUTH_APP_CLIENT_ID}:${env.ZOOM_OAUTH_APP_CLIENT_SECRET}`) + .toString("base64"); + const response = await fetch("https://zoom.us/oauth/token", { + method: "POST", + headers: { + "Authorization": `Basic ${token}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token }).toString(), + }); + if (!response.ok) throw new Error(await response.text()); + + return z.object({ + access_token: z.string(), + refresh_token: z.string(), + }).parse(await response.json()); +} + +/** + * Generates a Zoom join token for local recording. + * Sets bypass_waiting_room=true so the token can skip waiting-room admission. + */ +async function generate_zoom_join_token(args: { access_token: string, meeting_id: string }): Promise<{ join_token: string }> { + const { access_token, meeting_id } = z.object({ access_token: z.string(), meeting_id: z.string() }).parse(args); + const response = await fetch( + `https://api.zoom.us/v2/meetings/${meeting_id}/jointoken/local_recording?bypass_waiting_room=true`, + { headers: { Authorization: `Bearer ${access_token}` } }, + ); + if (!response.ok) throw new Error(await response.text()); + + const data = z.object({ token: z.string() }).parse(await response.json()); + return { join_token: data.token }; +} diff --git a/bot_zoom_skip_waiting_room/src/api/zoom_oauth.ts b/bot_zoom_skip_waiting_room/src/api/zoom_oauth.ts new file mode 100644 index 0000000..f11cdd4 --- /dev/null +++ b/bot_zoom_skip_waiting_room/src/api/zoom_oauth.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { env } from "../config/env"; + +/** + * Generate a Zoom OAuth authorization URL. + * This URL will redirect the user to the Zoom OAuth authorization page where they can authorize your Zoom OAuth app. + * Once the user has authorized the app, they will be redirected to your Zoom OAuth app's redirect URI with an authorization code. + * You can then use this authorization code to generate an access token and refresh token. + */ +export function zoom_oauth(): string { + const authorization_url = generate_zoom_oauth_authorization_url({ + zoom_oauth_app_client_id: env.ZOOM_OAUTH_APP_CLIENT_ID, + zoom_oauth_app_redirect_uri: env.ZOOM_OAUTH_APP_REDIRECT_URI, + }); + return authorization_url; +} + +/** + * Generates a Zoom OAuth authorization URL. + */ +function generate_zoom_oauth_authorization_url(args: { zoom_oauth_app_client_id: string, zoom_oauth_app_redirect_uri: string }): string { + const { zoom_oauth_app_client_id, zoom_oauth_app_redirect_uri } = z.object({ + zoom_oauth_app_client_id: z.string(), + zoom_oauth_app_redirect_uri: z.string(), + }).parse(args); + + const url = new URL("https://zoom.us/oauth/authorize"); + url.searchParams.set("response_type", "code"); // Telling Zoom to return a code which you can exchange for the user's Zoom OAuth access/refresh tokens + url.searchParams.set("client_id", zoom_oauth_app_client_id); + url.searchParams.set("redirect_uri", zoom_oauth_app_redirect_uri); + + return url.toString(); +} diff --git a/bot_zoom_skip_waiting_room/src/api/zoom_oauth_callback.ts b/bot_zoom_skip_waiting_room/src/api/zoom_oauth_callback.ts new file mode 100644 index 0000000..d8209aa --- /dev/null +++ b/bot_zoom_skip_waiting_room/src/api/zoom_oauth_callback.ts @@ -0,0 +1,74 @@ +import fs from "fs"; +import path from "path"; +import { cwd } from "process"; +import { z } from "zod"; +import { env } from "../config/env"; + +/** + * After the user has authorized the app, they will be redirected to your Zoom OAuth app's redirect URI with an authorization code. + * You can then use this authorization code to generate an access token and refresh token. + * You can save the refresh token to a file to be used later to generate a new access token once the access token has expired. + */ +export async function zoom_oauth_callback(args: { authorization_code: string }): Promise<{ + access_token: string, + refresh_token: string +}> { + const { authorization_code } = z.object({ authorization_code: z.string() }).parse(args); + const { access_token, refresh_token } = await generate_oauth_tokens_from_authorization_code({ + authorization_code, + zoom_oauth_app_client_id: env.ZOOM_OAUTH_APP_CLIENT_ID, + zoom_oauth_app_client_secret: env.ZOOM_OAUTH_APP_CLIENT_SECRET, + zoom_oauth_app_redirect_uri: env.ZOOM_OAUTH_APP_REDIRECT_URI, + }); + + // Save this refresh token to be used later + const file_path = path.join(cwd(), "output/zoom_oauth_refresh_token.txt"); + fs.mkdirSync(path.dirname(file_path), { recursive: true }); + fs.writeFileSync(file_path, refresh_token); + + return { access_token, refresh_token }; +} + +/** + * Generate Zoom OAuth access and refresh tokens from an authorization code. + */ +async function generate_oauth_tokens_from_authorization_code(args: { + authorization_code: string, + zoom_oauth_app_client_id: string, + zoom_oauth_app_client_secret: string, + zoom_oauth_app_redirect_uri: string, +}): Promise<{ access_token: string, refresh_token: string }> { + const { authorization_code, + zoom_oauth_app_client_id, + zoom_oauth_app_client_secret, + zoom_oauth_app_redirect_uri, + } = z.object({ + authorization_code: z.string(), + zoom_oauth_app_client_id: z.string(), + zoom_oauth_app_client_secret: z.string(), + zoom_oauth_app_redirect_uri: z.string(), + }).parse(args); + + const url = new URL("https://zoom.us/oauth/token"); + url.searchParams.set("grant_type", "authorization_code"); + url.searchParams.set("code", authorization_code); + url.searchParams.set("redirect_uri", zoom_oauth_app_redirect_uri); + + const auth_token = Buffer + .from(`${zoom_oauth_app_client_id}:${zoom_oauth_app_client_secret}`) + .toString("base64"); + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Authorization": `Basic ${auth_token}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + if (!response.ok) throw new Error(await response.text()); + + const data = z.object({ + access_token: z.string(), + refresh_token: z.string(), + }).parse(await response.json()); + return { access_token: data.access_token, refresh_token: data.refresh_token }; +} diff --git a/bot_zoom_skip_waiting_room/src/api/zoom_obf.ts b/bot_zoom_skip_waiting_room/src/api/zoom_obf.ts new file mode 100644 index 0000000..a28a6ba --- /dev/null +++ b/bot_zoom_skip_waiting_room/src/api/zoom_obf.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { get_zoom_oauth_access_token } from "./zoom_join_token"; + +/** + * Generate a Zoom OBF token given a meeting ID. + */ +export async function zoom_obf(args: { meeting_id: string }): Promise<{ obf_token: string }> { + const { meeting_id } = z.object({ meeting_id: z.string() }).parse(args); + const { access_token } = await get_zoom_oauth_access_token(); + return generate_zoom_obf({ access_token, meeting_id }); +} + +/** + * Generates a Zoom OBF token. + * This is the token that is used to join a Zoom meeting on behalf of an OAuth user. + */ +async function generate_zoom_obf(args: { access_token: string, meeting_id: string }): Promise<{ obf_token: string }> { + const { access_token, meeting_id } = z.object({ access_token: z.string(), meeting_id: z.string() }).parse(args); + const response = await fetch( + `https://api.zoom.us/v2/users/me/token?type=onbehalf&meeting_id=${meeting_id}`, + { headers: { Authorization: `Bearer ${access_token}` } }, + ); + if (!response.ok) throw new Error(await response.text()); + + const data = z.object({ token: z.string() }).parse(await response.json()); + return { obf_token: data.token }; +} diff --git a/bot_zoom_skip_waiting_room/src/config/env.ts b/bot_zoom_skip_waiting_room/src/config/env.ts new file mode 100644 index 0000000..2e9611f --- /dev/null +++ b/bot_zoom_skip_waiting_room/src/config/env.ts @@ -0,0 +1,23 @@ +import dotenv from "dotenv"; +import { EnvSchema } from "../schemas/EnvSchema"; + +dotenv.config(); + +export const env = EnvSchema.parse(process.env); + +// Verify that the Zoom OAuth App Redirect URI is set. +if (!env.ZOOM_OAUTH_APP_REDIRECT_URI) { + throw new Error("Zoom OAuth App Redirect URI is not set"); +} + +// Verify that the redirect URI is set to the expected path for Zoom OAuth. +if (!env.ZOOM_OAUTH_APP_REDIRECT_URI?.split("?")[0].endsWith("/zoom/oauth/callback")) { + throw new Error(` +Zoom OAuth App Redirect URI path is not correct. + +Expected: "/zoom/oauth/callback" +Received: "${new URL(env.ZOOM_OAUTH_APP_REDIRECT_URI).pathname}" + +Make sure that your Redirect URI in your Zoom OAuth App is also set to: https://${process.env.NGROK_DOMAIN ?? "NGROK_DOMAIN"}/zoom/oauth/callback +`); +} \ No newline at end of file diff --git a/bot_zoom_skip_waiting_room/src/index.ts b/bot_zoom_skip_waiting_room/src/index.ts new file mode 100644 index 0000000..d46f039 --- /dev/null +++ b/bot_zoom_skip_waiting_room/src/index.ts @@ -0,0 +1,126 @@ +import http from "http"; +import dotenv from "dotenv"; +import z from "zod"; +import { zoom_join_token } from "./api/zoom_join_token"; +import { zoom_oauth } from "./api/zoom_oauth"; +import { zoom_oauth_callback } from "./api/zoom_oauth_callback"; +import { zoom_obf } from "./api/zoom_obf"; +import { env } from "./config/env"; + +dotenv.config(); + +const server = http.createServer(); + +/** + * HTTP server for handling HTTP requests from Recall.ai + */ +server.on("request", async (req, res) => { + try { + // Parse the request + const url = new URL(`https://${req.headers.host?.replace("https://", "")}${req?.url}`); + const pathname = url.pathname.at(-1) === "/" ? url.pathname.slice(0, -1) : url.pathname; + const search_params = Object.fromEntries(url.searchParams.entries()) as any; + let body: any | null = null; + try { + if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method!)) { + const body_chunks: Buffer[] = []; + for await (const chunk of req) { + body_chunks.push(chunk); + } + const raw_body = Buffer.concat(body_chunks).toString("utf-8"); + if (raw_body.trim()) body = JSON.parse(raw_body); + } + } catch (error) { + console.log("Error parsing body", error); + } + + console.log(` +Incoming HTTP request: ${req.method} ${pathname} +search_params=${JSON.stringify(search_params)} +body=${JSON.stringify(body)} + `); + + switch (pathname) { + /** + * Zoom OAuth endpoints for generating a Zoom OAuth access token and refresh token. + */ + case "/zoom/oauth": { + if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`); + + const zoom_oauth_url = zoom_oauth(); + console.log(`Created Zoom OAuth URL: ${zoom_oauth_url}`); + + // redirect to the Zoom OAuth URL + res.writeHead(302, { Location: zoom_oauth_url }); + res.end(); + return; + } + case "/zoom/oauth/callback": { + if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`); + + const { code: authorization_code } = z.object({ code: z.string() }) + .parse(Object.fromEntries(url.searchParams.entries())); + const { access_token, refresh_token } = await zoom_oauth_callback({ authorization_code }); + console.log(`Zoom OAuth callback called with authorization code access_token: ${access_token} and refresh_token: ${refresh_token}`); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + message: "Zoom OAuth callback received", + })); + return; + } + /** + * Generate a Zoom join token for local recording which includes permission to bypass the waiting room. + */ + case "/zoom/join-token": { + if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`); + + const { join_token } = await zoom_join_token(search_params); + console.log(`Generated Zoom join token: ${join_token}`); + + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(join_token); + return; + } + /** + * Generate a Zoom OBF token which is required to authenticate a participant in a Zoom meeting. + */ + case "/zoom/obf-token": { + if (req.method !== "GET") throw new Error(`Method not allowed: ${req.method}`); + + const { obf_token } = await zoom_obf(search_params); + console.log(`Generated Zoom OBF token: ${obf_token}`); + + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(obf_token); + return; + } + default: { + if (url.pathname === "/favicon.ico") { + res.writeHead(200, { "Content-Type": "image/x-icon" }); + res.end(Buffer.from("")); + return; + } + + throw new Error(`Endpoint not found: ${req.method} ${url.pathname}`); + } + } + } catch (error) { + console.error(`${req.method} ${req.url}`, error); + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } +}); + +/** + * Start the server + */ +server.listen(env.PORT, "0.0.0.0", () => { + console.log(` +Server is running on port ${env.PORT} + +To get started, open the following URL in your browser: https://${process.env.NGROK_DOMAIN ?? "NGROK_DOMAIN"}/zoom/oauth + +After you complete the OAuth flow, you can then create a bot using the \`run.sh\` script. See the README for more details. + `); +}); \ No newline at end of file diff --git a/bot_zoom_skip_waiting_room/src/schemas/EnvSchema.ts b/bot_zoom_skip_waiting_room/src/schemas/EnvSchema.ts new file mode 100644 index 0000000..58c47ef --- /dev/null +++ b/bot_zoom_skip_waiting_room/src/schemas/EnvSchema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const EnvSchema = z.object({ + PORT: z.string().transform((val) => parseInt(val)).default(4000), + ZOOM_OAUTH_APP_CLIENT_ID: z.string(), + ZOOM_OAUTH_APP_CLIENT_SECRET: z.string(), + ZOOM_OAUTH_APP_REDIRECT_URI: z.string(), +}); \ No newline at end of file diff --git a/bot_zoom_skip_waiting_room/tsconfig.json b/bot_zoom_skip_waiting_room/tsconfig.json new file mode 100644 index 0000000..6eaad52 --- /dev/null +++ b/bot_zoom_skip_waiting_room/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 81c641e..fa669d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9003,6 +9003,244 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "bot_zoom_skip_waiting_room": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "dotenv": "^17.2.3", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } + }, + "bot_zoom_skip_waiting_room/node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "bot_zoom_skip_waiting_room/node_modules/@types/node/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "bot_zoom_skip_waiting_room/node_modules/ts-node/node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "bot_zoom_skip_waiting_room/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "calendar_v2": { "version": "1.0.0", "license": "MIT", @@ -14922,6 +15160,10 @@ "resolved": "bot_zoom_obf_flow", "link": true }, + "node_modules/bot_zoom_skip_waiting_room": { + "resolved": "bot_zoom_skip_waiting_room", + "link": true + }, "node_modules/calendar_v2": { "resolved": "calendar_v2", "link": true