chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
# Zoom Transcript Integration
Easily receive and automate Zoom meeting transcripts in Botpress for meetings hosted by specific Zoom users.
## How It Works
1. **Zoom meeting ends** → Zoom sends a webhook when transcript is ready.
2. **Integration filters `host_id`** → Only allows events from specified Zoom user(s).
3. **Transcript is fetched and cleaned** → Downloaded from Zoom and converted to plain text.
4. **Event emitted** → A `transcriptReceived` event with `meetingUUID` and `transcript` is available to your flows.
---
## Prerequisites
You must be:
- A Zoom **account owner**, **admin**, or have the **“Zoom for Developers” role** to be able to create the Zoom OAuth App
- On a **Zoom premium plan** (free tier doesn't support cloud recordings)
---
## Step-by-Step Setup
### 1. Create a Zoom OAuth App
- Visit: [https://marketplace.zoom.us/](https://marketplace.zoom.us/)
- Go to **Develop > Build App** → Choose **Server-to-Server OAuth** → Name your app
- On the **App Credentials** page, copy:
- **Account ID**
- **Client ID**
- **Client Secret**
Youll use these in your Botpress integration configuration later.
- In **Information**, fill out the necessary information about yourself and the app.
- In **Features**, copy the **Secret Token**
- In **Scopes**, add:
cloud_recording:read:list_user_recordings:admin
cloud_recording:read:list_recording_files:admin
cloud_recording:read:recording:admin
- Activate the app under the **Activation** tab
### 2. Get Zoom `host_id`
You can find your Zoom `host_id` directly from the Zoom web portal.
#### Steps:
1. Log in to your Zoom account and navigate to:
**Zoom Admin Panel > User Management > Users**
2. Click on the name of the user you want to get the `host_id` for.
3. Look at the URL in your browser's address bar. It will be in this format:
https://yourdomain.zoom.us/user/xxxxxxxxxxxxx/profile you want to copy the xxxxxxxxxxxxx as that is your host_id
---
### 3. Configure the Botpress Integration
- Install this integration into your Bot
- Paste:
- `Zoom Client ID`
- `Zoom Client Secret`
- `Zoom Account ID`
- `Secret Token`
- `Allowed Zoom User IDs` → Paste your `host_id` (you can include multiple)
Click **Save Configuration**.
---
### 4. Set Webhook in Zoom
Back in your Zoom OAuth App:
- Go to **Features** → Enable **Event Subscriptions**
- Name: `Transcript Received` (Can choose a different name as well)
- Method: `Webhook`
- Endpoint URL: use the **Botpress integration URL**
- Add Event:
- Under **Recording**, select **only**:
- `Recording transcript files have completed` (`recording.transcript_completed`)
- Click **Done**
- Click **Validate** next to the endpoint URL (you should see Validated)
- Click **Save**
---
## Done!
Your Botpress bot will now receive transcripts for allowed Zoom users when cloud recordings complete. Make sure:
- You **record to the cloud**
- Youre on a **paid Zoom plan**
- Youve correctly added all intended `host_id`s
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256px" height="256px" viewBox="0 0 256 256" version="1.1">
<defs>
<linearGradient id="linear0" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0" gradientTransform="matrix(134.566488,-233.075995,233.075995,134.566488,60.82202,244.452418)">
<stop offset="0" style="stop-color:rgb(3.137255%,27.058824%,74.901961%);stop-opacity:1;"/>
<stop offset="0.6" style="stop-color:rgb(4.313725%,36.078431%,100%);stop-opacity:1;"/>
<stop offset="1" style="stop-color:rgb(30.980392%,56.470588%,93.333333%);stop-opacity:1;"/>
</linearGradient>
</defs>
<g id="surface1">
<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear0);" d="M 255.855469 127.914062 C 255.855469 141.578125 254.753906 154.972656 252.65625 168.035156 C 245.714844 211.273438 211.460938 245.523438 168.222656 252.46875 C 154.957031 254.597656 141.542969 255.667969 128.105469 255.664062 C 114.445312 255.664062 101.050781 254.566406 87.984375 252.46875 C 44.746094 245.523438 10.496094 211.273438 3.550781 168.035156 C 1.421875 154.765625 0.351562 141.351562 0.355469 127.914062 C 0.355469 114.253906 1.453125 100.859375 3.550781 87.796875 C 10.496094 44.558594 44.746094 10.304688 87.984375 3.363281 C 101.253906 1.230469 114.667969 0.164062 128.105469 0.164062 C 141.765625 0.164062 155.160156 1.265625 168.222656 3.363281 C 211.460938 10.304688 245.714844 44.558594 252.65625 87.796875 C 254.789062 101.0625 255.855469 114.480469 255.855469 127.914062 Z M 255.855469 127.914062 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 76.652344 147.480469 L 44.960938 147.480469 C 42.746094 147.484375 40.746094 146.144531 39.902344 144.097656 C 39.050781 142.050781 39.519531 139.691406 41.089844 138.128906 L 63.039062 116.179688 L 47.308594 116.179688 C 42.984375 116.179688 39.480469 112.675781 39.480469 108.351562 L 68.707031 108.351562 C 70.929688 108.351562 72.917969 109.679688 73.765625 111.730469 C 74.621094 113.777344 74.152344 116.136719 72.582031 117.703125 L 50.628906 139.652344 L 68.824219 139.652344 C 73.148438 139.652344 76.652344 143.15625 76.652344 147.480469 Z M 216.726562 123.023438 C 216.726562 114.613281 209.882812 107.765625 201.46875 107.765625 C 196.96875 107.765625 192.917969 109.722656 190.121094 112.832031 C 187.324219 109.722656 183.273438 107.765625 178.777344 107.765625 C 170.359375 107.765625 163.515625 114.609375 163.515625 123.023438 L 163.515625 147.480469 C 167.835938 147.480469 171.339844 143.976562 171.339844 139.652344 L 171.339844 123.023438 C 171.339844 118.925781 174.675781 115.589844 178.773438 115.589844 C 182.875 115.589844 186.207031 118.925781 186.207031 123.023438 L 186.207031 139.652344 C 186.207031 143.976562 189.710938 147.480469 194.035156 147.480469 L 194.035156 123.023438 C 194.035156 118.925781 197.367188 115.589844 201.46875 115.589844 C 205.566406 115.589844 208.902344 118.925781 208.902344 123.023438 L 208.902344 139.652344 C 208.902344 143.976562 212.40625 147.480469 216.726562 147.480469 Z M 159.601562 127.914062 C 159.601562 139.046875 150.582031 148.066406 139.453125 148.066406 C 128.320312 148.066406 119.300781 139.046875 119.300781 127.914062 C 119.300781 116.785156 128.320312 107.765625 139.453125 107.765625 C 150.582031 107.765625 159.601562 116.785156 159.601562 127.914062 Z M 151.777344 127.914062 C 151.777344 121.109375 146.257812 115.589844 139.453125 115.589844 C 132.644531 115.589844 127.125 121.109375 127.125 127.914062 C 127.125 134.722656 132.644531 140.242188 139.449219 140.242188 C 146.257812 140.242188 151.777344 134.722656 151.777344 127.914062 Z M 116.171875 127.914062 C 116.171875 139.046875 107.148438 148.066406 96.023438 148.066406 C 84.890625 148.066406 75.871094 139.046875 75.871094 127.914062 C 75.871094 116.785156 84.890625 107.765625 96.023438 107.765625 C 107.148438 107.765625 116.171875 116.785156 116.171875 127.914062 Z M 108.34375 127.914062 C 108.34375 121.109375 102.828125 115.589844 96.023438 115.589844 C 89.210938 115.589844 83.695312 121.109375 83.695312 127.914062 C 83.695312 134.722656 89.210938 140.242188 96.023438 140.242188 C 102.828125 140.242188 108.34375 134.722656 108.34375 127.914062 Z M 108.34375 127.914062 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,49 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
export default new IntegrationDefinition({
name: 'zoom',
version: '0.1.1',
title: 'Zoom',
description: 'Receives Zoom webhook and processes transcript for meetings.',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
schema: z.object({
zoomAccountId: z
.string()
.title('Zoom Account ID')
.describe('Zoom Account ID (Found on Zoom OAuth App under App Credentials)'),
zoomClientId: z
.string()
.title('Zoom Client ID')
.describe('Zoom Client ID (Found on Zoom OAuth App under App Credentials)'),
zoomClientSecret: z
.string()
.title('Zoom Client Secret')
.describe('Zoom Client Secret (Found on Zoom OAuth App under App Credentials)'),
secretToken: z.string().title('Secret Token').describe('Secret Token (Found on Zoom OAuth App under Features)'),
allowedZoomUserIds: z
.array(z.string())
.title('Allowed Zoom User IDs')
.describe('Process events from these Zoom User IDs'),
}),
},
channels: {},
events: {
transcriptReceived: {
title: 'Transcript Received',
description: 'Fires when a transcript is received from Zoom',
schema: z.object({
meetingUUID: z.string().title('Meeting UUID').describe('The UUID of the Zoom meeting'),
hostId: z.string().title('Host ID').describe('The ID of the host'),
transcript: z.string().title('Transcript').describe('The transcript of the meeting'),
audioUrl: z.string().title('Audio URL').describe('The URL of the meeting audio'),
rawVtt: z.string().title('Raw VTT').describe('The raw VTT file content of the meeting transcript').optional(),
}),
},
},
attributes: {
category: 'Communication & Channels',
repo: 'botpress',
},
})
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@botpresshub/zoom",
"scripts": {
"build": "bp add -y && bp build",
"check:bplint": "bp lint",
"check:type": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"axios": "^1.13.2",
"exponential-backoff": "^3.1.1"
},
"devDependencies": {
"@botpress/cli": "workspace:*"
}
}
+125
View File
@@ -0,0 +1,125 @@
import { RuntimeError, z } from '@botpress/sdk'
import axios from 'axios'
import { backOff } from 'exponential-backoff'
import * as bp from '.botpress'
const accessTokenResponseSchema = z.object({
access_token: z.string().optional(),
error: z.string().optional(),
error_description: z.string().optional(),
})
const recordingFileSchema = z.object({
file_type: z.string(),
file_extension: z.string(),
download_url: z.string().optional(),
})
const recordingsResponseSchema = z.object({
recording_files: z.array(recordingFileSchema).optional(),
})
export class ZoomClient {
public constructor(
private _config: Pick<bp.configuration.Configuration, 'zoomClientId' | 'zoomClientSecret' | 'zoomAccountId'>,
private _logger: bp.Logger
) {}
public async getAccessToken(): Promise<string> {
const { zoomClientId, zoomClientSecret, zoomAccountId } = this._config
const tokenUrl = `https://zoom.us/oauth/token?grant_type=account_credentials&account_id=${zoomAccountId}`
const authHeader = Buffer.from(`${zoomClientId}:${zoomClientSecret}`).toString('base64')
try {
const { data } = await axios.post(tokenUrl, null, {
headers: {
Authorization: `Basic ${authHeader}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
timeout: 10000,
})
const validation = accessTokenResponseSchema.safeParse(data)
if (!validation.success || !validation.data.access_token) {
throw new RuntimeError('Invalid access token response')
}
return validation.data.access_token
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
const validation = accessTokenResponseSchema.safeParse(error.response.data)
const errorData = validation.success ? validation.data : {}
const errorMsg = `Zoom access token fetch failed (HTTP ${error.response.status}): ${errorData.error_description || errorData.error || 'Unknown error'}`
this._logger.forBot().error(errorMsg, errorData)
throw new RuntimeError(errorMsg)
}
this._logger.forBot().error('Failed to fetch Zoom access token', error)
throw new RuntimeError('Failed to fetch Zoom access token')
}
}
/**
* Fetch transcript file URL with retries
* Zoom may take time to generate the transcript, so we retry up to 3 times
*
* API: GET /meetings/{meetingId}/recordings
* Docs: https://developers.zoom.us/docs/api/meetings/#tag/cloud-recording
*/
public async fetchTranscriptUrl(
meetingUUID: string,
accessToken: string
): Promise<{ transcriptUrl: string; audioUrl: string } | null> {
const encodedUUID = encodeURIComponent(meetingUUID)
try {
return await backOff(
async () => {
const { data } = await axios.get(`https://api.zoom.us/v2/meetings/${encodedUUID}/recordings`, {
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 15000,
})
const validation = recordingsResponseSchema.safeParse(data)
if (!validation.success) {
this._logger.forBot().error('Invalid recordings response format')
throw new RuntimeError('Invalid recordings response format')
}
const transcriptFile = validation.data.recording_files?.find(
(f) => f.file_type === 'TRANSCRIPT' && f.file_extension === 'VTT'
)
if (!transcriptFile?.download_url) {
throw new Error('Transcript not ready')
}
const audioFile = validation.data.recording_files?.find((f) => f.file_type === 'M4A')
if (!audioFile?.download_url) {
throw new Error('Audio not ready')
}
return { transcriptUrl: transcriptFile.download_url, audioUrl: audioFile.download_url }
},
{ numOfAttempts: 3, startingDelay: 20000 }
)
} catch (error) {
// Return null if transcript or audio wasn't found after retries
if (error instanceof Error && (error.message === 'Transcript not ready' || error.message === 'Audio not ready')) {
this._logger
.forBot()
.warn(`Recording files not found for meeting ${meetingUUID} after retries: ${error.message}`)
return null
}
// Re-throw other errors (network failures, auth errors, etc.)
if (axios.isAxiosError(error)) {
const status = error.response?.status
this._logger.forBot().error(`Failed to fetch recordings for meeting ${meetingUUID}: HTTP ${status}`, error)
throw new RuntimeError(`Failed to fetch recordings: HTTP ${status || 'network error'}`)
}
throw error
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { register, unregister, handler } from './setup'
import * as bp from '.botpress'
export default new bp.Integration({
register,
unregister,
handler,
actions: {},
channels: {},
})
+117
View File
@@ -0,0 +1,117 @@
import { Request, RuntimeError } from '@botpress/sdk'
import axios from 'axios'
import crypto from 'crypto'
import { ZoomClient } from '../client'
import { zoomWebhookSchema, TranscriptCompletedPayload } from '../types'
import { cleanVtt } from '../utils'
import * as bp from '.botpress'
const handleTranscriptCompleted = async ({
payload,
config,
client,
logger,
}: {
payload: TranscriptCompletedPayload
config: bp.configuration.Configuration
client: bp.Client
logger: bp.Logger
}): Promise<{ status: number; body: string }> => {
try {
const allowedUserIds = config.allowedZoomUserIds
const hostId = payload.payload.object.host_id
if (!allowedUserIds.includes(hostId)) {
return { status: 200, body: 'Event ignored: userId not allowed' }
}
const zoomClient = new ZoomClient(config, logger)
const accessToken = await zoomClient.getAccessToken()
const meetingUUID = payload.payload.object.uuid
const response = await zoomClient.fetchTranscriptUrl(meetingUUID, accessToken)
if (!response) {
throw new RuntimeError('Transcript file not found after retries')
}
const { transcriptUrl, audioUrl } = response
const { data: vttText } = await axios.get<string>(transcriptUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
responseType: 'text',
timeout: 30000,
})
const plainText = cleanVtt(vttText)
await client.createEvent({
type: 'transcriptReceived',
payload: {
meetingUUID,
hostId,
transcript: plainText,
audioUrl,
rawVtt: vttText,
},
})
logger.forBot().info(`Transcript processed successfully for meeting ${meetingUUID}`)
return { status: 200, body: 'Transcript processed.' }
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
logger.forBot().error(`Transcript handler error: ${errorMsg}`)
return { status: 500, body: `Zoom handler error: ${errorMsg}` }
}
}
export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => {
const config = ctx.configuration
const isSignatureValid = _isSignatureValid(req, config.secretToken)
if (!isSignatureValid) {
logger.forBot().warn('Request signature was invalid')
return { status: 401, body: 'Invalid signature' }
}
try {
const parsed = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const validation = zoomWebhookSchema.safeParse(parsed)
if (!validation.success) {
logger.forBot().error(`Invalid webhook body format: ${validation.error.message}`)
return { status: 400, body: 'Invalid request body format' }
}
const webhookBody = validation.data
if (webhookBody.event === 'endpoint.url_validation') {
const plainToken = webhookBody.payload.plainToken
const encryptedToken = crypto.createHmac('sha256', config.secretToken).update(plainToken).digest('hex')
return {
status: 200,
body: JSON.stringify({ plainToken, encryptedToken }),
headers: { 'Content-Type': 'application/json' },
}
}
if (webhookBody.event === 'recording.transcript_completed') {
return await handleTranscriptCompleted({ payload: webhookBody, config, client, logger })
}
return { status: 200, body: 'Event ignored.' }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
logger.forBot().error(`Webhook handler error: ${errorMsg}`)
return { status: 400, body: 'Invalid request' }
}
}
const _isSignatureValid = (req: Request, secret: string) => {
const timestamp = req.headers?.['x-zm-request-timestamp']
const signature = req.headers?.['x-zm-signature']
if (!timestamp || !signature) {
return false
}
const message = `v0:${timestamp}:${typeof req.body === 'string' ? req.body : JSON.stringify(req.body)}`
const expectedSig = 'v0=' + crypto.createHmac('sha256', secret).update(message).digest('hex')
return signature === expectedSig
}
+3
View File
@@ -0,0 +1,3 @@
export { handler } from './handler'
export { register } from './register'
export { unregister } from './unregister'
+18
View File
@@ -0,0 +1,18 @@
import { RuntimeError } from '@botpress/sdk'
import { ZoomClient } from '../client'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ ctx, logger }) => {
try {
const zoomClient = new ZoomClient(ctx.configuration, logger)
await zoomClient.getAccessToken()
logger.forBot().info('Zoom integration registered successfully')
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
logger.forBot().error(`Failed to connect to Zoom API: ${errorMsg}`)
throw new RuntimeError(
`Configuration Error! Please check your Zoom Account ID, Client ID, and Client Secret. Error: ${errorMsg}`
)
}
}
@@ -0,0 +1,5 @@
import * as bp from '.botpress'
export const unregister: bp.IntegrationProps['unregister'] = async ({ logger }) => {
logger.forBot().info('Unregister process for Zoom integration invoked. No resources to clean up.')
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from '@botpress/sdk'
const urlValidationSchema = z.object({
event: z.literal('endpoint.url_validation'),
payload: z.object({
plainToken: z.string(),
}),
})
const transcriptCompletedSchema = z.object({
event: z.literal('recording.transcript_completed'),
payload: z.object({
object: z.object({
uuid: z.string(),
host_id: z.string(),
}),
}),
})
export const zoomWebhookSchema = z.discriminatedUnion('event', [urlValidationSchema, transcriptCompletedSchema])
export type TranscriptCompletedPayload = z.infer<typeof transcriptCompletedSchema>
+7
View File
@@ -0,0 +1,7 @@
export function cleanVtt(vtt: string): string {
return vtt
.replace(/WEBVTT\n/g, '')
.replace(/\d{2}:\d{2}:\d{2}\.\d{3} --> .*\n/g, '')
.replace(/\n+/g, ' ')
.trim()
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}