From 5b3b4d65a9a0925e284f98e57e720843de5fc3d6 Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 26 May 2026 12:12:57 +0200 Subject: [PATCH 01/53] Enhance Azure Blob Storage support with SAS token handling improvements, refactor connection string parsing, and improve error logging for signed URL generation. Update documentation to clarify Azure URL behavior. --- README.md | 4 +- src/middleware/disk-uploader.ts | 13 ++--- .../providers/azure-blob-storage-provider.ts | 51 ++++++++++++++----- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 100aa378..2b0bc393 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ An example JSON payload sent via webhook and pushed to the Redis list: Notes: - The storage URL is provided as blobUrl to be storage-provider agnostic (works for S3, Azure Blob, etc.). It may be omitted if not available. -- If available from internal APIs (screenapp uploader), a direct file URL is used. For S3-compatible uploads, the URL is constructed based on S3 configuration. +- If available from internal APIs (screenapp uploader), a direct file URL is used. For S3-compatible uploads, the URL is constructed based on S3 configuration. For Azure Blob Storage, notification URLs are SAS URLs; unsigned Azure public blob URLs are not pushed to Redis as a fallback. - If a webhook secret is configured, the request body is signed with HMAC-SHA256 and sent in the X-Webhook-Signature header. - The metadata.storage section includes provider-specific path details. For S3-compatible uploads: bucket and key are provided. For the Screenapp uploader, you may see `{ provider: "screenapp", fileId, url, defaultProfile }`. @@ -445,7 +445,7 @@ Notes: - The default object key layout is: `meeting-bot/{userId}/{fileName}{extension}` (e.g., `meeting-bot/1234/My Meeting - 2025-11-13 14-42.webm`). This same layout is used for both S3 and Azure to ensure parity. - When `STORAGE_PROVIDER=azure` is set and Azure environment variables are provided, the upload will go to Azure Blob Storage instead of S3. -- Signed URL generation for Azure uses SAS tokens with a configurable TTL via `AZURE_SIGNED_URL_TTL_SECONDS`. +- Signed URL generation for Azure uses SAS tokens with a configurable TTL via `AZURE_SIGNED_URL_TTL_SECONDS`. Redis completion notifications use these SAS URLs for Azure `blobUrl` and `metadata.storage.url`. ## ⚙️ Configuration diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index 69173b91..e4163d23 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -544,22 +544,15 @@ class DiskUploader implements IUploader { url: this.lastUploadedBlobUrl, }; } else if (provider.name === 'azure') { - // Prefer signed URL if method is available let url: string | undefined; if (typeof (provider as any).getSignedUrl === 'function') { try { url = await (provider as any).getSignedUrl(key, { expiresInSeconds: config.azureBlobStorage.signedUrlTtlSeconds }); } catch (e) { - this._logger.warn('Failed to generate signed URL for Azure blob, falling back to public URL (may be inaccessible without SAS)', e as any); - } - } - // Construct canonical URL if no signed URL available - if (!url) { - const account = config.azureBlobStorage.accountName; - const container = config.azureBlobStorage.container; - if (account && container) { - url = `https://${account}.blob.core.windows.net/${container}/${encodeURI(key)}`; + this._logger.error('Failed to generate SAS URL for Azure blob. Notification payload will not include an unsigned Azure URL.', e as any); } + } else { + this._logger.error('Azure storage provider does not support SAS URL generation. Notification payload will not include an Azure URL.'); } this.lastUploadedBlobUrl = url; this.lastStorageDetails = { diff --git a/src/uploader/providers/azure-blob-storage-provider.ts b/src/uploader/providers/azure-blob-storage-provider.ts index c4d048c6..4e1bb5c8 100644 --- a/src/uploader/providers/azure-blob-storage-provider.ts +++ b/src/uploader/providers/azure-blob-storage-provider.ts @@ -14,6 +14,25 @@ import { DefaultAzureCredential } from '@azure/identity'; export class AzureBlobStorageProvider implements StorageProvider { readonly name = 'azure' as const; + private parseConnectionString(connectionString?: string): Record { + if (!connectionString) return {}; + + return connectionString.split(';').reduce>((parts, part) => { + const separatorIndex = part.indexOf('='); + if (separatorIndex <= 0) return parts; + + const key = part.slice(0, separatorIndex); + const value = part.slice(separatorIndex + 1); + if (key && value) parts[key] = value; + return parts; + }, {}); + } + + private appendSasToken(baseUrl: string, sasToken: string): string { + const token = sasToken.startsWith('?') ? sasToken.substring(1) : sasToken; + return `${baseUrl}?${token}`; + } + private getContainerClient(): ContainerClient { const cfg = config.azureBlobStorage; @@ -79,41 +98,45 @@ export class AzureBlobStorageProvider implements StorageProvider { // Determine credential type for SAS const cfg = config.azureBlobStorage; + const connectionStringParts = this.parseConnectionString(cfg.connectionString); + const accountName = cfg.accountName || connectionStringParts.AccountName; + const accountKey = cfg.accountKey || connectionStringParts.AccountKey; + const sasToken = cfg.sasToken || connectionStringParts.SharedAccessSignature; const baseUrl = container.getBlockBlobClient(blobName).url.split('?')[0]; - if (cfg.accountName && cfg.accountKey) { - const cred = new StorageSharedKeyCredential(cfg.accountName, cfg.accountKey); + if (accountName && accountKey) { + const cred = new StorageSharedKeyCredential(accountName, accountKey); const sas = generateBlobSASQueryParameters({ containerName: container.containerName, blobName, permissions: BlobSASPermissions.parse('r'), + startsOn: new Date(Date.now() - 5 * 60 * 1000), expiresOn: new Date(Date.now() + expiresIn * 1000), protocol: SASProtocol.Https, }, cred).toString(); return `${baseUrl}?${sas}`; } - // If we have connection string with key, it is handled above; with SAS, URL already has SAS for container but not blob-specific; we can return blob URL with container SAS - if (cfg.connectionString || (cfg.accountName && cfg.sasToken)) { - // Container SAS should suffice to access blobs inside; ensure token is appended - const sas = cfg.connectionString ? '' : (cfg.sasToken || ''); - const token = sas.startsWith('?') ? sas.substring(1) : sas; // strip leading ? if present - const sep = baseUrl.includes('?') ? '&' : '?'; - return token ? `${baseUrl}${sep}${token}` : baseUrl; + if (sasToken) { + // A supplied container/account SAS should grant access to blobs inside the container. + return this.appendSasToken(baseUrl, sasToken); } // Managed identity / AAD: create User Delegation SAS - if (cfg.accountName && cfg.useManagedIdentity) { + if (accountName && cfg.useManagedIdentity) { const cred = new DefaultAzureCredential(); - const service = new BlobServiceClient(`https://${cfg.accountName}.blob.core.windows.net`, cred); - const key = await service.getUserDelegationKey(new Date(), new Date(Date.now() + expiresIn * 1000)); + const service = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, cred); + const startsOn = new Date(Date.now() - 5 * 60 * 1000); + const expiresOn = new Date(Date.now() + expiresIn * 1000); + const key = await service.getUserDelegationKey(startsOn, expiresOn); const sas = generateBlobSASQueryParameters({ containerName: container.containerName, blobName, permissions: BlobSASPermissions.parse('r'), - expiresOn: new Date(Date.now() + expiresIn * 1000), + startsOn, + expiresOn, protocol: SASProtocol.Https, - }, key, cfg.accountName).toString(); + }, key, accountName).toString(); return `${baseUrl}?${sas}`; } From 26827ec056f979482a4c765b48df0c8df8251de4 Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 26 May 2026 12:16:09 +0200 Subject: [PATCH 02/53] Bump version to 1.2.11 in package.json and package-lock.json. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ba5db7d..d4f6cab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.4", + "version": "1.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.4", + "version": "1.2.11", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 4cf7f926..baab4e54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.10", + "version": "1.2.11", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From c2e4c879f768bb1f29ce7b7997907d9e74f237d9 Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 26 May 2026 17:30:42 +0200 Subject: [PATCH 03/53] Add MP4 transcoding support, MIME type abstraction, and recording duration metadata handling. Bump version to 1.2.12. --- package-lock.json | 4 +- package.json | 2 +- src/bots/GoogleMeetBot.ts | 8 ++-- src/config.ts | 7 ++- src/lib/recording.ts | 14 ++++++ src/middleware/disk-uploader.ts | 77 +++++++++++++++++++++++++++++++++ src/services/uploadService.ts | 3 ++ src/tasks/RecordingTask.ts | 8 ++-- 8 files changed, 113 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4f6cab8..88531fd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.11", + "version": "1.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.11", + "version": "1.2.12", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index baab4e54..3f3d341c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.11", + "version": "1.2.12", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 61f33e26..874c5f10 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -13,7 +13,7 @@ import { retryActionWithWait } from '../util/resilience'; import { uploadDebugImage } from '../services/bugService'; import createBrowserContext from '../lib/chromium'; import { GOOGLE_LOBBY_MODE_HOST_TEXT, GOOGLE_REQUEST_DENIED, GOOGLE_REQUEST_TIMEOUT } from '../constants'; -import { vp9MimeType, webmMimeType } from '../lib/recording'; +import { getRecordingMimeTypesForExtension } from '../lib/recording'; export class GoogleMeetBot extends MeetBotBase { private _logger: Logger; @@ -525,6 +525,8 @@ export class GoogleMeetBot extends MeetBotBase { } }); + const { primaryMimeType, secondaryMimeType } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + // Inject the MediaRecorder code into the browser context using page.evaluate await this.page.evaluate( async ({ teamId, duration, inactivityLimit, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: @@ -1058,8 +1060,8 @@ export class GoogleMeetBot extends MeetBotBase { slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - primaryMimeType: webmMimeType, - secondaryMimeType: vp9MimeType + primaryMimeType, + secondaryMimeType } ); diff --git a/src/config.ts b/src/config.ts index 412fabc9..31b8c320 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,6 +45,11 @@ const constructRedisUri = () => { } }; +const normalizeFileExtension = (extension?: string) => { + if (!extension) return '.webm'; + return extension.startsWith('.') ? extension : `.${extension}`; +}; + export default { port: process.env.PORT || 3000, db: { @@ -81,7 +86,7 @@ export default { notifyRedisUri: process.env.NOTIFY_REDIS_URI, // optional override notifyRedisDb: process.env.NOTIFY_REDIS_DB ? Number(process.env.NOTIFY_REDIS_DB) : 1, // must not default to 0 notifyRedisList: process.env.NOTIFY_REDIS_LIST ?? 'jobs:meetbot:recordings', - uploaderFileExtension: process.env.UPLOADER_FILE_EXTENSION ? process.env.UPLOADER_FILE_EXTENSION : '.webm', + uploaderFileExtension: normalizeFileExtension(process.env.UPLOADER_FILE_EXTENSION), isRedisEnabled: process.env.REDIS_CONSUMER_ENABLED === 'true', s3CompatibleStorage: { endpoint: process.env.S3_ENDPOINT, diff --git a/src/lib/recording.ts b/src/lib/recording.ts index 484066fa..31c2a088 100644 --- a/src/lib/recording.ts +++ b/src/lib/recording.ts @@ -14,3 +14,17 @@ export const webmMimeType = `${webmContentType};codecs="h264,opus"`; export const vp9ContentType: ContentType = 'video/webm'; export const vp9MimeType = `${vp9ContentType};codecs="vp9,opus"`; + +export const getRecordingMimeTypesForExtension = (extension: string) => { + if (extension === '.mp4') { + return { + primaryMimeType: mp4MimeType, + secondaryMimeType: webmMimeType, + }; + } + + return { + primaryMimeType: webmMimeType, + secondaryMimeType: vp9MimeType, + }; +}; diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index e4163d23..cc0e2c83 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -9,6 +9,8 @@ import { import { ContentType, extensionToContentType, FileType } from '../types'; import fs, { createWriteStream } from 'fs'; import path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { LogAggregator } from '../util/logger'; import config from '../config'; import { getStorageProvider } from '../uploader/providers/factory'; @@ -18,6 +20,7 @@ import { notifyRecordingCompleted, RecordingCompletedPayload } from '../services console.log(' ----- PWD OR CWD ----- ', process.cwd()); const tempFolder = path.join(process.cwd(), 'dist', '_tempvideo'); +const execFileAsync = promisify(execFile); function isNoSuchUploadError(err: any, userId: string, logger: Logger): boolean { /** @@ -75,6 +78,7 @@ class DiskUploader implements IUploader { private lastUploadedBlobUrl?: string; private lastRecordingId?: string; private lastStorageDetails?: Record; + private recordingDuration?: number; private queue: Buffer[]; private writing: boolean; @@ -192,6 +196,7 @@ class DiskUploader implements IUploader { timezone: this._timezone, namePrefix: this._namePrefix, botId: this._botId, + duration: this.recordingDuration, }, this._logger); this._logger.info('Finish recording upload...', file.name, this._userId, this._teamId); try { @@ -212,6 +217,7 @@ class DiskUploader implements IUploader { fileId: (file as any)?._id, url: fileUrl, defaultProfile: file.defaultProfile, + duration: this.recordingDuration ?? file.duration, }; } catch {} } catch {} @@ -445,6 +451,70 @@ class DiskUploader implements IUploader { this._logger.info('Finish wait on temp file write...', userId); } + private async isMp4File(filePath: string): Promise { + const file = await fs.promises.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(12); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + return bytesRead >= 12 && buffer.toString('ascii', 4, 8) === 'ftyp'; + } finally { + await file.close(); + } + } + + private async ensureRequestedContainer(filePath: string): Promise { + if (this.fileExtension !== '.mp4') return; + + if (await this.isMp4File(filePath)) { + return; + } + + const outputPath = `${filePath}.transcoded.mp4`; + this._logger.info('Recording temp file is not an MP4 container. Transcoding before upload...', { + inputPath: filePath, + outputPath, + }); + + try { + await execFileAsync('ffmpeg', [ + '-y', + '-i', filePath, + '-c:v', 'libx264', + '-preset', 'veryfast', + '-pix_fmt', 'yuv420p', + '-c:a', 'aac', + '-movflags', '+faststart', + outputPath, + ], { maxBuffer: 10 * 1024 * 1024 }); + + await fs.promises.rename(outputPath, filePath); + this._logger.info('Transcoded recording temp file to MP4 container before upload.', { filePath }); + } catch (err) { + try { + await fs.promises.unlink(outputPath); + } catch {} + throw err; + } + } + + private async getMediaDuration(filePath: string): Promise { + try { + const { stdout } = await execFileAsync('ffprobe', [ + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + filePath, + ], { maxBuffer: 1024 * 1024 }); + + const duration = Number.parseFloat(stdout.trim()); + if (!Number.isFinite(duration) || duration <= 0) return undefined; + return Math.round(duration); + } catch (err) { + this._logger.warn('Unable to determine recording duration with ffprobe', { filePath, error: err }); + return undefined; + } + } + private async finalizeDiskWriting() { try { await this.waitForWritingFlag(); @@ -455,6 +525,10 @@ class DiskUploader implements IUploader { await this.writeWithRetries(); } + const filePath = DiskUploader.getFilePath(this._userId, this._tempFileId, this.fileExtension); + await this.ensureRequestedContainer(filePath); + this.recordingDuration = await this.getMediaDuration(filePath); + return true; } catch(err) { this._logger.info('Critical: Failed to finalise temp file write...', this._userId, err); @@ -542,6 +616,7 @@ class DiskUploader implements IUploader { endpoint: s3cfg.endpoint, forcePathStyle: !!s3cfg.forcePathStyle, url: this.lastUploadedBlobUrl, + duration: this.recordingDuration, }; } else if (provider.name === 'azure') { let url: string | undefined; @@ -563,6 +638,7 @@ class DiskUploader implements IUploader { url: this.lastUploadedBlobUrl, signedUrlTtlSeconds: config.azureBlobStorage.signedUrlTtlSeconds, blobPrefix: config.azureBlobStorage.blobPrefix, + duration: this.recordingDuration, }; } } catch (metaErr) { @@ -651,6 +727,7 @@ class DiskUploader implements IUploader { botId: this._botId, contentType: this.contentType, uploaderType: config.uploaderType, + duration: this.recordingDuration, }, }; await notifyRecordingCompleted(payload, this._logger); diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index 6abfd283..5dd75b36 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -75,6 +75,7 @@ type FinalizeUploadOptions = { timezone: string; namePrefix: string; botId: string; + duration?: number; }; interface FinalizeUploadResponseBody { file: FileType; @@ -90,6 +91,7 @@ export const finalizeUpload = async ({ timezone, namePrefix, botId, + duration, }: FinalizeUploadOptions, logger: Logger) => { const apiV2 = createApiV2(token); const time = getTimeString(timezone, logger); @@ -101,6 +103,7 @@ export const finalizeUpload = async ({ contentType, name: fileNameTemplate(namePrefix, time), botId: botId, + ...(typeof duration === 'number' ? { duration } : {}), }, } ); diff --git a/src/tasks/RecordingTask.ts b/src/tasks/RecordingTask.ts index f35855be..f93808f6 100644 --- a/src/tasks/RecordingTask.ts +++ b/src/tasks/RecordingTask.ts @@ -2,7 +2,7 @@ import { Page } from 'playwright'; import { Task } from '../lib/Task'; import config from '../config'; import { Logger } from 'winston'; -import { vp9MimeType, webmMimeType } from '../lib/recording'; +import { getRecordingMimeTypesForExtension } from '../lib/recording'; export class RecordingTask extends Task { private userId: string; @@ -30,6 +30,8 @@ export class RecordingTask extends Task { } protected async execute(): Promise { + const { primaryMimeType, secondaryMimeType } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + await this.page.evaluate( async ({ teamId, duration, inactivityLimit, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: { teamId: string, duration: number, inactivityLimit: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { @@ -267,8 +269,8 @@ export class RecordingTask extends Task { slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - primaryMimeType: webmMimeType, - secondaryMimeType: vp9MimeType + primaryMimeType, + secondaryMimeType } ); } From f8a739714a410734fbea17818766c923bcd7fbc5 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 27 May 2026 10:36:18 +0200 Subject: [PATCH 04/53] Streamlined bot interactions, added more robust audio initialization with `wait_for_pulseaudio`, reduced delays, optimized selector handling, and improved Teams prewarm customization. Updated version to 1.2.13. --- README.md | 2 + package-lock.json | 4 +- package.json | 2 +- src/bots/GoogleMeetBot.ts | 59 ++++---- src/bots/MicrosoftTeamsBot.ts | 274 ++++++++++++---------------------- src/bots/ZoomBot.ts | 92 ++++++------ src/config.ts | 2 + src/lib/ffmpegRecorder.ts | 4 +- start.sh | 22 ++- xvfb-run-wrapper | 20 ++- 10 files changed, 208 insertions(+), 273 deletions(-) diff --git a/README.md b/README.md index 2b0bc393..a0880a44 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,8 @@ Notes: | `MAX_RECORDING_DURATION_MINUTES` | Maximum recording duration in minutes | `180` | | `MEETING_INACTIVITY_MINUTES` | Continuous inactivity duration after which the bot will end meeting recording | `1` | | `INACTIVITY_DETECTION_START_DELAY_MINUTES` | Initial grace period at the start of recording before inactivity detection begins | `1` | +| `TEAMS_PREWARM_ENABLED` | Enable the extra Microsoft Teams warmup browser pass for environments that still show first-run dialogs | `false` | +| `TEAMS_AUDIO_STABILIZATION_MS` | Delay before starting Microsoft Teams ffmpeg recording after joining | `1000` | | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment mode | `development` | | `UPLOADER_FILE_EXTENSION` | Final recording file extension (e.g., .mkv, .webm) | `.webm` | diff --git a/package-lock.json b/package-lock.json index 88531fd9..f98282d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.12", + "version": "1.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.12", + "version": "1.2.13", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 3f3d341c..1563052f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.12", + "version": "1.2.13", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 874c5f10..ee61a1e0 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -83,30 +83,27 @@ export class GoogleMeetBot extends MeetBotBase { this.page = await createBrowserContext(url, this._correlationId, 'google'); this._logger.info('Navigating to Google Meet URL...'); - await this.page.goto(url, { waitUntil: 'networkidle' }); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); - this._logger.info('Waiting for 10 seconds...'); - await this.page.waitForTimeout(10000); + const nameInputSelector = 'input[type="text"][aria-label="Your name"]'; + const waitForPreJoinReady = async () => { + const continueWithoutDevicesButton = this.page.getByRole('button', { name: 'Continue without microphone and camera' }); + const preJoinReady = await Promise.race([ + continueWithoutDevicesButton.waitFor({ timeout: 15000 }).then(() => 'device-check' as const).catch(() => null), + this.page.locator(nameInputSelector).first().waitFor({ state: 'visible', timeout: 15000 }).then(() => 'name-input' as const).catch(() => null), + ]); - const dismissDeviceCheck = async () => { - try { + if (preJoinReady === 'device-check') { this._logger.info('Clicking Continue without microphone and camera button...'); - await retryActionWithWait( - 'Clicking the "Continue without microphone and camera" button', - async () => { - await this.page.getByRole('button', { name: 'Continue without microphone and camera' }).waitFor({ timeout: 30000 }); - await this.page.getByRole('button', { name: 'Continue without microphone and camera' }).click(); - }, - this._logger, - 1, - 15000, - ); - } catch (dismissError) { - this._logger.info('Continue without microphone and camera button is probably missing!...'); + await continueWithoutDevicesButton.click(); } }; - await dismissDeviceCheck(); + try { + await waitForPreJoinReady(); + } catch (dismissError) { + this._logger.info('Continue without microphone and camera button is probably missing!...'); + } const verifyItIsOnGoogleMeetPage = async (): Promise<'SIGN_IN_PAGE' | 'GOOGLE_MEET_PAGE' | 'UNSUPPORTED_PAGE' | null> => { try { @@ -149,7 +146,7 @@ export class GoogleMeetBot extends MeetBotBase { this._logger.info('Waiting for the input field to be visible...'); await retryActionWithWait( 'Waiting for the input field', - async () => await this.page.waitForSelector('input[type="text"][aria-label="Your name"]', { timeout: 10000 }), + async () => await this.page.waitForSelector(nameInputSelector, { timeout: 10000 }), this._logger, 3, 15000, @@ -157,15 +154,9 @@ export class GoogleMeetBot extends MeetBotBase { await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'text-input-field-wait', userId, this._logger, botId); } ); - - this._logger.info('Waiting for 10 seconds...'); - await this.page.waitForTimeout(10000); this._logger.info('Filling the input field with the name...'); - await this.page.fill('input[type="text"][aria-label="Your name"]', name ? name : 'ScreenApp Notetaker'); - - this._logger.info('Waiting for 10 seconds...'); - await this.page.waitForTimeout(10000); + await this.page.fill(nameInputSelector, name ? name : 'ScreenApp Notetaker'); await retryActionWithWait( 'Clicking the "Ask to join" button', @@ -181,8 +172,8 @@ export class GoogleMeetBot extends MeetBotBase { for (const text of possibleTexts) { try { - const button = await this.page.locator('button', { hasText: new RegExp(text.toLocaleLowerCase(), 'i') }).first(); - if (await button.count() > 0) { + const button = this.page.locator('button', { hasText: new RegExp(text, 'i') }).first(); + if (await button.isVisible({ timeout: 3000 }).catch(() => false)) { await button.click({ timeout: 5000 }); buttonClicked = true; this._logger.info(`Success clicked using "${text}" action...`); @@ -247,7 +238,7 @@ export class GoogleMeetBot extends MeetBotBase { let botWasDeniedAccess = false; try { - peopleElement = await this.page.waitForSelector('button[aria-label="People"]', { timeout: 5000 }); + peopleElement = await this.page.locator('button[aria-label^="People"]').first().isVisible({ timeout: 500 }).catch(() => false); } catch(e) { this._logger.error( 'wait error', { error: e } @@ -256,7 +247,7 @@ export class GoogleMeetBot extends MeetBotBase { } try { - callButtonElement = await this.page.waitForSelector('button[aria-label="Leave call"]', { timeout: 5000 }); + callButtonElement = await this.page.locator('button[aria-label="Leave call"]').first().isVisible({ timeout: 500 }).catch(() => false); } catch(e) { this._logger.error( 'wait error', { error: e } @@ -364,7 +355,7 @@ export class GoogleMeetBot extends MeetBotBase { ); // Do nothing } - }, 20000); + }, 2000); }); const waitingAtLobbySuccess = await waitAtLobbyPromise; @@ -389,7 +380,7 @@ export class GoogleMeetBot extends MeetBotBase { try { this._logger.info('Waiting for the "Got it" button...'); - await this.page.waitForSelector('button:has-text("Got it")', { timeout: 15000 }); + await this.page.waitForSelector('button:has-text("Got it")', { timeout: 3000 }); this._logger.info('Going to click all visible "Got it" buttons...'); @@ -428,13 +419,13 @@ export class GoogleMeetBot extends MeetBotBase { gotItButtonsClicked++; this._logger.info(`Clicked a "Got it" button #${gotItButtonsClicked}`); - await this.page.waitForTimeout(2000); + await this.page.waitForTimeout(500); } catch (err) { this._logger.warn('Click failed, possibly already dismissed', { error: err }); } } - await this.page.waitForTimeout(2000); + await this.page.waitForTimeout(500); } } catch (error) { // Log and ignore this error diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index cb856d13..f761fdfd 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -95,64 +95,64 @@ export class MicrosoftTeamsBot extends MeetBotBase { } private async joinMeeting({ url, name, teamId, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { - // First run: Navigate to pre-join screen to trigger Chrome dialogs, then close - this._logger.info('Pre-warming: Opening browser to trigger first-run dialogs...'); - let warmupPage: Page | undefined; - try { - warmupPage = await createBrowserContext(url, this._correlationId, 'microsoft'); - this._logger.info('Pre-warming: Navigating to Teams meeting...'); - await warmupPage.goto(url, { waitUntil: 'networkidle' }); - - await warmupPage.waitForTimeout(2000); - - // Try to click "Join from browser" button - this._logger.info('Pre-warming: Looking for Join from browser button...'); - const joinButtonSelectors = [ - 'button[aria-label="Join meeting from this browser"]', - 'button[aria-label="Continue on this browser"]', - 'button[aria-label="Join on this browser"]', - 'button:has-text("Continue on this browser")', - 'button:has-text("Join from browser")', - ]; - - for (const selector of joinButtonSelectors) { - try { - await warmupPage.waitForSelector(selector, { timeout: 5000 }); - this._logger.info(`Pre-warming: Found button with selector: ${selector}`); - await warmupPage.click(selector, { force: true }); - this._logger.info('Pre-warming: Clicked join from browser button'); - break; - } catch (err) { - continue; + const joinButtonSelectors = [ + 'button[aria-label="Join meeting from this browser"]', + 'button[aria-label="Continue on this browser"]', + 'button[aria-label="Join on this browser"]', + 'button:has-text("Continue on this browser")', + 'button:has-text("Join from browser")', + ]; + + const clickFirstVisibleSelector = async (page: Page, selectors: string[], timeoutMs: number, logPrefix: string): Promise => { + const startedAt = Date.now(); + for (const selector of selectors) { + this._logger.info(`${logPrefix}: checking selector`, { selector }); + } + + while ((Date.now() - startedAt) < timeoutMs) { + for (const selector of selectors) { + const button = page.locator(selector).first(); + if (await button.isVisible({ timeout: 500 }).catch(() => false)) { + this._logger.info(`${logPrefix}: found button`, { selector }); + await button.click({ force: true }); + return true; + } } + await page.waitForTimeout(500); } - // Wait for pre-join screen to load - this._logger.info('Pre-warming: Waiting for pre-join screen...'); - await warmupPage.waitForTimeout(5000); + return false; + }; - this._logger.info('Pre-warming complete - dialogs triggered'); - } catch (error) { - this._logger.warn('Pre-warming failed (non-fatal):', error); - } finally { - // Guarantee the warmup chrome tree is reaped even if the block above threw - // mid-flight. join()'s outer finally only covers this.page, not warmupPage. + if (config.teamsPrewarmEnabled) { + // First run: Navigate to pre-join screen to trigger Chrome dialogs, then close. + // Disabled by default because the fake-device flags normally avoid those dialogs. + this._logger.info('Pre-warming: Opening browser to trigger first-run dialogs...'); + let warmupPage: Page | undefined; try { - const browser = warmupPage?.context().browser(); - if (browser?.isConnected()) { - this._logger.info('Pre-warming: Closing warmup browser...'); - await browser.close(); + warmupPage = await createBrowserContext(url, this._correlationId, 'microsoft'); + this._logger.info('Pre-warming: Navigating to Teams meeting...'); + await warmupPage.goto(url, { waitUntil: 'domcontentloaded' }); + await clickFirstVisibleSelector(warmupPage, joinButtonSelectors, 8000, 'Pre-warming'); + await warmupPage.locator('input[data-tid="prejoin-display-name-input"]').waitFor({ state: 'visible', timeout: 8000 }).catch(() => undefined); + this._logger.info('Pre-warming complete - dialogs triggered'); + } catch (error) { + this._logger.warn('Pre-warming failed (non-fatal):', error); + } finally { + // Guarantee the warmup chrome tree is reaped even if the block above threw + // mid-flight. join()'s outer finally only covers this.page, not warmupPage. + try { + const browser = warmupPage?.context().browser(); + if (browser?.isConnected()) { + this._logger.info('Pre-warming: Closing warmup browser...'); + await browser.close(); + } + } catch (cleanupErr) { + this._logger.warn('Pre-warming: warmup browser cleanup failed (non-fatal)', { error: cleanupErr }); } - } catch (cleanupErr) { - this._logger.warn('Pre-warming: warmup browser cleanup failed (non-fatal)', { error: cleanupErr }); } - } - - // Wait 10 seconds for Chrome to fully release the user-data-dir lock before - // the actual meeting browser opens. Skip if warmup never launched. - if (warmupPage) { - this._logger.info('Waiting 10 seconds before opening browser for actual meeting...'); - await new Promise(resolve => setTimeout(resolve, 10000)); + } else { + this._logger.info('Teams pre-warming disabled; launching the meeting browser directly'); } // Second run: Actual meeting join @@ -160,36 +160,12 @@ export class MicrosoftTeamsBot extends MeetBotBase { this.page = await createBrowserContext(url, this._correlationId, 'microsoft'); - await this.page.waitForTimeout(1000); - this._logger.info('Navigating to Microsoft Teams Meeting URL...'); - await this.page.goto(url, { waitUntil: 'networkidle' }); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); // Try to find and click "Join from browser" button this._logger.info('Waiting for Join meeting from browser button...'); - const joinButtonSelectors = [ - 'button[aria-label="Join meeting from this browser"]', - 'button[aria-label="Continue on this browser"]', - 'button[aria-label="Join on this browser"]', - 'button:has-text("Continue on this browser")', - 'button:has-text("Join from browser")', - ]; - - let buttonClicked = false; - for (const selector of joinButtonSelectors) { - try { - this._logger.info(`Trying selector: ${selector}`); - await this.page.waitForSelector(selector, { timeout: 60000 }); - this._logger.info(`Found button, clicking: ${selector}`); - await this.page.click(selector, { force: true }); - buttonClicked = true; - this._logger.info('Successfully clicked join from browser button'); - break; - } catch (err) { - this._logger.info(`Selector not found: ${selector}`); - continue; - } - } + const buttonClicked = await clickFirstVisibleSelector(this.page, joinButtonSelectors, 60000, 'Join from browser'); if (!buttonClicked) { this._logger.info('Join from browser button not found, proceeding anyway...'); @@ -205,20 +181,18 @@ export class MicrosoftTeamsBot extends MeetBotBase { const nameInput = this.page.locator('input[data-tid="prejoin-display-name-input"]'); // Wait for the field to be visible - await nameInput.waitFor({ state: 'visible', timeout: 120000 }); + await nameInput.waitFor({ state: 'visible', timeout: 45000 }); this._logger.info('Found name input field, filling with bot name...'); await nameInput.fill(name ? name : 'ScreenApp Notetaker'); - await this.page.waitForTimeout(1000); } catch (err) { - this._logger.info('Name input field not found after 120s, skipping...', err instanceof Error ? err.message : String(err)); + this._logger.info('Name input field not found after 45s, skipping...', err instanceof Error ? err.message : String(err)); } // Toggle off camera and mute microphone before joining const toggleDevices = async () => { try { this._logger.info('Attempting to turn off camera and mute microphone...'); - await this.page.waitForTimeout(2000); // Turn off camera try { @@ -232,12 +206,12 @@ export class MicrosoftTeamsBot extends MeetBotBase { for (const selector of cameraSelectors) { const cameraButton = this.page.locator(selector).first(); - const isVisible = await cameraButton.isVisible({ timeout: 2000 }).catch(() => false); + const isVisible = await cameraButton.isVisible({ timeout: 1000 }).catch(() => false); if (isVisible) { const label = await cameraButton.getAttribute('aria-label'); this._logger.info(`Clicking camera toggle: ${label}`); await cameraButton.click(); - await this.page.waitForTimeout(500); + await this.page.waitForTimeout(250); break; } } @@ -257,12 +231,12 @@ export class MicrosoftTeamsBot extends MeetBotBase { for (const selector of micSelectors) { const micButton = this.page.locator(selector).first(); - const isVisible = await micButton.isVisible({ timeout: 2000 }).catch(() => false); + const isVisible = await micButton.isVisible({ timeout: 1000 }).catch(() => false); if (isVisible) { const label = await micButton.getAttribute('aria-label'); this._logger.info(`Clicking microphone toggle: ${label}`); await micButton.click(); - await this.page.waitForTimeout(500); + await this.page.waitForTimeout(250); break; } } @@ -295,7 +269,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { for (const text of possibleTexts) { try { const button = this.page.getByRole('button', { name: new RegExp(text, 'i') }); - if (await button.isVisible({ timeout: 3000 }).catch(() => false)) { + if (await button.isVisible({ timeout: 1000 }).catch(() => false)) { await button.click(); buttonClicked = true; this._logger.info(`Successfully clicked "${text}" button`); @@ -341,81 +315,46 @@ export class MicrosoftTeamsBot extends MeetBotBase { pushState('joined'); const dismissDeviceChecksAndNotifications = async () => { - const notificationCheck = async () => { - try { - this._logger.info('Waiting for the "Close" button...'); - await this.page.waitForSelector('button[aria-label=Close]', { timeout: 5000 }); - this._logger.info('Clicking the "Close" button...'); - await this.page.click('button[aria-label=Close]', { timeout: 2000 }); - } catch (error) { - // Log and ignore this error - this._logger.info('Turn On notification might be missing...', error); + const closeSelectors = ['button[aria-label=Close]:visible', 'button[title="Close"]:visible']; + const startedAt = Date.now(); + let closeButtonsClicked = 0; + let emptyPasses = 0; + + while ((Date.now() - startedAt) < 3000) { + let clickedOnPass = false; + for (const selector of closeSelectors) { + const visibleButtons = await this.page.locator(selector).all(); + for (const btn of visibleButtons) { + try { + await btn.click({ timeout: 1000 }); + closeButtonsClicked++; + clickedOnPass = true; + } catch (err) { + this._logger.warn('Close button click failed, possibly already dismissed', { error: err }); + } + } } - }; - const deviceCheck = async () => { - try { - this._logger.info('Waiting for the "Close" button...'); - await this.page.waitForSelector('button[title="Close"]', { timeout: 5000 }); - - this._logger.info('Going to click all visible "Close" buttons...'); - - let closeButtonsClicked = 0; - let previousButtonCount = -1; - let consecutiveNoChangeCount = 0; - const maxConsecutiveNoChange = 2; // Stop if button count doesn't change for 2 consecutive iterations - - while (true) { - const visibleButtons = await this.page.locator('button[title="Close"]:visible').all(); - - const currentButtonCount = visibleButtons.length; - - if (currentButtonCount === 0) { - break; - } - - // Check if button count hasn't changed (indicating we might be stuck) - if (currentButtonCount === previousButtonCount) { - consecutiveNoChangeCount++; - if (consecutiveNoChangeCount >= maxConsecutiveNoChange) { - this._logger.warn(`Button count hasn't changed for ${maxConsecutiveNoChange} iterations, stopping`); - break; - } - } else { - consecutiveNoChangeCount = 0; - } - - previousButtonCount = currentButtonCount; - - for (const btn of visibleButtons) { - try { - await btn.click({ timeout: 5000 }); - closeButtonsClicked++; - this._logger.info(`Clicked a "Close" button #${closeButtonsClicked}`); - - await this.page.waitForTimeout(2000); - } catch (err) { - this._logger.warn('Click failed, possibly already dismissed', { error: err }); - } - } - - await this.page.waitForTimeout(2000); + if (!clickedOnPass) { + emptyPasses++; + if (emptyPasses >= 2) { + break; } - } catch (error) { - // Log and ignore this error - this._logger.info('Device permissions modals might be missing...', { error }); + await this.page.waitForTimeout(250); + } else { + emptyPasses = 0; } - }; + } - await notificationCheck(); - await deviceCheck(); - this._logger.info('Finished dismissing device checks and notifications...'); + this._logger.info('Finished dismissing device checks and notifications', { closeButtonsClicked }); }; await dismissDeviceChecksAndNotifications(); // Wait for mic to be fully muted and any initial beeps to stop - this._logger.info('Waiting 5 seconds for audio to stabilize before recording...'); - await this.page.waitForTimeout(5000); + if (config.teamsAudioStabilizationMs > 0) { + this._logger.info('Waiting briefly for audio to stabilize before recording...', { ms: config.teamsAudioStabilizationMs }); + await this.page.waitForTimeout(config.teamsAudioStabilizationMs); + } // Recording the meeting page with ffmpeg this._logger.info('Begin recording with ffmpeg...'); @@ -441,33 +380,6 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Verify PulseAudio is ready before starting FFmpeg this._logger.info('Verifying PulseAudio status before starting FFmpeg...'); try { - const execAsync = promisify(exec); - - // Check if PulseAudio process is running - try { - const { stdout: psOutput } = await execAsync('ps aux | grep pulseaudio | grep -v grep'); - this._logger.info('PulseAudio process status:', psOutput.trim()); - } catch (err) { - this._logger.error('PulseAudio process not found!', err); - } - - // Check XDG_RUNTIME_DIR - this._logger.info('Environment check:', { - XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR, - USER: process.env.USER, - HOME: process.env.HOME - }); - - // Check if PulseAudio socket exists - try { - const socketPath = `${process.env.XDG_RUNTIME_DIR}/pulse/native`; - const { stdout: socketCheck } = await execAsync(`ls -la ${socketPath}`); - this._logger.info('PulseAudio socket exists:', socketCheck.trim()); - } catch (err) { - this._logger.error('PulseAudio socket not found!', err); - } - - // Try to list sources const { stdout: paStatus } = await execAsync('pactl list sources short'); this._logger.info('PulseAudio sources available:', paStatus.trim() || '(empty - no sources found)'); @@ -478,9 +390,9 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Try to restart PulseAudio try { await execAsync('pulseaudio --kill || true'); - await execAsync('sleep 1'); + await new Promise(resolve => setTimeout(resolve, 1000)); await execAsync('pulseaudio -D --exit-idle-time=-1 --log-level=info'); - await execAsync('sleep 2'); + await new Promise(resolve => setTimeout(resolve, 1000)); this._logger.info('Restarted PulseAudio'); // Recreate the null sink diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 3bd356b8..bcccdc7f 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -89,23 +89,19 @@ export class ZoomBot extends BotBase { this._logger.info(`Detected .exe download: ${route.request().url()?.split('download')[0]}`); }); - await this.page.waitForTimeout(1000); - this._logger.info('Navigating to Zoom Meeting URL...'); - await this.page.goto(url, { waitUntil: 'networkidle' }); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); // Accept cookies try { this._logger.info('Waiting for the "Accept Cookies" button...'); - await this.page.waitForTimeout(3000); - const acceptCookies = await this.page.locator('button', { hasText: 'Accept Cookies' }); - await acceptCookies.waitFor({ timeout: 5000 }); + const acceptCookies = this.page.locator('button', { hasText: 'Accept Cookies' }).first(); + await acceptCookies.waitFor({ timeout: 2500 }); this._logger.info('Clicking the "Accept Cookies" button...', await acceptCookies.count()); await acceptCookies.click({ force: true }); } catch (error) { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'accept-cookie', params.userId, this._logger, params.botId); this._logger.info('Unable to accept cookies...', error); } @@ -120,22 +116,22 @@ export class ZoomBot extends BotBase { return false; } - this._logger.info('Waiting for 5 seconds...'); - await this.page.waitForTimeout(5000); - const launchMeetingGetByRole = this.page.getByRole('button', { name: /Launch Meeting/i }).first(); - this._logger.info('Does Launch Meeting exist', await launchMeetingGetByRole.isVisible()); + this._logger.info('Does Launch Meeting exist', await launchMeetingGetByRole.isVisible({ timeout: 1000 }).catch(() => false)); const launchDownloadGetByRole = this.page.getByRole('button', { name: /Download Now/i }).first(); - this._logger.info('Does Download Now exist', await launchDownloadGetByRole.isVisible()); + const launchDownloadVisible = await launchDownloadGetByRole.isVisible({ timeout: 1000 }).catch(() => false); + this._logger.info('Does Download Now exist', launchDownloadVisible); - this._logger.info('Click on Download Now...'); - await launchDownloadGetByRole.click({ force: true }); + if (launchDownloadVisible) { + this._logger.info('Click on Download Now...'); + await launchDownloadGetByRole.click({ force: true }); + } - const joinFromBrowser = await this.page.locator('a', { hasText: 'Join from your browser' }).first(); - await joinFromBrowser.waitFor({ timeout: 5000 }); + const joinFromBrowser = this.page.locator('a', { hasText: 'Join from your browser' }).first(); + await joinFromBrowser.waitFor({ timeout: 4000 }); - if ((await joinFromBrowser.count()) > 0) { + if (await joinFromBrowser.isVisible({ timeout: 500 }).catch(() => false)) { await joinFromBrowser.click({ force: true }); return true; } @@ -158,9 +154,10 @@ export class ZoomBot extends BotBase { const wcUrl = new URL(url); wcUrl.pathname = wcUrl.pathname.replace('/j/', '/wc/join/'); this._logger.info('Navigating to Zoom Web Client URL...', { wcUrl: wcUrl.toString(), botId: params.botId, userId: params.userId }); - await this.page.goto(wcUrl.toString(), { waitUntil: 'networkidle' }); + await this.page.goto(wcUrl.toString(), { waitUntil: 'domcontentloaded' }); return true; } catch(err) { + usingDirectWebClient = false; this._logger.info('Failed to access ZOOM web client by URL', { botId: params.botId, userId: params.userId }); return false; } @@ -168,7 +165,7 @@ export class ZoomBot extends BotBase { const waitForJoinFromBrowserNav = async (): Promise => { try { - const maxAttempts = 3; + const maxAttempts = 10; let attempt = 0; const navPromise = new Promise((foundResolver) => { @@ -180,9 +177,8 @@ export class ZoomBot extends BotBase { } try { - const joinFromBrowser = await this.page.locator('a', { hasText: 'Join from your browser' }).first(); - await joinFromBrowser.waitFor({ timeout: 4000 }).catch(); - if (await joinFromBrowser.count() > 0) { + const joinFromBrowser = this.page.locator('a', { hasText: 'Join from your browser' }).first(); + if (await joinFromBrowser.isVisible({ timeout: 500 }).catch(() => false)) { this._logger.info('Waiting for zoom navigation to meeting page...', params.userId); } else { @@ -205,7 +201,7 @@ export class ZoomBot extends BotBase { } } attempt += 1; - }, 6000); + }, 1000); }); const success = await navPromise; return success; @@ -239,9 +235,6 @@ export class ZoomBot extends BotBase { this._logger.info('Heading to the web client...', { usingDirectWebClient }); - this._logger.info('Waiting for 10 seconds...'); - await this.page.waitForTimeout(10000); - let iframe: Frame | Page = this.page; const apps: ('app' | 'iframe')[] = []; const detectAppContainer = async (startWith: 'app' | 'iframe'): Promise => { @@ -253,8 +246,8 @@ export class ZoomBot extends BotBase { apps.push(startWith); if (startWith === 'app') { const input = await this.page.waitForSelector('input[type="text"]', { timeout: 30000 }); - const join = await this.page.locator('button', { hasText: /Join/i }); - join.waitFor({ timeout: 15000 }); + const join = this.page.locator('button', { hasText: /Join/i }).first(); + await join.waitFor({ timeout: 15000 }); this._logger.info('App container...', { input: input !== null, join: join !== null }); if (input && join) { iframe = this.page; @@ -290,16 +283,13 @@ export class ZoomBot extends BotBase { this._logger.info('Waiting for the input field to be visible...'); await iframe.waitForSelector('input[type="text"]', { timeout: 60000 }); - - this._logger.info('Waiting for 5 seconds...'); - await this.page.waitForTimeout(5000); + this._logger.info('Filling the input field with the name...'); await iframe.fill('input[type="text"]', name ? name : 'ScreenApp Notetaker'); - await this.page.waitForTimeout(3000); - this._logger.info('Clicking the "Join" button...'); - const joinButton = await iframe.locator('button', { hasText: 'Join' }); + const joinButton = iframe.locator('button', { hasText: 'Join' }).first(); + await joinButton.waitFor({ timeout: 15000 }); await joinButton.click(); // Wait in waiting room @@ -385,7 +375,8 @@ export class ZoomBot extends BotBase { try { const cameraNotifications: ('found' | 'dismissed')[] = []; const micNotifications: ('found' | 'dismissed')[] = []; - const stopWaiting = 30 * 1000; + const stopWaiting = 6 * 1000; + let sawNotification = false; const notifyPromise = new Promise((res) => { notifyTimeout = setTimeout(() => { @@ -394,10 +385,20 @@ export class ZoomBot extends BotBase { }, stopWaiting); notifyInternval = setInterval(async () => { try { - const cameraDiv = await iframe.locator('div', { hasText: /^Cannot detect your camera/i }).first(); - const micDiv = await iframe.locator('div', { hasText: /^Cannot detect your microphone/i }).first(); + const cameraDiv = iframe.locator('div', { hasText: /^Cannot detect your camera/i }).first(); + const micDiv = iframe.locator('div', { hasText: /^Cannot detect your microphone/i }).first(); + const cameraVisible = await cameraDiv.isVisible({ timeout: 500 }).catch(() => false); + const micVisible = await micDiv.isVisible({ timeout: 500 }).catch(() => false); - if (await cameraDiv.isVisible()) { + if (!cameraVisible && !micVisible && !sawNotification) { + clearInterval(notifyInternval); + clearTimeout(notifyTimeout); + res(false); + return; + } + + if (cameraVisible) { + sawNotification = true; if (!cameraNotifications.includes('found')) cameraNotifications.push('found'); } @@ -406,7 +407,8 @@ export class ZoomBot extends BotBase { cameraNotifications.push('dismissed'); } - if (await micDiv.isVisible()) { + if (micVisible) { + sawNotification = true; if (!micNotifications.includes('found')) micNotifications.push('found'); } @@ -428,8 +430,8 @@ export class ZoomBot extends BotBase { let counter = 0; try { for await (const close of closeButtons) { - if (await close.isVisible()) { - await close.click({ timeout: 5000 }); + if (await close.isVisible({ timeout: 500 }).catch(() => false)) { + await close.click({ timeout: 1000 }); counter += 1; } } @@ -443,7 +445,7 @@ export class ZoomBot extends BotBase { clearTimeout(notifyTimeout); res(false); } - }, 2000); + }, 1000); }); await notifyPromise.catch(() => { @@ -457,9 +459,9 @@ export class ZoomBot extends BotBase { // Dismiss annoucements OK button if present try { - const okButton = await iframe.locator('button', { hasText: 'OK' }).first(); - if (await okButton.isVisible()) { - await okButton.click({ timeout: 5000 }); + const okButton = iframe.locator('button', { hasText: 'OK' }).first(); + if (await okButton.isVisible({ timeout: 1000 }).catch(() => false)) { + await okButton.click({ timeout: 1000 }); this._logger.info('Dismissed the OK button...'); } } catch (error) { diff --git a/src/config.ts b/src/config.ts index 31b8c320..5578c786 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,6 +68,8 @@ export default { joinWaitTime: process.env.JOIN_WAIT_TIME_MINUTES ? Number(process.env.JOIN_WAIT_TIME_MINUTES) : 10, // Number of retries for transient errors (not applied to WaitingAtLobbyRetryError) retryCount: process.env.RETRY_COUNT ? Number(process.env.RETRY_COUNT) : 2, + teamsPrewarmEnabled: process.env.TEAMS_PREWARM_ENABLED === 'true', + teamsAudioStabilizationMs: process.env.TEAMS_AUDIO_STABILIZATION_MS ? Number(process.env.TEAMS_AUDIO_STABILIZATION_MS) : 1000, miscStorageBucket: process.env.GCP_MISC_BUCKET, miscStorageFolder: process.env.GCP_MISC_BUCKET_FOLDER ? process.env.GCP_MISC_BUCKET_FOLDER : 'meeting-bot', region: process.env.GCP_DEFAULT_REGION, diff --git a/src/lib/ffmpegRecorder.ts b/src/lib/ffmpegRecorder.ts index 9dfa9373..f1210275 100644 --- a/src/lib/ffmpegRecorder.ts +++ b/src/lib/ffmpegRecorder.ts @@ -157,7 +157,7 @@ export class FFmpegRecorder { } }); - // Wait a bit to ensure ffmpeg starts successfully + // Wait briefly to catch immediate startup failures without delaying recording. setTimeout(() => { if (settled) { // Already rejected due to early exit/error @@ -173,7 +173,7 @@ export class FFmpegRecorder { settled = true; reject(new Error('ffmpeg failed to start')); } - }, 2000); + }, 1000); } catch (error) { this.logger.error('Error starting ffmpeg:', error); diff --git a/start.sh b/start.sh index 74fadb88..1ef9324f 100644 --- a/start.sh +++ b/start.sh @@ -17,18 +17,30 @@ echo "Using XDG_RUNTIME_DIR: $XDG_RUNTIME_DIR" mkdir -p "$XDG_RUNTIME_DIR" chmod 700 "$XDG_RUNTIME_DIR" +wait_for_pulseaudio() { + for _ in {1..25}; do + if pactl info >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + return 1 +} + # Kill any existing PulseAudio processes pulseaudio --kill 2>/dev/null || true -sleep 1 +for _ in {1..10}; do + if ! pgrep -x "pulseaudio" >/dev/null; then + break + fi + sleep 0.1 +done # Start PulseAudio in user mode (simpler and more reliable) pulseaudio -D --exit-idle-time=-1 --log-level=info 2>&1 -# Wait for PulseAudio to fully initialize -sleep 5 - # Verify PulseAudio is running -if pgrep -x "pulseaudio" > /dev/null; then +if wait_for_pulseaudio && pgrep -x "pulseaudio" > /dev/null; then echo "✓ PulseAudio is running (PID: $(pgrep -x pulseaudio))" # Load null sink module (virtual audio output device) diff --git a/xvfb-run-wrapper b/xvfb-run-wrapper index 50d17949..32ce0a4e 100644 --- a/xvfb-run-wrapper +++ b/xvfb-run-wrapper @@ -10,6 +10,16 @@ trap 'echo "forwarding SIGTERM to child"; kill -SIGTERM "$child" 2>/dev/null' SI export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} echo "Using XDG_RUNTIME_DIR: $XDG_RUNTIME_DIR" +wait_for_pulseaudio() { + for _ in {1..25}; do + if pactl info >/dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + return 1 +} + # Start Xvfb (800px height to accommodate 80px offset for address bar) Xvfb :99 -screen 0 1280x800x24 & xvfb_pid=$! @@ -20,14 +30,18 @@ sleep 1 # Initialize PulseAudio for audio capture echo "Initializing PulseAudio..." pulseaudio --kill 2>/dev/null || true -sleep 1 +for _ in {1..10}; do + if ! pgrep -x "pulseaudio" >/dev/null; then + break + fi + sleep 0.1 +done # Start PulseAudio in user mode pulseaudio -D --exit-idle-time=-1 --log-level=info 2>&1 -sleep 5 # Verify PulseAudio is running and set up virtual devices -if pgrep -x "pulseaudio" > /dev/null; then +if wait_for_pulseaudio && pgrep -x "pulseaudio" > /dev/null; then echo "✓ PulseAudio is running (PID: $(pgrep -x pulseaudio))" # Load null sink module (virtual audio output device) From c5b6b914777854f5e555e75b66e9827bf2a90186 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 27 May 2026 12:58:00 +0200 Subject: [PATCH 05/53] Introduce lone participant exit delay, enhance inactivity detection, improve chunk upload handling, and add duration metadata. Bump version to 1.2.14. --- README.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/bots/GoogleMeetBot.ts | 127 +++++++++++++++++++++----------- src/bots/MicrosoftTeamsBot.ts | 80 ++++++++++++-------- src/config.ts | 1 + src/middleware/disk-uploader.ts | 78 +++++++++++++++++++- src/tasks/ContextBridgeTask.ts | 5 +- src/tasks/RecordingTask.ts | 99 +++++++++++++++++++------ 9 files changed, 298 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index a0880a44..1420ece7 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,7 @@ Notes: | `MAX_RECORDING_DURATION_MINUTES` | Maximum recording duration in minutes | `180` | | `MEETING_INACTIVITY_MINUTES` | Continuous inactivity duration after which the bot will end meeting recording | `1` | | `INACTIVITY_DETECTION_START_DELAY_MINUTES` | Initial grace period at the start of recording before inactivity detection begins | `1` | +| `LONE_PARTICIPANT_EXIT_DELAY_SECONDS` | Delay before stopping after the bot has seen other participants and then becomes alone | `10` | | `TEAMS_PREWARM_ENABLED` | Enable the extra Microsoft Teams warmup browser pass for environments that still show first-run dialogs | `false` | | `TEAMS_AUDIO_STABILIZATION_MS` | Delay before starting Microsoft Teams ffmpeg recording after joining | `1000` | | `PORT` | Server port | `3000` | diff --git a/package-lock.json b/package-lock.json index f98282d5..33a3ad54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.13", + "version": "1.2.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.13", + "version": "1.2.14", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 1563052f..db14b319 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.13", + "version": "1.2.14", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index ee61a1e0..14acd4f2 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -489,6 +489,7 @@ export class GoogleMeetBot extends MeetBotBase { ): Promise { const duration = config.maxRecordingDuration * 60 * 1000; const inactivityLimit = config.inactivityLimit * 60 * 1000; + const loneParticipantExitDelayMs = config.loneParticipantExitDelaySeconds * 1000; // Capture and send the browser console logs to Node.js context this.page?.on('console', async msg => { @@ -506,9 +507,12 @@ export class GoogleMeetBot extends MeetBotBase { await uploader.saveDataToTempFile(buffer); }); - await this.page.exposeFunction('screenAppMeetEnd', (slightlySecretId: string) => { + await this.page.exposeFunction('screenAppMeetEnd', (slightlySecretId: string, recordedDurationSeconds?: number) => { if (slightlySecretId !== this.slightlySecretId) return; try { + if (typeof recordedDurationSeconds === 'number') { + uploader.setRecordingDuration(recordedDurationSeconds); + } this._logger.info('Attempt to end meeting early...'); waitingPromise.resolveEarly(); } catch (error) { @@ -520,10 +524,9 @@ export class GoogleMeetBot extends MeetBotBase { // Inject the MediaRecorder code into the browser context using page.evaluate await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: - { teamId:string, userId: string, duration: number, inactivityLimit: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: + { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { let timeoutId: NodeJS.Timeout; - let inactivityParticipantDetectionTimeout: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; let isOnValidGoogleMeetPageInterval: NodeJS.Timeout; @@ -541,7 +544,7 @@ export class GoogleMeetBot extends MeetBotBase { }; async function startRecording() { - console.log('Will activate inactivity detection (participant + silence checks) after', activateInactivityDetectionAfter); + console.log('Participant detection is active immediately; silence detection activates after', activateInactivityDetectionAfter); // Check for the availability of the mediaDevices API if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { @@ -580,59 +583,81 @@ export class GoogleMeetBot extends MeetBotBase { } const mediaRecorder = new MediaRecorder(stream, { ...options }); + let chunkUploadChain: Promise = Promise.resolve(); + let isStoppingRecording = false; - mediaRecorder.ondataavailable = async (event: BlobEvent) => { + mediaRecorder.ondataavailable = (event: BlobEvent) => { if (!event.data.size) { console.warn('Received empty chunk...'); return; } - try { - const arrayBuffer = await event.data.arrayBuffer(); - sendChunkToServer(arrayBuffer); - } catch (error) { - console.error('Error uploading chunk:', error); - } + + const chunk = event.data; + chunkUploadChain = chunkUploadChain.then(async () => { + try { + const arrayBuffer = await chunk.arrayBuffer(); + await sendChunkToServer(arrayBuffer); + } catch (error) { + console.error('Error uploading chunk:', error); + } + }); }; // Start recording with 2-second intervals const chunkDuration = 2000; mediaRecorder.start(chunkDuration); + const recordingStartedAt = Date.now(); + const initialAloneGraceMs = activateInactivityDetectionAfterMinutes * 60 * 1000; let dismissModalsInterval: NodeJS.Timeout; let lastDimissError: Error | null = null; const stopTheRecording = async () => { - mediaRecorder.stop(); - stream.getTracks().forEach((track) => track.stop()); + if (isStoppingRecording) return; + isStoppingRecording = true; + const recordedDurationSeconds = Math.max(1, Math.round((Date.now() - recordingStartedAt) / 1000)); - // Cleanup recording timer - clearTimeout(timeoutId); + try { + await new Promise((resolve) => { + if (mediaRecorder.state === 'inactive') { + resolve(); + return; + } + mediaRecorder.addEventListener('stop', () => resolve(), { once: true }); + mediaRecorder.stop(); + }); + await chunkUploadChain; + } catch (error) { + console.error('Error stopping recorder or flushing final chunks:', error); + } finally { + stream.getTracks().forEach((track) => track.stop()); - // Cancel the perpetural checks - if (inactivityParticipantDetectionTimeout) { - clearTimeout(inactivityParticipantDetectionTimeout); - } - if (inactivitySilenceDetectionTimeout) { - clearTimeout(inactivitySilenceDetectionTimeout); - } + // Cleanup recording timer + clearTimeout(timeoutId); - if (loneTest) { - clearTimeout(loneTest); - } + // Cancel the perpetural checks + if (inactivitySilenceDetectionTimeout) { + clearTimeout(inactivitySilenceDetectionTimeout); + } - if (isOnValidGoogleMeetPageInterval) { - clearInterval(isOnValidGoogleMeetPageInterval); - } + if (loneTest) { + clearTimeout(loneTest); + } - if (dismissModalsInterval) { - clearInterval(dismissModalsInterval); - if (lastDimissError && lastDimissError instanceof Error) { - console.error('Error dismissing modals:', { lastDimissError, message: lastDimissError?.message }); + if (isOnValidGoogleMeetPageInterval) { + clearInterval(isOnValidGoogleMeetPageInterval); } - } - // Begin browser cleanup - (window as any).screenAppMeetEnd(slightlySecretId); + if (dismissModalsInterval) { + clearInterval(dismissModalsInterval); + if (lastDimissError && lastDimissError instanceof Error) { + console.error('Error dismissing modals:', { lastDimissError, message: lastDimissError?.message }); + } + } + + // Begin browser cleanup + (window as any).screenAppMeetEnd(slightlySecretId, recordedDurationSeconds); + } }; let loneTest: NodeJS.Timeout; @@ -640,6 +665,27 @@ export class GoogleMeetBot extends MeetBotBase { let loneTestDetectionActive = true; const maxDetectionFailures = 10; // Track up to 10 consecutive failures let lastBadgeLogTime = 0; // Track last time we logged badge count + let hasSeenOtherParticipant = false; + let aloneSince: number | null = null; + + const shouldStopForParticipantCount = (contributors: number) => { + const now = Date.now(); + if (contributors >= 2) { + hasSeenOtherParticipant = true; + aloneSince = null; + return false; + } + + if (hasSeenOtherParticipant) { + if (aloneSince === null) { + aloneSince = now; + console.log('Bot is alone after previously seeing participants; waiting before ending recording.'); + } + return now - aloneSince >= loneParticipantExitDelayMs; + } + + return now - recordingStartedAt >= initialAloneGraceMs; + }; function detectLoneParticipantResilient(): void { const re = /^[0-9]+$/; @@ -813,7 +859,7 @@ export class GoogleMeetBot extends MeetBotBase { return; } detectionFailures = 0; - if (contributors < 2) { + if (shouldStopForParticipantCount(contributors)) { console.log('Bot is alone, ending meeting.'); loneTestDetectionActive = false; stopTheRecording(); @@ -826,7 +872,7 @@ export class GoogleMeetBot extends MeetBotBase { return; } retryWithBackoff(); - }, 5000); + }, 2000); } retryWithBackoff(); @@ -924,9 +970,7 @@ export class GoogleMeetBot extends MeetBotBase { /** * Perpetual checks for inactivity detection */ - inactivityParticipantDetectionTimeout = setTimeout(() => { - detectLoneParticipantResilient(); - }, activateInactivityDetectionAfterMinutes * 60 * 1000); + detectLoneParticipantResilient(); inactivitySilenceDetectionTimeout = setTimeout(() => { detectIncrediblySilentMeeting(); @@ -1047,6 +1091,7 @@ export class GoogleMeetBot extends MeetBotBase { teamId, duration, inactivityLimit, + loneParticipantExitDelayMs, userId, slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index f761fdfd..f854a6aa 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -529,7 +529,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Inject inactivity detection script await this.page.evaluate( - ({ activateAfterMinutes, maxDuration }: { activateAfterMinutes: number, maxDuration: number }) => { + ({ activateAfterMinutes, loneParticipantExitDelayMs, maxDuration }: { activateAfterMinutes: number, loneParticipantExitDelayMs: number, maxDuration: number }) => { // Max duration timeout - safety limit (3 hours default in production) setTimeout(() => { console.log(`Max recording duration (${maxDuration / 60000} minutes) reached, ending meeting`); @@ -537,38 +537,60 @@ export class MicrosoftTeamsBot extends MeetBotBase { }, maxDuration); console.log(`Max duration timeout set to ${maxDuration / 60000} minutes (safety limit)`); - // Activate participant detection after delay - setTimeout(() => { - console.log('Activating participant count detection...'); - - // Participant count detection - const detectLoneParticipant = () => { - const interval = setInterval(() => { - try { - const regex = /\d+/; - const contributors = Array.from(document.querySelectorAll('button[aria-label=People]') ?? []) - .filter(x => regex.test(x?.textContent ?? ''))[0]?.textContent; - const match = (typeof contributors === 'undefined' || !contributors) ? null : contributors.match(regex); - - if (match && Number(match[0]) >= 2) { - return; // Still has participants - } - - console.log('Bot is alone, ending meeting'); - clearInterval(interval); - (window as any).screenAppMeetEnd(); - } catch (error) { - console.error('Participant detection error:', error); - } - }, 5000); - }; + console.log('Activating participant count detection...'); + + const recordingStartedAt = Date.now(); + const initialAloneGraceMs = activateAfterMinutes * 60 * 1000; + let hasSeenOtherParticipant = false; + let aloneSince: number | null = null; + + const shouldStopForParticipantCount = (participants: number) => { + const now = Date.now(); + if (participants >= 2) { + hasSeenOtherParticipant = true; + aloneSince = null; + return false; + } + + if (hasSeenOtherParticipant) { + if (aloneSince === null) { + aloneSince = now; + console.log('Bot is alone after previously seeing participants; waiting before ending recording.'); + } + return now - aloneSince >= loneParticipantExitDelayMs; + } - // Start participant detection - detectLoneParticipant(); - }, activateAfterMinutes * 60 * 1000); + return now - recordingStartedAt >= initialAloneGraceMs; + }; + + // Participant count detection + const interval = setInterval(() => { + try { + const regex = /\d+/; + const contributors = Array.from(document.querySelectorAll('button[aria-label=People], button[aria-label^="People"]') ?? []) + .map(x => `${x.getAttribute('aria-label') ?? ''} ${x?.textContent ?? ''}`) + .filter(text => regex.test(text))[0]; + const match = (typeof contributors === 'undefined' || !contributors) ? null : contributors.match(regex); + + if (!match) { + return; + } + + if (!shouldStopForParticipantCount(Number(match[0]))) { + return; + } + + console.log('Bot is alone, ending meeting'); + clearInterval(interval); + (window as any).screenAppMeetEnd(); + } catch (error) { + console.error('Participant detection error:', error); + } + }, 2000); }, { activateAfterMinutes: config.activateInactivityDetectionAfter, + loneParticipantExitDelayMs: config.loneParticipantExitDelaySeconds * 1000, maxDuration: duration, } ); diff --git a/src/config.ts b/src/config.ts index 5578c786..57c049c3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,6 +64,7 @@ export default { chromeExecutablePath: process.env.CHROME_PATH || '/usr/bin/google-chrome', // We use Google Chrome with Playwright for recording inactivityLimit: process.env.MEETING_INACTIVITY_MINUTES ? Number(process.env.MEETING_INACTIVITY_MINUTES) : 1, activateInactivityDetectionAfter: process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES ? Number(process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES) : 1, + loneParticipantExitDelaySeconds: process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS ? Number(process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS) : 10, serviceKey: process.env.SCREENAPP_BACKEND_SERVICE_API_KEY, joinWaitTime: process.env.JOIN_WAIT_TIME_MINUTES ? Number(process.env.JOIN_WAIT_TIME_MINUTES) : 10, // Number of retries for transient errors (not applied to WaitingAtLobbyRetryError) diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index cc0e2c83..c985d5dc 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -48,6 +48,7 @@ function isNoSuchUploadError(err: any, userId: string, logger: Logger): boolean export interface IUploader { uploadRecordingToRemoteStorage(options?: { forceUpload?: boolean }): Promise; saveDataToTempFile(data: Buffer): Promise; + setRecordingDuration(durationSeconds: number): void; } // Save to disk and upload in one session @@ -79,6 +80,7 @@ class DiskUploader implements IUploader { private lastRecordingId?: string; private lastStorageDetails?: Record; private recordingDuration?: number; + private firstChunkReceivedAt?: number; private queue: Buffer[]; private writing: boolean; @@ -310,6 +312,9 @@ class DiskUploader implements IUploader { this._logger.info('Force upload is enabled. Stopping disk writes...', this._userId, this._teamId); return false; } + if (!this.firstChunkReceivedAt) { + this.firstChunkReceivedAt = Date.now(); + } this.enqueue(data); return true; } catch(err) { @@ -318,6 +323,21 @@ class DiskUploader implements IUploader { } } + public setRecordingDuration(durationSeconds: number): void { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + this._logger.warn('Ignoring invalid recording duration from recorder', { durationSeconds }); + return; + } + + const roundedDuration = Math.round(durationSeconds); + this.recordingDuration = roundedDuration; + this._logger.info('Recording duration captured from recorder', { + duration: roundedDuration, + userId: this._userId, + teamId: this._teamId, + }); + } + private static getFolderPath(userId: string) { const folderPath = path.join(tempFolder, userId); return folderPath; @@ -497,6 +517,44 @@ class DiskUploader implements IUploader { } } + private async ensureWebmDurationMetadata(filePath: string): Promise { + if (this.fileExtension !== '.webm') return; + + const outputPath = `${filePath}.duration.webm`; + this._logger.info('Remuxing WebM recording to write duration metadata...', { + inputPath: filePath, + outputPath, + }); + + try { + await execFileAsync('ffmpeg', [ + '-y', + '-fflags', '+genpts', + '-i', filePath, + '-map', '0', + '-c', 'copy', + '-avoid_negative_ts', 'make_zero', + outputPath, + ], { maxBuffer: 10 * 1024 * 1024 }); + + const stats = await fs.promises.stat(outputPath); + if (stats.size <= 0) { + throw new Error('Remuxed WebM output is empty'); + } + + await fs.promises.rename(outputPath, filePath); + this._logger.info('Remuxed WebM recording with duration metadata.', { filePath }); + } catch (err) { + try { + await fs.promises.unlink(outputPath); + } catch {} + this._logger.warn('Unable to remux WebM recording with duration metadata; continuing with duration field only.', { + filePath, + error: err, + }); + } + } + private async getMediaDuration(filePath: string): Promise { try { const { stdout } = await execFileAsync('ffprobe', [ @@ -527,7 +585,25 @@ class DiskUploader implements IUploader { const filePath = DiskUploader.getFilePath(this._userId, this._tempFileId, this.fileExtension); await this.ensureRequestedContainer(filePath); - this.recordingDuration = await this.getMediaDuration(filePath); + + if (!this.recordingDuration && this.firstChunkReceivedAt) { + const elapsedSeconds = Math.round((Date.now() - this.firstChunkReceivedAt) / 1000); + if (elapsedSeconds > 0) { + this.recordingDuration = elapsedSeconds; + this._logger.info('Recording duration estimated from chunk receive time', { + duration: this.recordingDuration, + userId: this._userId, + teamId: this._teamId, + }); + } + } + + await this.ensureWebmDurationMetadata(filePath); + + const probedDuration = await this.getMediaDuration(filePath); + if (typeof probedDuration === 'number') { + this.recordingDuration = probedDuration; + } return true; } catch(err) { diff --git a/src/tasks/ContextBridgeTask.ts b/src/tasks/ContextBridgeTask.ts index 6e2252cf..0e31bb96 100644 --- a/src/tasks/ContextBridgeTask.ts +++ b/src/tasks/ContextBridgeTask.ts @@ -44,9 +44,12 @@ export class ContextBridgeTask extends Task { await this.uploader.saveDataToTempFile(buffer); }); - await this.page.exposeFunction('screenAppMeetEnd', (slightlySecretId: string) => { + await this.page.exposeFunction('screenAppMeetEnd', (slightlySecretId: string, recordedDurationSeconds?: number) => { if (slightlySecretId !== this.slightlySecretId) return; try { + if (typeof recordedDurationSeconds === 'number') { + this.uploader.setRecordingDuration(recordedDurationSeconds); + } this._logger.info('Early signal resolve recording'); this.waitingPromise.resolveEarly(); } catch (error) { diff --git a/src/tasks/RecordingTask.ts b/src/tasks/RecordingTask.ts index f93808f6..dca83faf 100644 --- a/src/tasks/RecordingTask.ts +++ b/src/tasks/RecordingTask.ts @@ -31,12 +31,13 @@ export class RecordingTask extends Task { protected async execute(): Promise { const { primaryMimeType, secondaryMimeType } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + const loneParticipantExitDelayMs = config.loneParticipantExitDelaySeconds * 1000; await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: - { teamId: string, duration: number, inactivityLimit: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: + { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { let timeoutId: NodeJS.Timeout; - let inactivityDetectionTimeout: NodeJS.Timeout; + let inactivitySilenceDetectionTimeout: NodeJS.Timeout; /** * @summary A simple method to reliably send chunks over exposeFunction @@ -57,7 +58,7 @@ export class RecordingTask extends Task { }; async function startRecording() { - console.log('Will activate the inactivity detection after', activateInactivityDetectionAfter); + console.log('Participant detection is active immediately; silence detection activates after', activateInactivityDetectionAfter); // Check for the availability of the mediaDevices API if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { @@ -88,43 +89,90 @@ export class RecordingTask extends Task { } const mediaRecorder = new MediaRecorder(stream, { ...options }); + let chunkUploadChain: Promise = Promise.resolve(); + let isStoppingRecording = false; - mediaRecorder.ondataavailable = async (event: BlobEvent) => { + mediaRecorder.ondataavailable = (event: BlobEvent) => { if (!event.data.size) { console.warn('Received empty chunk...'); return; } - try { - const arrayBuffer = await event.data.arrayBuffer(); - await sendChunkToServer(arrayBuffer); - } catch (error) { - console.error('Error uploading chunk:', error.message, error); - } + + const chunk = event.data; + chunkUploadChain = chunkUploadChain.then(async () => { + try { + const arrayBuffer = await chunk.arrayBuffer(); + await sendChunkToServer(arrayBuffer); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('Error uploading chunk:', message, error); + } + }); }; // Start recording with 2-second intervals const chunkDuration = 2000; mediaRecorder.start(chunkDuration); + const recordingStartedAt = Date.now(); + const initialAloneGraceMs = activateInactivityDetectionAfterMinutes * 60 * 1000; const stopTheRecording = async () => { + if (isStoppingRecording) return; + isStoppingRecording = true; console.log('-------- TRIGGER stop the recording'); - mediaRecorder.stop(); - stream.getTracks().forEach((track) => track.stop()); + const recordedDurationSeconds = Math.max(1, Math.round((Date.now() - recordingStartedAt) / 1000)); + + try { + await new Promise((resolve) => { + if (mediaRecorder.state === 'inactive') { + resolve(); + return; + } + mediaRecorder.addEventListener('stop', () => resolve(), { once: true }); + mediaRecorder.stop(); + }); + await chunkUploadChain; + } catch (error) { + console.error('Error stopping recorder or flushing final chunks:', error); + } finally { + stream.getTracks().forEach((track) => track.stop()); - // Cleanup recording timer - clearTimeout(timeoutId); + // Cleanup recording timer + clearTimeout(timeoutId); - // Cancel the perpetural checks - if (inactivityDetectionTimeout) { - clearTimeout(inactivityDetectionTimeout); - } + // Cancel the perpetural checks + if (inactivitySilenceDetectionTimeout) { + clearTimeout(inactivitySilenceDetectionTimeout); + } - // Begin browser cleanup - (window as any).screenAppMeetEnd(slightlySecretId); + // Begin browser cleanup + (window as any).screenAppMeetEnd(slightlySecretId, recordedDurationSeconds); + } }; let loneTest: NodeJS.Timeout; let monitor = true; + let hasSeenOtherParticipant = false; + let aloneSince: number | null = null; + + const shouldStopForParticipantCount = (participants: number) => { + const now = Date.now(); + if (participants > 1) { + hasSeenOtherParticipant = true; + aloneSince = null; + return false; + } + + if (hasSeenOtherParticipant) { + if (aloneSince === null) { + aloneSince = now; + console.log('Bot is alone after previously seeing participants; waiting before ending recording.'); + } + return now - aloneSince >= loneParticipantExitDelayMs; + } + + return now - recordingStartedAt >= initialAloneGraceMs; + }; // TODO Create standard detection lib const detectLoneParticipant = () => { @@ -181,7 +229,8 @@ export class RecordingTask extends Task { console.error('Zoom participants detection is probably not working on user:', { userId, teamId }); return; } - if (Number(participants[0]) > 1) { + const participantCount = Number(participants[0]); + if (!shouldStopForParticipantCount(participantCount)) { return; } @@ -246,8 +295,9 @@ export class RecordingTask extends Task { /** * Perpetual checks for inactivity detection */ - inactivityDetectionTimeout = setTimeout(() => { - detectLoneParticipant(); + detectLoneParticipant(); + + inactivitySilenceDetectionTimeout = setTimeout(() => { detectIncrediblySilentMeeting(); }, activateInactivityDetectionAfterMinutes * 60 * 1000); @@ -265,6 +315,7 @@ export class RecordingTask extends Task { teamId: this.teamId, duration: this.duration, inactivityLimit: this.inactivityLimit, + loneParticipantExitDelayMs, userId: this.userId, slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, From 569835a862a212fd300ee90483cc8723db8f3e83 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 27 May 2026 13:44:02 +0200 Subject: [PATCH 06/53] Enhance Teams bot participant detection with improved parsing, meeting state inference, and error handling. --- package-lock.json | 4 +- package.json | 2 +- src/bots/MicrosoftTeamsBot.ts | 153 +++++++++++++++- src/lib/webmDuration.ts | 298 ++++++++++++++++++++++++++++++++ src/middleware/disk-uploader.ts | 37 ++-- 5 files changed, 457 insertions(+), 37 deletions(-) create mode 100644 src/lib/webmDuration.ts diff --git a/package-lock.json b/package-lock.json index 33a3ad54..d7384792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.14", + "version": "1.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.14", + "version": "1.2.15", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index db14b319..24229891 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.14", + "version": "1.2.15", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index f854a6aa..4288aa4e 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -543,6 +543,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { const initialAloneGraceMs = activateAfterMinutes * 60 * 1000; let hasSeenOtherParticipant = false; let aloneSince: number | null = null; + let lastParticipantDetectionLogAt = 0; const shouldStopForParticipantCount = (participants: number) => { const now = Date.now(); @@ -563,24 +564,158 @@ export class MicrosoftTeamsBot extends MeetBotBase { return now - recordingStartedAt >= initialAloneGraceMs; }; - // Participant count detection + const normalizeText = (text: string) => text.replace(/\s+/g, ' ').trim(); + + const parseParticipantCount = (text: string): number | undefined => { + const normalized = normalizeText(text); + const patterns = [ + /\b(?:people|participants?|teilnehm(?:er|ende)?|personen)\D{0,30}(\d{1,3})\b/i, + /\b(\d{1,3})\D{0,30}(?:people|participants?|teilnehm(?:er|ende)?|personen)\b/i, + ]; + + for (const pattern of patterns) { + const match = normalized.match(pattern); + if (match) { + const value = Number(match[1]); + if (Number.isFinite(value)) { + return value; + } + } + } + + if (/^\D*\d{1,3}\D*$/.test(normalized) && normalized.length <= 16) { + const match = normalized.match(/\d{1,3}/); + const value = match ? Number(match[0]) : NaN; + if (Number.isFinite(value)) { + return value; + } + } + + return undefined; + }; + + const getTeamsMeetingState = (): 'active' | 'alone' | 'ended' => { + const bodyText = normalizeText(document.body.innerText || ''); + + const endedPhrases = [ + 'the meeting has ended', + 'this meeting has ended', + 'meeting has been ended', + 'call ended', + 'you have been removed', + 'you’ve been removed', + 'removed from the meeting', + 'besprechung wurde beendet', + 'anruf beendet', + 'sie wurden entfernt', + 'du wurdest entfernt', + ]; + + if (endedPhrases.some(phrase => bodyText.toLowerCase().includes(phrase))) { + return 'ended'; + } + + const alonePhrases = [ + "you're the only one here", + "you’re the only one here", + 'you are the only one here', + "you're the only one in this meeting", + "you’re the only one in this meeting", + 'you are the only one in this meeting', + 'only one in this meeting', + 'only you are here', + 'no one else is here', + 'waiting for others to join', + 'sie sind der einzige', + 'du bist der einzige', + 'sie sind die einzige person', + 'du bist die einzige person', + 'warten auf andere', + ]; + + return alonePhrases.some(phrase => bodyText.toLowerCase().includes(phrase)) ? 'alone' : 'active'; + }; + + const getParticipantCount = (): { count?: number; samples: string[] } => { + const selectors = [ + 'button[data-tid*="roster" i]', + '[data-tid*="roster" i]', + 'button[id*="roster" i]', + '[id*="roster" i]', + 'button[aria-label*="people" i]', + '[aria-label*="people" i]', + 'button[aria-label*="participant" i]', + '[aria-label*="participant" i]', + 'button[aria-label*="teilnehm" i]', + '[aria-label*="teilnehm" i]', + 'button[aria-label*="personen" i]', + '[aria-label*="personen" i]', + ]; + + const candidates = Array.from(document.querySelectorAll(selectors.join(','))); + const samples: string[] = []; + + for (const element of candidates) { + const searchRoots = [ + element, + element.parentElement, + element.parentElement?.parentElement, + ].filter(Boolean) as Element[]; + + for (const root of searchRoots) { + const text = normalizeText([ + root.getAttribute('aria-label') ?? '', + root.getAttribute('title') ?? '', + root.getAttribute('data-tid') ?? '', + root.textContent ?? '', + ].join(' ')); + + if (!text) continue; + if (samples.length < 6) { + samples.push(text.slice(0, 140)); + } + + const count = parseParticipantCount(text); + if (typeof count === 'number') { + return { count, samples }; + } + } + } + + return { samples }; + }; + const interval = setInterval(() => { try { - const regex = /\d+/; - const contributors = Array.from(document.querySelectorAll('button[aria-label=People], button[aria-label^="People"]') ?? []) - .map(x => `${x.getAttribute('aria-label') ?? ''} ${x?.textContent ?? ''}`) - .filter(text => regex.test(text))[0]; - const match = (typeof contributors === 'undefined' || !contributors) ? null : contributors.match(regex); + const meetingState = getTeamsMeetingState(); + if (meetingState === 'ended') { + console.log('Teams meeting ended page state detected, ending recording.'); + clearInterval(interval); + (window as any).screenAppMeetEnd(); + return; + } - if (!match) { + const { count, samples } = getParticipantCount(); + const inferredCount = typeof count === 'number' + ? count + : meetingState === 'alone' + ? 1 + : undefined; + + if (typeof inferredCount !== 'number') { + const now = Date.now(); + if (now - lastParticipantDetectionLogAt > 30000) { + console.log('Teams participant count not detected yet', { samples }); + lastParticipantDetectionLogAt = now; + } return; } - if (!shouldStopForParticipantCount(Number(match[0]))) { + if (!shouldStopForParticipantCount(inferredCount)) { return; } - console.log('Bot is alone, ending meeting'); + console.log('Bot is alone, ending Teams recording', { inferredCount, meetingState }); clearInterval(interval); (window as any).screenAppMeetEnd(); } catch (error) { diff --git a/src/lib/webmDuration.ts b/src/lib/webmDuration.ts new file mode 100644 index 00000000..b824bb9f --- /dev/null +++ b/src/lib/webmDuration.ts @@ -0,0 +1,298 @@ +import fs from 'fs'; +import { createReadStream, createWriteStream } from 'fs'; +import { pipeline } from 'stream/promises'; + +const SEGMENT_ID = '18538067'; +const INFO_ID = '1549a966'; +const TIMECODE_SCALE_ID = '2ad7b1'; +const DURATION_ID = '4489'; +const DEFAULT_TIMECODE_SCALE_NS = 1_000_000; +const HEADER_SCAN_BYTES = 4 * 1024 * 1024; + +type EbmlElement = { + idHex: string; + idStart: number; + idEnd: number; + sizeStart: number; + sizeEnd: number; + sizeLength: number; + size: number; + unknownSize: boolean; + dataStart: number; + dataEnd: number; +}; + +type PatchPlan = + | { type: 'in-place'; offset: number; data: Buffer } + | { type: 'rewrite'; head: Buffer; replacement: Buffer; copyStart: number }; + +function getVintLength(firstByte: number): number { + for (let length = 1; length <= 8; length++) { + if ((firstByte & (1 << (8 - length))) !== 0) { + return length; + } + } + throw new Error('Invalid EBML variable-length integer'); +} + +function readElementId(buffer: Buffer, offset: number): { idHex: string; end: number } { + if (offset >= buffer.length) { + throw new Error('Unexpected end of buffer while reading EBML element id'); + } + + const length = getVintLength(buffer[offset]); + const end = offset + length; + if (end > buffer.length) { + throw new Error('Unexpected end of buffer while reading EBML element id'); + } + + return { + idHex: buffer.subarray(offset, end).toString('hex'), + end, + }; +} + +function readVintSize(buffer: Buffer, offset: number): { value: number; length: number; unknownSize: boolean; end: number } { + if (offset >= buffer.length) { + throw new Error('Unexpected end of buffer while reading EBML element size'); + } + + const firstByte = buffer[offset]; + const length = getVintLength(firstByte); + const end = offset + length; + if (end > buffer.length) { + throw new Error('Unexpected end of buffer while reading EBML element size'); + } + + const marker = 1 << (8 - length); + let value = firstByte & (marker - 1); + for (let index = offset + 1; index < end; index++) { + value = value * 256 + buffer[index]; + } + + const unknownSize = value === Math.pow(2, 7 * length) - 1; + return { value, length, unknownSize, end }; +} + +function readElement(buffer: Buffer, offset: number, containerEnd: number): EbmlElement { + const id = readElementId(buffer, offset); + const size = readVintSize(buffer, id.end); + const dataStart = size.end; + const dataEnd = size.unknownSize ? containerEnd : dataStart + size.value; + + if (dataEnd > containerEnd) { + throw new Error(`EBML element ${id.idHex} extends past its container`); + } + + return { + idHex: id.idHex, + idStart: offset, + idEnd: id.end, + sizeStart: id.end, + sizeEnd: size.end, + sizeLength: size.length, + size: size.value, + unknownSize: size.unknownSize, + dataStart, + dataEnd, + }; +} + +function findTopLevelElement(buffer: Buffer, fileSize: number, idHex: string): EbmlElement | undefined { + let offset = 0; + while (offset < buffer.length && offset < fileSize) { + const element = readElement(buffer, offset, fileSize); + if (element.idHex === idHex) { + return element; + } + + if (element.dataEnd <= offset || element.dataEnd > buffer.length) { + break; + } + offset = element.dataEnd; + } + + return undefined; +} + +function findChildElement(buffer: Buffer, parent: EbmlElement, idHex: string): EbmlElement | undefined { + let offset = parent.dataStart; + while (offset < parent.dataEnd && offset < buffer.length) { + const element = readElement(buffer, offset, parent.dataEnd); + if (element.idHex === idHex) { + return element; + } + + if (element.dataEnd <= offset || element.dataEnd > buffer.length) { + break; + } + offset = element.dataEnd; + } + + return undefined; +} + +function readUnsignedInteger(buffer: Buffer, start: number, end: number): number { + let value = 0; + for (let index = start; index < end; index++) { + value = value * 256 + buffer[index]; + } + return value; +} + +function encodeVintSize(value: number, length: number): Buffer { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid EBML size value: ${value}`); + } + + const maxKnownValue = Math.pow(2, 7 * length) - 2; + if (value > maxKnownValue) { + throw new Error(`EBML size ${value} does not fit in ${length} bytes`); + } + + const encoded = Buffer.alloc(length); + let remaining = value; + for (let index = length - 1; index >= 0; index--) { + encoded[index] = remaining & 0xff; + remaining = Math.floor(remaining / 256); + } + + encoded[0] |= 1 << (8 - length); + return encoded; +} + +function createDurationElement(durationSeconds: number, timecodeScaleNs: number): Buffer { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + throw new Error(`Invalid WebM duration: ${durationSeconds}`); + } + if (!Number.isFinite(timecodeScaleNs) || timecodeScaleNs <= 0) { + throw new Error(`Invalid WebM TimecodeScale: ${timecodeScaleNs}`); + } + + const durationInTimecodeTicks = durationSeconds * 1_000_000_000 / timecodeScaleNs; + const element = Buffer.alloc(11); + element[0] = 0x44; + element[1] = 0x89; + element[2] = 0x88; + element.writeDoubleBE(durationInTimecodeTicks, 3); + return element; +} + +function writeFloatPayload(durationSeconds: number, timecodeScaleNs: number, size: number): Buffer { + const durationInTimecodeTicks = durationSeconds * 1_000_000_000 / timecodeScaleNs; + const payload = Buffer.alloc(size); + + if (size === 4) { + payload.writeFloatBE(durationInTimecodeTicks, 0); + return payload; + } + if (size === 8) { + payload.writeDoubleBE(durationInTimecodeTicks, 0); + return payload; + } + + throw new Error(`Unsupported existing WebM Duration payload size: ${size}`); +} + +function updateKnownElementSize(buffer: Buffer, element: EbmlElement, delta: number): void { + if (element.unknownSize) { + return; + } + + const size = encodeVintSize(element.size + delta, element.sizeLength); + size.copy(buffer, element.sizeStart); +} + +function createPatchPlan(prefix: Buffer, fileSize: number, durationSeconds: number): PatchPlan { + const segment = findTopLevelElement(prefix, fileSize, SEGMENT_ID); + if (!segment) { + throw new Error('WebM Segment element was not found'); + } + + const info = findChildElement(prefix, segment, INFO_ID); + if (!info) { + throw new Error('WebM Info element was not found'); + } + if (info.unknownSize) { + throw new Error('Cannot append Duration to a WebM Info element with unknown size'); + } + if (info.dataEnd > prefix.length) { + throw new Error('WebM Info element is outside the scanned header range'); + } + + const timecodeScaleElement = findChildElement(prefix, info, TIMECODE_SCALE_ID); + const timecodeScaleNs = timecodeScaleElement + ? readUnsignedInteger(prefix, timecodeScaleElement.dataStart, timecodeScaleElement.dataEnd) + : DEFAULT_TIMECODE_SCALE_NS; + + const durationElement = findChildElement(prefix, info, DURATION_ID); + if (durationElement && (durationElement.size === 4 || durationElement.size === 8)) { + return { + type: 'in-place', + offset: durationElement.dataStart, + data: writeFloatPayload(durationSeconds, timecodeScaleNs, durationElement.size), + }; + } + + const replacement = createDurationElement(durationSeconds, timecodeScaleNs); + const replaceStart = durationElement ? durationElement.idStart : info.dataEnd; + const copyStart = durationElement ? durationElement.dataEnd : info.dataEnd; + const delta = replacement.length - (copyStart - replaceStart); + const head = Buffer.from(prefix.subarray(0, replaceStart)); + + updateKnownElementSize(head, info, delta); + updateKnownElementSize(head, segment, delta); + + return { + type: 'rewrite', + head, + replacement, + copyStart, + }; +} + +async function rewriteWebmFile(filePath: string, outputPath: string, plan: Extract): Promise { + await fs.promises.writeFile(outputPath, Buffer.concat([plan.head, plan.replacement])); + + const readStream = createReadStream(filePath, { start: plan.copyStart }); + const writeStream = createWriteStream(outputPath, { flags: 'a' }); + await pipeline(readStream, writeStream); +} + +export async function writeWebmDurationMetadata(filePath: string, durationSeconds: number): Promise { + const stats = await fs.promises.stat(filePath); + const bytesToRead = Math.min(stats.size, HEADER_SCAN_BYTES); + const file = await fs.promises.open(filePath, 'r'); + let prefix: Buffer; + + try { + prefix = Buffer.alloc(bytesToRead); + const { bytesRead } = await file.read(prefix, 0, bytesToRead, 0); + prefix = prefix.subarray(0, bytesRead); + } finally { + await file.close(); + } + + const plan = createPatchPlan(prefix, stats.size, durationSeconds); + + if (plan.type === 'in-place') { + const writableFile = await fs.promises.open(filePath, 'r+'); + try { + await writableFile.write(plan.data, 0, plan.data.length, plan.offset); + } finally { + await writableFile.close(); + } + return; + } + + const outputPath = `${filePath}.duration-patched.webm`; + try { + await rewriteWebmFile(filePath, outputPath, plan); + await fs.promises.rename(outputPath, filePath); + } catch (err) { + try { + await fs.promises.unlink(outputPath); + } catch {} + throw err; + } +} diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index c985d5dc..eae68ae8 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -16,6 +16,7 @@ import config from '../config'; import { getStorageProvider } from '../uploader/providers/factory'; import { getTimeString } from '../lib/datetime'; import { notifyRecordingCompleted, RecordingCompletedPayload } from '../services/notificationService'; +import { writeWebmDurationMetadata } from '../lib/webmDuration'; console.log(' ----- PWD OR CWD ----- ', process.cwd()); @@ -520,35 +521,21 @@ class DiskUploader implements IUploader { private async ensureWebmDurationMetadata(filePath: string): Promise { if (this.fileExtension !== '.webm') return; - const outputPath = `${filePath}.duration.webm`; - this._logger.info('Remuxing WebM recording to write duration metadata...', { - inputPath: filePath, - outputPath, + if (!this.recordingDuration) { + this._logger.warn('Skipping WebM duration metadata patch because recording duration is unknown.', { filePath }); + return; + } + + this._logger.info('Patching WebM recording duration metadata...', { + filePath, + duration: this.recordingDuration, }); try { - await execFileAsync('ffmpeg', [ - '-y', - '-fflags', '+genpts', - '-i', filePath, - '-map', '0', - '-c', 'copy', - '-avoid_negative_ts', 'make_zero', - outputPath, - ], { maxBuffer: 10 * 1024 * 1024 }); - - const stats = await fs.promises.stat(outputPath); - if (stats.size <= 0) { - throw new Error('Remuxed WebM output is empty'); - } - - await fs.promises.rename(outputPath, filePath); - this._logger.info('Remuxed WebM recording with duration metadata.', { filePath }); + await writeWebmDurationMetadata(filePath, this.recordingDuration); + this._logger.info('Patched WebM recording duration metadata.', { filePath }); } catch (err) { - try { - await fs.promises.unlink(outputPath); - } catch {} - this._logger.warn('Unable to remux WebM recording with duration metadata; continuing with duration field only.', { + this._logger.warn('Unable to patch WebM recording duration metadata; continuing with duration field only.', { filePath, error: err, }); From cb575e3ddc70cc3dc70339721c60bb01b48ab499 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 27 May 2026 15:43:48 +0200 Subject: [PATCH 07/53] Add meeting join failure notifications to Redis failure list; improve upload failure handling and error logging across bots. Bump version to 1.2.16. --- README.md | 3 + package-lock.json | 4 +- package.json | 2 +- src/app/common.ts | 14 ++++- src/app/google.ts | 14 ++++- src/app/microsoft.ts | 14 ++++- src/app/zoom.ts | 14 ++++- src/bots/GoogleMeetBot.ts | 5 +- src/bots/MicrosoftTeamsBot.ts | 10 ++-- src/bots/ZoomBot.ts | 12 +++- src/config.ts | 1 + src/connect/RedisConsumerService.ts | 4 +- src/error.ts | 6 ++ src/lib/JobStore.ts | 14 ++++- src/services/notificationService.ts | 87 +++++++++++++++++++++++++++-- src/util/logger.ts | 3 + 16 files changed, 176 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1420ece7..398c3af7 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Both are disabled by default. - NOTIFY_REDIS_URI: Optional Redis URI for notifications; if not set, falls back to REDIS_HOST/REDIS_PORT/etc via redisUri - NOTIFY_REDIS_DB: Redis database number to use for notifications (default: 1). Note: By default, DB 1 is used, not DB 0 - NOTIFY_REDIS_LIST: Redis list key to RPUSH to (default: jobs:meetbot:recordings) +- NOTIFY_REDIS_FAILURE_LIST: Redis list key to RPUSH failed meeting jobs to (default: jobs:meetbot:failures) Existing REDIS_* connection envs are used to derive a default redisUri when NOTIFY_REDIS_URI is not specified. @@ -180,6 +181,8 @@ Notes: #### Behavior - Notifications are triggered only after the recording upload/processing has successfully completed. +- Failed meeting jobs are pushed to NOTIFY_REDIS_FAILURE_LIST after all join/recording retries are exhausted, or after a non-retryable upload failure. +- Failure notifications are Redis-only and use the same NOTIFY_REDIS_ENABLED, NOTIFY_REDIS_URI, and NOTIFY_REDIS_DB settings. - If both channels are enabled, both will receive the payload. - Failures to notify are logged but do not interrupt the main recording flow. diff --git a/package-lock.json b/package-lock.json index d7384792..92f601f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.15", + "version": "1.2.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.15", + "version": "1.2.16", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 24229891..8e524df5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.15", + "version": "1.2.16", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/app/common.ts b/src/app/common.ts index 27a8ea62..b0f8b073 100644 --- a/src/app/common.ts +++ b/src/app/common.ts @@ -2,6 +2,7 @@ import { Logger } from 'winston'; import { KnownError, WaitingAtLobbyRetryError } from '../error'; import { getErrorType } from '../util/logger'; import config from '../config'; +import { createMeetingFailedPayload, notifyMeetingFailed } from '../services/notificationService'; export interface MeetingJoinParams { url: string; @@ -15,9 +16,11 @@ export interface MeetingJoinParams { } export interface MeetingJoinRedisParams extends MeetingJoinParams { - provider: 'google' | 'microsoft' | 'zoom'; + provider: MeetingProvider; } +export type MeetingProvider = 'google' | 'microsoft' | 'zoom'; + const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); @@ -101,3 +104,12 @@ export const processMeetingJoin = async ( logger.error(`LogBasedMetric Bot has permanently failed. [errorType: ${errorType}]`); } }; + +export const notifyMeetingJoinFailure = async ( + params: MeetingJoinParams & { provider: MeetingProvider }, + error: unknown, + logger: Logger +) => { + const payload = createMeetingFailedPayload(params, error); + await notifyMeetingFailed(payload, logger); +}; diff --git a/src/app/google.ts b/src/app/google.ts index 66abaf1d..29f89dde 100644 --- a/src/app/google.ts +++ b/src/app/google.ts @@ -6,7 +6,7 @@ import { createCorrelationId, loggerFactory } from '../util/logger'; import DiskUploader from '../middleware/disk-uploader'; import { getRecordingNamePrefix } from '../util/recordingName'; import { encodeFileNameSafebase64 } from '../util/strings'; -import { MeetingJoinParams } from './common'; +import { MeetingJoinParams, notifyMeetingJoinFailure } from './common'; import { globalJobStore } from '../lib/globalJobStore'; const router = express.Router(); @@ -66,7 +66,17 @@ const joinGoogleMeet = async (req: Request, res: Response) => { // Create and join the meeting const bot = new GoogleMeetBot(logger, correlationId); await bot.join({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }); - }, logger); + }, logger, 0, (error) => notifyMeetingJoinFailure({ + bearerToken, + url, + name, + teamId, + timezone, + userId, + eventId, + botId, + provider: 'google', + }, error, logger)); if (!jobResult.accepted) { return res.status(409).json({ diff --git a/src/app/microsoft.ts b/src/app/microsoft.ts index 4b7651a1..08eb49fd 100644 --- a/src/app/microsoft.ts +++ b/src/app/microsoft.ts @@ -6,7 +6,7 @@ import { createCorrelationId, loggerFactory } from '../util/logger'; import DiskUploader from '../middleware/disk-uploader'; import { getRecordingNamePrefix } from '../util/recordingName'; import { encodeFileNameSafebase64 } from '../util/strings'; -import { MeetingJoinParams } from './common'; +import { MeetingJoinParams, notifyMeetingJoinFailure } from './common'; import { globalJobStore } from '../lib/globalJobStore'; const router = express.Router(); @@ -66,7 +66,17 @@ const joinMicrosoftTeams = async (req: Request, res: Response) => { // Create and join the meeting const bot = new MicrosoftTeamsBot(logger, correlationId); await bot.join({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }); - }, logger); + }, logger, 0, (error) => notifyMeetingJoinFailure({ + bearerToken, + url, + name, + teamId, + timezone, + userId, + eventId, + botId, + provider: 'microsoft', + }, error, logger)); if (!jobResult.accepted) { return res.status(409).json({ diff --git a/src/app/zoom.ts b/src/app/zoom.ts index 3aa50753..20753ee9 100644 --- a/src/app/zoom.ts +++ b/src/app/zoom.ts @@ -6,7 +6,7 @@ import { createCorrelationId, loggerFactory } from '../util/logger'; import DiskUploader from '../middleware/disk-uploader'; import { getRecordingNamePrefix } from '../util/recordingName'; import { encodeFileNameSafebase64 } from '../util/strings'; -import { MeetingJoinParams } from './common'; +import { MeetingJoinParams, notifyMeetingJoinFailure } from './common'; import { globalJobStore } from '../lib/globalJobStore'; const router = express.Router(); @@ -66,7 +66,17 @@ const joinZoom = async (req: Request, res: Response) => { // Create and join the meeting const bot = new ZoomBot(logger, correlationId); await bot.join({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }); - }, logger); + }, logger, 0, (error) => notifyMeetingJoinFailure({ + bearerToken, + url, + name, + teamId, + timezone, + userId, + eventId, + botId, + provider: 'zoom', + }, error, logger)); if (!jobResult.accepted) { return res.status(409).json({ diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 14acd4f2..73542da1 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -1,7 +1,7 @@ import { JoinParams } from './AbstractMeetBot'; import { BotStatus, WaitPromise } from '../types'; import config from '../config'; -import { UnsupportedMeetingError, WaitingAtLobbyRetryError } from '../error'; +import { RecordingUploadFailedError, UnsupportedMeetingError, WaitingAtLobbyRetryError } from '../error'; import { patchBotStatus } from '../services/botService'; import { handleUnsupportedMeetingError, handleWaitingAtLobbyError, MeetBotBase } from './MeetBotBase'; import { v4 } from 'uuid'; @@ -44,11 +44,12 @@ export class GoogleMeetBot extends MeetBotBase { if (_state.includes('finished') && !uploadResult) { _state.splice(_state.indexOf('finished'), 1, 'failed'); + throw new RecordingUploadFailedError('Google Meet recording completed but upload failed'); } await patchBotStatus({ botId, eventId, provider: 'google', status: _state, token: bearerToken }, this._logger); } catch(error) { - if (!_state.includes('finished')) + if (!_state.includes('finished') && !_state.includes('failed')) _state.push('failed'); await patchBotStatus({ botId, eventId, provider: 'google', status: _state, token: bearerToken }, this._logger); diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index 4288aa4e..e82f5f9a 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -2,7 +2,7 @@ import { Page } from 'playwright'; import { JoinParams } from './AbstractMeetBot'; import { BotStatus } from '../types'; import config from '../config'; -import { WaitingAtLobbyRetryError } from '../error'; +import { RecordingUploadFailedError, WaitingAtLobbyRetryError } from '../error'; import { handleWaitingAtLobbyError, MeetBotBase } from './MeetBotBase'; import { v4 } from 'uuid'; import { patchBotStatus } from '../services/botService'; @@ -49,10 +49,8 @@ export class MicrosoftTeamsBot extends MeetBotBase { if (_state.includes('finished') && !uploadResult) { _state.splice(_state.indexOf('finished'), 1, 'failed'); - this._logger.error('Recording completed but upload failed; not throwing so JobStore does not rejoin the (now-ended) meeting', { botId, userId, teamId }); - // Intentionally do NOT throw here — JobStore.executeTaskWithRetry would re-run - // the entire join+record+upload task, producing a second (garbage) recording on - // an already-ended meeting. The single upload failure is preferable to that. + this._logger.error('Recording completed but upload failed; raising non-retryable failure so JobStore does not rejoin the ended meeting', { botId, userId, teamId }); + throw new RecordingUploadFailedError('Microsoft Teams recording completed but upload failed'); } else if (uploadResult) { this._logger.info('Recording and upload completed successfully', { botId, userId, teamId }); } @@ -69,7 +67,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { currentState: _state }); - if (!_state.includes('finished')) + if (!_state.includes('finished') && !_state.includes('failed')) _state.push('failed'); // Try to update bot status (may fail if API is unreachable, but that's OK) diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index bcccdc7f..11b2b28a 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -2,7 +2,7 @@ import { Frame, Page } from 'playwright'; import { JoinParams, AbstractMeetBot } from './AbstractMeetBot'; import { BotStatus, WaitPromise } from '../types'; import config from '../config'; -import { WaitingAtLobbyRetryError } from '../error'; +import { RecordingUploadFailedError, WaitingAtLobbyRetryError } from '../error'; import { v4 } from 'uuid'; import { patchBotStatus } from '../services/botService'; import { RecordingTask } from '../tasks/RecordingTask'; @@ -44,6 +44,7 @@ export class ZoomBot extends BotBase { this._logger.info('Begin recording upload to server', { userId, teamId }); const uploadResult = await uploader.uploadRecordingToRemoteStorage(); this._logger.info('Recording upload result', { uploadResult, userId, teamId }); + return uploadResult; }; try { @@ -52,9 +53,14 @@ export class ZoomBot extends BotBase { await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); // Finish the upload from the temp video - await handleUpload(); + const uploadResult = await handleUpload(); + + if (_state.includes('finished') && !uploadResult) { + _state.splice(_state.indexOf('finished'), 1, 'failed'); + throw new RecordingUploadFailedError('Zoom recording completed but upload failed'); + } } catch(error) { - if (!_state.includes('finished')) + if (!_state.includes('finished') && !_state.includes('failed')) _state.push('failed'); await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); diff --git a/src/config.ts b/src/config.ts index 57c049c3..ec39c3a4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -89,6 +89,7 @@ export default { notifyRedisUri: process.env.NOTIFY_REDIS_URI, // optional override notifyRedisDb: process.env.NOTIFY_REDIS_DB ? Number(process.env.NOTIFY_REDIS_DB) : 1, // must not default to 0 notifyRedisList: process.env.NOTIFY_REDIS_LIST ?? 'jobs:meetbot:recordings', + notifyRedisFailureList: process.env.NOTIFY_REDIS_FAILURE_LIST ?? 'jobs:meetbot:failures', uploaderFileExtension: normalizeFileExtension(process.env.UPLOADER_FILE_EXTENSION), isRedisEnabled: process.env.REDIS_CONSUMER_ENABLED === 'true', s3CompatibleStorage: { diff --git a/src/connect/RedisConsumerService.ts b/src/connect/RedisConsumerService.ts index 58e282ba..ee322b36 100644 --- a/src/connect/RedisConsumerService.ts +++ b/src/connect/RedisConsumerService.ts @@ -1,5 +1,5 @@ import { globalJobStore } from '../lib/globalJobStore'; -import { MeetingJoinRedisParams } from '../app/common'; +import { MeetingJoinRedisParams, notifyMeetingJoinFailure } from '../app/common'; import messageBroker from './messageBroker'; import { loggerFactory, createCorrelationId } from '../util/logger'; import { GoogleMeetBot } from '../bots/GoogleMeetBot'; @@ -179,7 +179,7 @@ export class RedisConsumerService { throw new Error(`Unsupported provider: ${meetingParams.provider}`); } - }, logger); + }, logger, 0, (error) => notifyMeetingJoinFailure(meetingParams, error, logger)); if (jobAcceptedResult.accepted) { logger.info('Message successfully added to JobStore'); diff --git a/src/error.ts b/src/error.ts index e2a2e1f4..5eec3e8c 100644 --- a/src/error.ts +++ b/src/error.ts @@ -33,6 +33,12 @@ export class MeetingTimeoutError extends Error { } } +export class RecordingUploadFailedError extends KnownError { + constructor(message: string) { + super(message, false, 0); + } +} + export class UnsupportedMeetingError extends KnownError { public googleMeetPageStatus: 'SIGN_IN_PAGE' | 'GOOGLE_MEET_PAGE' | 'UNSUPPORTED_PAGE' | null; diff --git a/src/lib/JobStore.ts b/src/lib/JobStore.ts index 23bece06..cc405991 100644 --- a/src/lib/JobStore.ts +++ b/src/lib/JobStore.ts @@ -12,7 +12,8 @@ export class JobStore { async addJob( task: () => Promise, logger: Logger, - retryCount: number = 0 + retryCount: number = 0, + onPermanentFailure?: (error: unknown) => Promise | void ): Promise<{ accepted: boolean }> { if (this.isRunning || this.shutdownRequested) { return { accepted: false }; @@ -23,7 +24,7 @@ export class JobStore { // Execute the task asynchronously without waiting for completion this.executeTaskWithRetry(task, logger, retryCount).then(() => { logger.info('LogBasedMetric Bot has finished recording meeting successfully.'); - }).catch((error) => { + }).catch(async (error) => { const errorType = getErrorType(error); if (error instanceof KnownError) { logger.error('KnownError JobStore is permanently exiting:', { error }); @@ -31,6 +32,13 @@ export class JobStore { logger.error('Error executing task after multiple retries:', { error }); } logger.error(`LogBasedMetric Bot has permanently failed. [errorType: ${errorType}]`); + if (onPermanentFailure) { + try { + await onPermanentFailure(error); + } catch (failureNotificationError) { + logger.warn('Meeting failure notification failed', failureNotificationError as any); + } + } }).finally(() => { this.isRunning = false; }); @@ -111,4 +119,4 @@ export class JobStore { checkCompletion(); }); } -} \ No newline at end of file +} diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index c24b9958..debcede4 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -3,6 +3,8 @@ import crypto from 'crypto'; import { Logger } from 'winston'; import config from '../config'; import { createClient, RedisClientType } from 'redis'; +import { KnownError } from '../error'; +import { getErrorType } from '../util/logger'; export interface RecordingCompletedPayload { recordingId: string; @@ -13,6 +15,42 @@ export interface RecordingCompletedPayload { metadata?: Record; } +export interface MeetingFailedPayload { + recordingId: string; + meetingLink?: string; + status: 'failed'; + timestamp: string; + error: { + type: string; + message: string; + name?: string; + retryable?: boolean; + maxRetries?: number; + }; + metadata: { + userId: string; + teamId: string; + botId?: string; + eventId?: string; + provider?: string; + meetingName?: string; + timezone?: string; + }; +} + +export interface MeetingFailureContext { + url: string; + name?: string; + teamId: string; + timezone?: string; + userId: string; + botId?: string; + eventId?: string; + provider?: string; +} + +type RedisNotificationPayload = RecordingCompletedPayload | MeetingFailedPayload; + function signPayload(body: string, secret?: string): string | undefined { if (!secret) return undefined; return crypto.createHmac('sha256', secret).update(body).digest('hex'); @@ -42,12 +80,16 @@ async function sendWebhook(payload: RecordingCompletedPayload, logger: Logger) { } } -async function rpushToRedisList(payload: RecordingCompletedPayload, logger: Logger) { +async function rpushToRedisList( + payload: RedisNotificationPayload, + logger: Logger, + list: string, + logLabel: string +) { if (!config.notifyRedisEnabled) return; const uri = config.notifyRedisUri || config.redisUri; let db = config.notifyRedisDb; - const list = config.notifyRedisList; if (!uri) { logger.warn('Redis notification enabled but no URI available. Skipping.'); @@ -70,9 +112,9 @@ async function rpushToRedisList(payload: RecordingCompletedPayload, logger: Logg await client.connect(); const body = JSON.stringify(payload); await client.rPush(list, body); - logger.info(`Recording completed payload pushed to Redis list ${list} on DB ${db}.`); + logger.info(`${logLabel} payload pushed to Redis list ${list} on DB ${db}.`); } catch (err) { - logger.error('Failed to push recording notification to Redis', err as any); + logger.error(`Failed to push ${logLabel.toLowerCase()} notification to Redis`, err as any); } finally { try { if (client) await client.quit(); @@ -84,6 +126,41 @@ export async function notifyRecordingCompleted(payload: RecordingCompletedPayloa // both notification channels are optional; do both if enabled await Promise.allSettled([ sendWebhook(payload, logger), - rpushToRedisList(payload, logger), + rpushToRedisList(payload, logger, config.notifyRedisList, 'Recording completed'), ]); } + +export function createMeetingFailedPayload(context: MeetingFailureContext, error: unknown): MeetingFailedPayload { + const entityId = context.botId ?? context.eventId ?? context.userId; + const errorType = getErrorType(error); + const message = error instanceof Error ? error.message : String(error ?? 'Unknown error'); + + return { + recordingId: entityId, + meetingLink: context.url, + status: 'failed', + timestamp: new Date().toISOString(), + error: { + type: errorType, + message, + ...(error instanceof Error ? { name: error.name } : {}), + ...(error instanceof KnownError ? { + retryable: error.retryable, + maxRetries: error.maxRetries, + } : {}), + }, + metadata: { + userId: context.userId, + teamId: context.teamId, + botId: context.botId, + eventId: context.eventId, + provider: context.provider, + meetingName: context.name, + timezone: context.timezone, + }, + }; +} + +export async function notifyMeetingFailed(payload: MeetingFailedPayload, logger: Logger) { + await rpushToRedisList(payload, logger, config.notifyRedisFailureList, 'Meeting failed'); +} diff --git a/src/util/logger.ts b/src/util/logger.ts index 95ecd1f6..4735eddb 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -108,6 +108,9 @@ export const getErrorType = (error: unknown): string => { if (error.constructor.name === 'UnsupportedMeetingError') { return 'UnsupportedMeetingError'; } + if (error.constructor.name === 'RecordingUploadFailedError') { + return 'RecordingUploadFailedError'; + } if (error.constructor.name === 'KnownError') { return 'KnownError'; } From 3ff77775921c98570657bb6e0b807beb50f3d4fd Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 27 May 2026 15:51:42 +0200 Subject: [PATCH 08/53] Introduce processing queue for Redis jobs with atomic message handling, improved error recovery, and task completion acknowledgment. Update docs and bump version to 1.2.17. --- README.md | 7 ++-- package-lock.json | 4 +-- package.json | 2 +- src/config.ts | 1 + src/connect/RedisConsumerService.ts | 34 ++++++++++++++---- src/connect/RedisMessageBroker.ts | 55 +++++++++++++++++++++++++---- src/lib/JobStore.ts | 12 +++++-- 7 files changed, 95 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 398c3af7..23c92e64 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,8 @@ r.rpush('jobs:meetbot:list', json.dumps(message)) #### Queue Processing - **FIFO Queue**: Messages are processed in First-In-First-Out order -- **BLPOP Processing**: The bot uses `BLPOP` to consume messages from the queue +- **Atomic Processing Move**: The bot uses `BLMOVE` to move messages from the pending queue into the processing queue before recording starts +- **Processing Acknowledgement**: The bot removes the original message from the processing queue with `LREM` after the recording finishes or permanently fails - **Automatic Processing**: Messages are automatically picked up and processed by the Redis consumer service - **Single Job Execution**: Only one meeting is processed at a time across the entire system @@ -324,6 +325,7 @@ The following environment variables configure Redis connectivity: | `REDIS_USERNAME` | Redis username (optional) | - | | `REDIS_PASSWORD` | Redis password (optional) | - | | `REDIS_QUEUE_NAME` | Queue name for meeting jobs | `jobs:meetbot:list` | +| `REDIS_PROCESSING_QUEUE_NAME` | Queue name for active Redis meeting jobs | `jobs:meetbot:processing` | | `REDIS_CONSUMER_ENABLED` | Enable/disable Redis consumer service | `false` | **Note**: When `REDIS_CONSUMER_ENABLED` is set to `false`, the Redis consumer service will not start, and the application will only support REST API endpoints for meeting requests. Redis message queue functionality will be disabled. @@ -470,6 +472,7 @@ Notes: | `REDIS_USERNAME` | Redis username (optional) | - | | `REDIS_PASSWORD` | Redis password (optional) | - | | `REDIS_QUEUE_NAME` | Queue name for meeting jobs | `jobs:meetbot:list` | +| `REDIS_PROCESSING_QUEUE_NAME` | Queue name for active Redis meeting jobs | `jobs:meetbot:processing` | | `REDIS_CONSUMER_ENABLED` | Enable/disable Redis consumer service | `false` | | `S3_ENDPOINT` | S3-compatible service endpoint URL | - | | `S3_ACCESS_KEY_ID` | Access key for bucket authentication | - | @@ -537,7 +540,7 @@ src/ - **JobStore**: Manages single job execution across the system - **RecordingTask**: Handles meeting recording functionality - **ContextBridgeTask**: Manages browser context and automation -- **RedisMessageBroker**: Handles Redis queue operations (RPUSH/BLPOP) +- **RedisMessageBroker**: Handles Redis queue operations (RPUSH/BLMOVE/LREM) - **RedisConsumerService**: Processes messages from Redis queue asynchronously ## ⚠️ Limitations diff --git a/package-lock.json b/package-lock.json index 92f601f9..ae82fca9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.16", + "version": "1.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.16", + "version": "1.2.17", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 8e524df5..c15c3220 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.16", + "version": "1.2.17", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index ec39c3a4..deda9c5e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,6 +77,7 @@ export default { accessKey: process.env.GCP_ACCESS_KEY_ID ?? '', accessSecret: process.env.GCP_SECRET_ACCESS_KEY ?? '', redisQueueName: process.env.REDIS_QUEUE_NAME ?? 'jobs:meetbot:list', + redisProcessingQueueName: process.env.REDIS_PROCESSING_QUEUE_NAME ?? 'jobs:meetbot:processing', redisUri: constructRedisUri(), // Notification: Webhook (disabled by default) notifyWebhookEnabled: process.env.NOTIFY_WEBHOOK_ENABLED === 'true', diff --git a/src/connect/RedisConsumerService.ts b/src/connect/RedisConsumerService.ts index ee322b36..621acf37 100644 --- a/src/connect/RedisConsumerService.ts +++ b/src/connect/RedisConsumerService.ts @@ -16,7 +16,7 @@ export class RedisConsumerService { private _isShutdownRequested: boolean = false; private reconnectInterval: NodeJS.Timeout | null = null; private readonly RECONNECT_DELAY = 5000; // 5 seconds - private readonly BLPOP_TIMEOUT = 10; // 10 seconds + private readonly QUEUE_BLOCK_TIMEOUT_SECONDS = 10; async start(): Promise { if (this._isRunning) { @@ -77,12 +77,12 @@ export class RedisConsumerService { // Check JobStore availability BEFORE attempting to get messages if (globalJobStore.isBusy()) { // JobStore is busy, wait a bit before checking again - await new Promise(resolve => setTimeout(resolve, this.BLPOP_TIMEOUT)); // BLPOP_TIMEOUT second delay + await new Promise(resolve => setTimeout(resolve, this.QUEUE_BLOCK_TIMEOUT_SECONDS * 1000)); continue; // Skip to next iteration } // JobStore is available, now we can safely get a message - const result = await messageBroker.getMeetingbotJobsWithTimeout(this.BLPOP_TIMEOUT); + const result = await messageBroker.getMeetingbotJobsWithTimeout(this.QUEUE_BLOCK_TIMEOUT_SECONDS); if (result && result.element) { await this.processMessage(result.element); @@ -104,7 +104,16 @@ export class RedisConsumerService { } private async processMessage(message: string): Promise { - const meetingParams: MeetingJoinRedisParams = JSON.parse(message); + let meetingParams: MeetingJoinRedisParams; + + try { + meetingParams = JSON.parse(message); + } catch (error) { + console.error('Invalid Redis meeting job JSON, removing from processing queue:', error); + await messageBroker.acknowledgeProcessingMeetingbotJob(message); + return; + } + // Create correlation ID and logger const correlationId = createCorrelationId({ teamId: meetingParams.teamId, @@ -114,6 +123,7 @@ export class RedisConsumerService { url: meetingParams.url }); const logger = loggerFactory(correlationId, meetingParams.provider); + let jobAccepted = false; try { logger.info('Processing Redis message:', { @@ -179,18 +189,28 @@ export class RedisConsumerService { throw new Error(`Unsupported provider: ${meetingParams.provider}`); } - }, logger, 0, (error) => notifyMeetingJoinFailure(meetingParams, error, logger)); + }, logger, 0, async (error) => { + await notifyMeetingJoinFailure(meetingParams, error, logger); + await messageBroker.acknowledgeProcessingMeetingbotJob(message); + }, async () => { + await messageBroker.acknowledgeProcessingMeetingbotJob(message); + }); + + jobAccepted = jobAcceptedResult.accepted; if (jobAcceptedResult.accepted) { logger.info('Message successfully added to JobStore'); } else { logger.warn('Message rejected by JobStore - race condition detected, returning to queue'); - // Race condition: external API request got through, put message back at head of queue - await messageBroker.returnMeetingbotJobs(message); + // Race condition: external API request got through, put message back at head of pending queue. + await messageBroker.returnProcessingMeetingbotJob(message); } } catch (error) { logger.error('Error processing Redis message:', error); + if (!jobAccepted) { + await messageBroker.returnProcessingMeetingbotJob(message); + } } } diff --git a/src/connect/RedisMessageBroker.ts b/src/connect/RedisMessageBroker.ts index 79eaddda..6da59a32 100644 --- a/src/connect/RedisMessageBroker.ts +++ b/src/connect/RedisMessageBroker.ts @@ -4,7 +4,7 @@ import config from '../config'; /** * This class must not have any instance/local state, and should only be used as a singleton. - * Uses FIFO queue (RPUSH + BLPOP) + * Uses FIFO queue (RPUSH + BLMOVE) with a processing list for active jobs. */ export class RedisMessageBroker extends EventEmitter { private meetbot: ReturnType | null = null; @@ -50,17 +50,60 @@ export class RedisMessageBroker extends EventEmitter { } /** - * Get a job from meetbot Redis queue with custom timeout. - * **Client needs to actively pull items.** + * Return a job from the processing queue to the head of the pending queue. + * + * This is used if a job was atomically moved into processing but could not be + * accepted by the local JobStore. + * + * @param {message} message - The original job message. + */ + async returnProcessingMeetingbotJob(message: string) { + return await this.meetbot?.sendCommand([ + 'EVAL', + 'local removed = redis.call("LREM", KEYS[1], 1, ARGV[1]); if removed > 0 then return redis.call("LPUSH", KEYS[2], ARGV[1]); end; return 0;', + '2', + config.redisProcessingQueueName, + config.redisQueueName, + message, + ]); + } + + /** + * Remove a finished or permanently failed job from the processing queue. + * + * @param {message} message - The original job message. + */ + async acknowledgeProcessingMeetingbotJob(message: string): Promise { + const removed = await this.meetbot?.sendCommand([ + 'LREM', + config.redisProcessingQueueName, + '1', + message, + ]); + + return Number(removed ?? 0); + } + + /** + * Move a job from the pending queue into the processing queue with a custom timeout. + * **Client needs to actively acknowledge items after completion.** * * @param timeout - Timeout in seconds for blocking operation * @return The message acquired from meetbot Redis queue */ async getMeetingbotJobsWithTimeout(timeout: number) { - return await this.meetbot?.blPop( + const message = await this.meetbot?.sendCommand([ + 'BLMOVE', config.redisQueueName, - timeout - ); + config.redisProcessingQueueName, + 'LEFT', + 'RIGHT', + String(timeout), + ]); + + return typeof message === 'string' + ? { key: config.redisQueueName, element: message } + : null; } /** diff --git a/src/lib/JobStore.ts b/src/lib/JobStore.ts index cc405991..7968bd9c 100644 --- a/src/lib/JobStore.ts +++ b/src/lib/JobStore.ts @@ -13,7 +13,8 @@ export class JobStore { task: () => Promise, logger: Logger, retryCount: number = 0, - onPermanentFailure?: (error: unknown) => Promise | void + onPermanentFailure?: (error: unknown) => Promise | void, + onCompleted?: () => Promise | void ): Promise<{ accepted: boolean }> { if (this.isRunning || this.shutdownRequested) { return { accepted: false }; @@ -22,8 +23,15 @@ export class JobStore { this.isRunning = true; // Execute the task asynchronously without waiting for completion - this.executeTaskWithRetry(task, logger, retryCount).then(() => { + this.executeTaskWithRetry(task, logger, retryCount).then(async () => { logger.info('LogBasedMetric Bot has finished recording meeting successfully.'); + if (onCompleted) { + try { + await onCompleted(); + } catch (completionCallbackError) { + logger.warn('Meeting completion callback failed', completionCallbackError as any); + } + } }).catch(async (error) => { const errorType = getErrorType(error); if (error instanceof KnownError) { From feac07d6b292dcd5925d1cb148f00df8699ccdbd Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 2 Jun 2026 19:21:16 +0200 Subject: [PATCH 09/53] Improve handling of Google Meet lobby admission failures; add redirection detection, debug logging, and error reporting enhancements. --- package-lock.json | 4 ++-- package.json | 2 +- src/bots/GoogleMeetBot.ts | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae82fca9..6c8b107f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.17", + "version": "1.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.17", + "version": "1.2.18", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index c15c3220..e6c3bd76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.17", + "version": "1.2.18", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 73542da1..87dd8897 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -205,6 +205,8 @@ export class GoogleMeetBot extends MeetBotBase { let waitTimeout: NodeJS.Timeout; let waitInterval: NodeJS.Timeout; + let redirectedFromMeetUrl: string | undefined; + let redirectedFromMeetBodyText: string | undefined; const waitAtLobbyPromise = new Promise((resolveWaiting) => { waitTimeout = setTimeout(() => { @@ -214,6 +216,29 @@ export class GoogleMeetBot extends MeetBotBase { waitInterval = setInterval(async () => { try { + const currentUrl = this.page.url(); + if (!currentUrl.includes('meet.google.com')) { + redirectedFromMeetUrl = currentUrl; + redirectedFromMeetBodyText = await this.page.evaluate(() => document.body.innerText).catch(() => ''); + this._logger.error('Google Meet Bot was redirected away from the meeting while waiting for admission...', { + currentUrl, + bodyText: redirectedFromMeetBodyText, + userId, + teamId + }); + + try { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'google-meet-redirected-away', userId, this._logger, botId); + } catch (debugImageError) { + this._logger.warn('Unable to upload Google Meet redirect debug image...', { error: debugImageError }); + } + + clearInterval(waitInterval); + clearTimeout(waitTimeout); + resolveWaiting(false); + return; + } + const detectLobbyModeHostWaitingText = async (): Promise<'WAITING_FOR_HOST_TO_ADMIT_BOT' | 'WAITING_REQUEST_TIMEOUT' | 'LOBBY_MODE_NOT_ACTIVE' | 'UNABLE_TO_DETECT_LOBBY_MODE'> => { try { const lobbyModeHostWaitingText = await this.page.getByText(GOOGLE_LOBBY_MODE_HOST_TEXT); @@ -361,14 +386,17 @@ export class GoogleMeetBot extends MeetBotBase { const waitingAtLobbySuccess = await waitAtLobbyPromise; if (!waitingAtLobbySuccess) { - const bodyText = await this.page.evaluate(() => document.body.innerText); + const bodyText = redirectedFromMeetBodyText ?? await this.page.evaluate(() => document.body.innerText); const userDenied = (bodyText || '')?.includes(GOOGLE_REQUEST_DENIED); - this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess, bodyText }); + this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess, redirectedFromMeetUrl, bodyText }); // Don't retry lobby errors - if user doesn't admit bot, retrying won't help - throw new WaitingAtLobbyRetryError('Google Meet bot could not enter the meeting...', bodyText ?? '', false, 0); + const errorMessage = redirectedFromMeetUrl ? + `Google Meet bot was redirected away from the meeting while waiting for admission: ${redirectedFromMeetUrl}` : + 'Google Meet bot could not enter the meeting...'; + throw new WaitingAtLobbyRetryError(errorMessage, bodyText ?? '', false, 0); } } catch(lobbyError) { this._logger.info('Closing the browser on error...', lobbyError); From e923917227ed209d299a32e566d6117adeca4adf Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 2 Jun 2026 19:28:32 +0200 Subject: [PATCH 10/53] Enhance Teams bot meeting state detection with explicit empty meeting text recognition and improved participant count parsing. Bump version to 1.2.19. --- package-lock.json | 4 +-- package.json | 2 +- src/bots/MicrosoftTeamsBot.ts | 54 +++++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c8b107f..e769542c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.18", + "version": "1.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.18", + "version": "1.2.19", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index e6c3bd76..78608b67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.18", + "version": "1.2.19", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index e82f5f9a..f320eb72 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -592,7 +592,17 @@ export class MicrosoftTeamsBot extends MeetBotBase { return undefined; }; - const getTeamsMeetingState = (): 'active' | 'alone' | 'ended' => { + const isExplicitEmptyMeetingText = (text: string): boolean => { + const emptyMeetingPatterns = [ + /\b0\D{0,30}(?:people|participants?|teilnehm(?:er|ende)?|personen)\D{0,80}(?:in|inside|joined|here|meeting|call|besprechung|anruf)\b/i, + /\b(?:no|zero)\D{0,30}(?:one|one else|people|participants?)\D{0,80}(?:in|inside|joined|here|meeting|call)\b/i, + /\b(?:keine|niemand)\D{0,80}(?:teilnehm(?:er|ende)?|personen|hier|besprechung|anruf)\b/i, + ]; + + return emptyMeetingPatterns.some(pattern => pattern.test(text)); + }; + + const getTeamsMeetingState = (): 'active' | 'alone' | 'empty' | 'ended' => { const bodyText = normalizeText(document.body.innerText || ''); const endedPhrases = [ @@ -613,6 +623,10 @@ export class MicrosoftTeamsBot extends MeetBotBase { return 'ended'; } + if (isExplicitEmptyMeetingText(bodyText)) { + return 'empty'; + } + const alonePhrases = [ "you're the only one here", "you’re the only one here", @@ -680,6 +694,31 @@ export class MicrosoftTeamsBot extends MeetBotBase { } } + const bodyLines = (document.body.innerText || '') + .split(/\n+/) + .map(normalizeText) + .filter(text => ( + text.length > 0 && + /(?:people|participants?|teilnehm|personen|meeting|call|besprechung|anruf)/i.test(text) + )); + + for (const text of bodyLines) { + if (samples.length < 6) { + samples.push(text.slice(0, 140)); + } + + if (isExplicitEmptyMeetingText(text)) { + return { count: 0, samples }; + } + + if (/(?:people|participants?|teilnehm|personen)/i.test(text)) { + const count = parseParticipantCount(text); + if (typeof count === 'number') { + return { count, samples }; + } + } + } + return { samples }; }; @@ -694,11 +733,14 @@ export class MicrosoftTeamsBot extends MeetBotBase { } const { count, samples } = getParticipantCount(); - const inferredCount = typeof count === 'number' - ? count - : meetingState === 'alone' - ? 1 - : undefined; + let inferredCount = count; + if (typeof inferredCount !== 'number') { + if (meetingState === 'empty') { + inferredCount = 0; + } else if (meetingState === 'alone') { + inferredCount = 1; + } + } if (typeof inferredCount !== 'number') { const now = Date.now(); From 26ca68b7be5df1e9611473e9adffeee1981890b4 Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 2 Jun 2026 19:53:51 +0200 Subject: [PATCH 11/53] Remove unused Linux X11 user agent in Chromium context and improve cleanup timing in Google Meet bot redirect flow. Bump version to 1.2.20. --- package-lock.json | 4 ++-- package.json | 2 +- src/bots/GoogleMeetBot.ts | 5 +++-- src/lib/chromium.ts | 3 --- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index e769542c..41fb1cb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.19", + "version": "1.2.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.19", + "version": "1.2.20", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 78608b67..d456128a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.19", + "version": "1.2.20", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 87dd8897..74f28c9f 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -227,14 +227,15 @@ export class GoogleMeetBot extends MeetBotBase { teamId }); + clearInterval(waitInterval); + clearTimeout(waitTimeout); + try { await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'google-meet-redirected-away', userId, this._logger, botId); } catch (debugImageError) { this._logger.warn('Unable to upload Google Meet redirect debug image...', { error: debugImageError }); } - clearInterval(waitInterval); - clearTimeout(waitTimeout); resolveWaiting(false); return; } diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index f051f2b5..487e749e 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -129,13 +129,10 @@ async function createBrowserContext(url: string, correlationId: string, botType: correlationId ); - const linuxX11UserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'; - const context = await browser.newContext({ permissions: ['camera', 'microphone'], viewport: size, ignoreHTTPSErrors: true, - userAgent: linuxX11UserAgent, // Record video only in development for debugging ...(process.env.NODE_ENV === 'development' && { recordVideo: { From 6c3afd2768e5d2cd62335d7a82eb8b9961597a45 Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 2 Jun 2026 20:00:29 +0200 Subject: [PATCH 12/53] Add retry logic for Redis notifications with exponential backoff, improve Redis DB handling, and update documentation. Bump version to 1.2.21. --- README.md | 6 ++-- package-lock.json | 4 +-- package.json | 2 +- src/config.ts | 12 +++++-- src/middleware/disk-uploader.ts | 1 + src/services/notificationService.ts | 51 +++++++++++++++++------------ 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 23c92e64..000ee3a2 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ You can configure Meeting Bot to notify external systems when a recording has fi - Webhook HTTP POST - Redis list push (RPUSH) to a configurable DB and list -Both are disabled by default. +Webhook notifications are disabled by default. Redis completion notifications are enabled automatically for Redis-worker mode (`REDIS_CONSUMER_ENABLED=true`) and can also be enabled explicitly with `NOTIFY_REDIS_ENABLED=true`. #### Environment Variables @@ -134,9 +134,9 @@ Both are disabled by default. - NOTIFY_WEBHOOK_URL: Webhook endpoint URL - NOTIFY_WEBHOOK_SECRET: Optional secret to HMAC-SHA256 sign payloads. Signature header: X-Webhook-Signature -- NOTIFY_REDIS_ENABLED: Enable Redis notifications (default: false) +- NOTIFY_REDIS_ENABLED: Enable Redis notifications. Completion notifications are also enabled automatically when REDIS_CONSUMER_ENABLED=true so Redis jobs produce result-list entries. - NOTIFY_REDIS_URI: Optional Redis URI for notifications; if not set, falls back to REDIS_HOST/REDIS_PORT/etc via redisUri -- NOTIFY_REDIS_DB: Redis database number to use for notifications (default: 1). Note: By default, DB 1 is used, not DB 0 +- NOTIFY_REDIS_DB: Optional Redis database number to use for notifications. If not set, the Redis client's default DB is used. DB 0 is allowed when explicitly configured. - NOTIFY_REDIS_LIST: Redis list key to RPUSH to (default: jobs:meetbot:recordings) - NOTIFY_REDIS_FAILURE_LIST: Redis list key to RPUSH failed meeting jobs to (default: jobs:meetbot:failures) diff --git a/package-lock.json b/package-lock.json index 41fb1cb0..c2695273 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.20", + "version": "1.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.20", + "version": "1.2.21", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index d456128a..a2c96218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.20", + "version": "1.2.21", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index deda9c5e..fd9d8e97 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,6 +50,11 @@ const normalizeFileExtension = (extension?: string) => { return extension.startsWith('.') ? extension : `.${extension}`; }; +const parseOptionalNumber = (value?: string) => { + if (typeof value === 'undefined' || value.trim() === '') return undefined; + return Number(value); +}; + export default { port: process.env.PORT || 3000, db: { @@ -84,11 +89,12 @@ export default { notifyWebhookUrl: process.env.NOTIFY_WEBHOOK_URL, // Optional secret to sign payloads (HMAC-SHA256). If set, signature will be sent in X-Webhook-Signature header notifyWebhookSecret: process.env.NOTIFY_WEBHOOK_SECRET, - // Notification: Redis (disabled by default). Uses same REDIS connection but selectable DB and list - notifyRedisEnabled: process.env.NOTIFY_REDIS_ENABLED === 'true', + // Notification: Redis. Explicitly enabled via NOTIFY_REDIS_ENABLED, and enabled + // automatically for Redis-worker mode so completed jobs are written to result list. + notifyRedisEnabled: process.env.NOTIFY_REDIS_ENABLED === 'true' || process.env.REDIS_CONSUMER_ENABLED === 'true', // If not provided, uses redisUri with specified database selection notifyRedisUri: process.env.NOTIFY_REDIS_URI, // optional override - notifyRedisDb: process.env.NOTIFY_REDIS_DB ? Number(process.env.NOTIFY_REDIS_DB) : 1, // must not default to 0 + notifyRedisDb: parseOptionalNumber(process.env.NOTIFY_REDIS_DB), notifyRedisList: process.env.NOTIFY_REDIS_LIST ?? 'jobs:meetbot:recordings', notifyRedisFailureList: process.env.NOTIFY_REDIS_FAILURE_LIST ?? 'jobs:meetbot:failures', uploaderFileExtension: normalizeFileExtension(process.env.UPLOADER_FILE_EXTENSION), diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index eae68ae8..73ba60e1 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -791,6 +791,7 @@ class DiskUploader implements IUploader { contentType: this.contentType, uploaderType: config.uploaderType, duration: this.recordingDuration, + storage: this.lastStorageDetails, }, }; await notifyRecordingCompleted(payload, this._logger); diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index debcede4..e5d6cacd 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -51,6 +51,9 @@ export interface MeetingFailureContext { type RedisNotificationPayload = RecordingCompletedPayload | MeetingFailedPayload; +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + function signPayload(body: string, secret?: string): string | undefined { if (!secret) return undefined; return crypto.createHmac('sha256', secret).update(body).digest('hex'); @@ -89,41 +92,47 @@ async function rpushToRedisList( if (!config.notifyRedisEnabled) return; const uri = config.notifyRedisUri || config.redisUri; - let db = config.notifyRedisDb; + const db = config.notifyRedisDb; if (!uri) { logger.warn('Redis notification enabled but no URI available. Skipping.'); return; } - if (typeof db !== 'number') { + if (typeof db !== 'undefined' && (!Number.isInteger(db) || db < 0)) { logger.warn('Redis notification DB is invalid. Skipping.'); return; } - // Enforce DB not 0: if 0 is set, switch to 1 and warn - if (db === 0) { - logger.warn('NOTIFY_REDIS_DB was set to 0. Switching to DB 1 as DB 0 is not allowed for notifications.'); - db = 1; - } - let client: RedisClientType | null = null; - try { - client = createClient({ url: uri, database: db, name: 'meetbot-notify' }); - client.on('error', (e) => logger.error('notify redis client error', e)); - await client.connect(); - const body = JSON.stringify(payload); - await client.rPush(list, body); - logger.info(`${logLabel} payload pushed to Redis list ${list} on DB ${db}.`); - } catch (err) { - logger.error(`Failed to push ${logLabel.toLowerCase()} notification to Redis`, err as any); - } finally { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + let client: RedisClientType | null = null; try { - if (client) await client.quit(); - } catch {} + client = createClient({ + url: uri, + name: 'meetbot-notify', + ...(typeof db === 'number' ? { database: db } : {}), + }); + client.on('error', (e) => logger.error('notify redis client error', e)); + await client.connect(); + const body = JSON.stringify(payload); + await client.rPush(list, body); + logger.info(`${logLabel} payload pushed to Redis list ${list} on DB ${typeof db === 'number' ? db : 'default'}.`); + return; + } catch (err) { + logger.error(`Failed to push ${logLabel.toLowerCase()} notification to Redis. Attempt ${attempt}/${maxAttempts}.`, err as any); + if (attempt < maxAttempts) { + await sleep(1000 * attempt); + } + } finally { + try { + if (client) await client.quit(); + } catch {} + } } } export async function notifyRecordingCompleted(payload: RecordingCompletedPayload, logger: Logger) { - // both notification channels are optional; do both if enabled + // Delivery channels are independently controlled by config; run all enabled channels. await Promise.allSettled([ sendWebhook(payload, logger), rpushToRedisList(payload, logger, config.notifyRedisList, 'Recording completed'), From 73f9801578817d6248b92d8e5871d69e247cd957 Mon Sep 17 00:00:00 2001 From: jakob Date: Fri, 5 Jun 2026 18:09:02 +0200 Subject: [PATCH 13/53] Add Google Meet CDP options, Kubernetes sidecar support, display name improvements, and anonymous join retries. Update docs and bump version to 1.2.22. --- .env.example | 10 + .github/workflows/docker-build.yml | 24 ++ .gitignore | 1 + .idea/easy-i18n.xml | 11 + Dockerfile.chrome-cdp | 59 +++++ README.md | 46 +++- package-lock.json | 4 +- package.json | 2 +- scripts/start-chrome-cdp.sh | 40 ++++ src/bots/GoogleMeetBot.ts | 370 ++++++++++++++++++++--------- src/bots/MicrosoftTeamsBot.ts | 3 + src/bots/ZoomBot.ts | 3 + src/config.ts | 6 + src/lib/chromium.ts | 162 +++++++++++-- src/util/googleMeetDisplayName.ts | 22 ++ 15 files changed, 623 insertions(+), 140 deletions(-) create mode 100644 .idea/easy-i18n.xml create mode 100644 Dockerfile.chrome-cdp create mode 100755 scripts/start-chrome-cdp.sh create mode 100644 src/util/googleMeetDisplayName.ts diff --git a/.env.example b/.env.example index 3da79d1a..e30b4622 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,16 @@ JOIN_WAIT_TIME_MINUTES=2 # RETRY_COUNT=2 means: 1 initial attempt + 2 retries = 3 total attempts RETRY_COUNT=2 +# Optional Google Meet identity. +# Prefer a dedicated, consented Google account that is invited to meetings. +# If you use CDP, launch that Chrome with --auto-accept-this-tab-capture so +# getDisplayMedia can select the current Meet tab without manual confirmation. +# GOOGLE_CHROME_CDP_URL=http://host.docker.internal:9222 +# GOOGLE_CHROME_USER_DATA_DIR=/usr/src/app/.chrome/google-meet-profile +# GOOGLE_CHROME_STORAGE_STATE_PATH=/usr/src/app/.chrome/google-meet-storage-state.json +# Re-submit anonymous guest requests if Google redirects while still waiting for host admission. +GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 + # GCP bucket configuration for uploading debugging screenshots GCP_ACCESS_KEY_ID= GCP_SECRET_ACCESS_KEY= diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 3ac5fc25..7034b962 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -8,6 +8,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + CHROME_CDP_IMAGE_NAME: jakobstadlhuber/meeting-bot-chrome-cdp jobs: build: @@ -74,6 +75,17 @@ jobs: type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} + - name: Extract Chrome CDP metadata + id: chrome-cdp-meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.CHROME_CDP_IMAGE_NAME }} + tags: | + ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && format('type=raw,value={0}', env.TAG) || '' }} + type=ref,event=branch + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + # Build and push on all branches - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -86,3 +98,15 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Build and push Chrome CDP Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.chrome-cdp + platforms: linux/amd64 + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.chrome-cdp-meta.outputs.tags }} + labels: ${{ steps.chrome-cdp-meta.outputs.labels }} + cache-from: type=gha,scope=chrome-cdp + cache-to: type=gha,mode=max,scope=chrome-cdp diff --git a/.gitignore b/.gitignore index bee531bd..42134483 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ assets/videos *.wav __pycache__/ .env +.chrome/ gcp-key assets/screenshots /data diff --git a/.idea/easy-i18n.xml b/.idea/easy-i18n.xml new file mode 100644 index 00000000..9fc64fbf --- /dev/null +++ b/.idea/easy-i18n.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile.chrome-cdp b/Dockerfile.chrome-cdp new file mode 100644 index 00000000..b24b04e6 --- /dev/null +++ b/Dockerfile.chrome-cdp @@ -0,0 +1,59 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + dumb-init \ + ffmpeg \ + fonts-liberation \ + gnupg \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libnss3 \ + libu2f-udev \ + libvulkan1 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + libxshmfence1 \ + libxss1 \ + libxtst6 \ + pulseaudio \ + wget \ + xdg-utils \ + xvfb && \ + wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-linux-signing-key.gpg && \ + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-linux-signing-key.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends google-chrome-stable && \ + rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1001 chrome && \ + useradd -m -d /home/chrome -u 1001 -g chrome chrome && \ + mkdir -p /tmp/.X11-unix /tmp/chrome-profile /run/user/1001 && \ + chown -R chrome:chrome /tmp/chrome-profile /run/user/1001 && \ + chmod 1777 /tmp/.X11-unix && \ + chmod 700 /run/user/1001 + +COPY --chown=chrome:chrome scripts/start-chrome-cdp.sh /usr/local/bin/start-chrome-cdp +RUN chmod +x /usr/local/bin/start-chrome-cdp + +USER chrome +ENV XDG_RUNTIME_DIR=/run/user/1001 +EXPOSE 9222 + +ENTRYPOINT ["dumb-init", "--"] +CMD ["start-chrome-cdp"] diff --git a/README.md b/README.md index 000ee3a2..dc82f687 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ Content-Type: application/json } ``` +For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. + +For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` at `http://127.0.0.1:9222`. The included `Dockerfile.chrome-cdp` builds a minimal Google Chrome + Xvfb CDP backend for that setup. + #### Join a Microsoft Teams Meeting ```bash POST /microsoft/join @@ -464,6 +468,10 @@ Notes: | `LONE_PARTICIPANT_EXIT_DELAY_SECONDS` | Delay before stopping after the bot has seen other participants and then becomes alone | `10` | | `TEAMS_PREWARM_ENABLED` | Enable the extra Microsoft Teams warmup browser pass for environments that still show first-run dialogs | `false` | | `TEAMS_AUDIO_STABILIZATION_MS` | Delay before starting Microsoft Teams ffmpeg recording after joining | `1000` | +| `GOOGLE_CHROME_CDP_URL` | Optional CDP endpoint for Google Meet joins, e.g. `http://host.docker.internal:9222`, to use an external Chrome instead of Docker Chrome. | - | +| `GOOGLE_CHROME_USER_DATA_DIR` | Optional persistent Chrome profile directory for Google Meet joins. Use a dedicated signed-in Google account profile. | - | +| `GOOGLE_CHROME_STORAGE_STATE_PATH` | Optional Playwright storage state JSON for Google Meet joins when not using a persistent profile. | - | +| `GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS` | Number of times to re-submit an anonymous Google Meet guest request if Meet redirects while waiting for host admission. | `10` | | `PORT` | Server port | `3000` | | `NODE_ENV` | Environment mode | `development` | | `UPLOADER_FILE_EXTENSION` | Final recording file extension (e.g., .mkv, .webm) | `.webm` | @@ -485,10 +493,46 @@ Notes: The project includes Docker support with separate configurations for development and production: -- `Dockerfile` - Development build with hot reload +- `Dockerfile.development` - Development build - `Dockerfile.production` - Optimized production build +- `Dockerfile.chrome-cdp` - Google Chrome CDP backend for Kubernetes sidecar deployments - `docker-compose.yml` - Complete development environment +#### Google Meet Chrome CDP Sidecar + +Build and publish the Chrome backend image separately from the bot image: + +```bash +docker build -f Dockerfile.chrome-cdp -t ghcr.io/your-org/meeting-bot-chrome-cdp:latest . +docker push ghcr.io/your-org/meeting-bot-chrome-cdp:latest +``` + +Then run it as a sidecar in the same Kubernetes pod as the bot: + +```yaml +containers: + - name: meeting-bot + image: ghcr.io/your-org/meeting-bot:latest + env: + - name: GOOGLE_CHROME_CDP_URL + value: http://127.0.0.1:9222 + + - name: chrome-cdp + image: ghcr.io/your-org/meeting-bot-chrome-cdp:latest + ports: + - name: cdp + containerPort: 9222 + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + cpu: "2" + memory: 3Gi +``` + +Do not expose the CDP port through a Kubernetes Service or Ingress. Keep it local to the pod so only the bot container can control the browser. + #### Using Docker Image from GitHub Packages The project automatically builds and publishes Docker images to GitHub Packages on every push to the main branch. diff --git a/package-lock.json b/package-lock.json index c2695273..313a7769 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.21", + "version": "1.2.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.21", + "version": "1.2.22", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index a2c96218..7e651079 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.21", + "version": "1.2.22", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh new file mode 100755 index 00000000..717a60ff --- /dev/null +++ b/scripts/start-chrome-cdp.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" +export CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-/tmp/chrome-profile}" +export CHROME_REMOTE_DEBUGGING_ADDRESS="${CHROME_REMOTE_DEBUGGING_ADDRESS:-127.0.0.1}" +export CHROME_REMOTE_DEBUGGING_PORT="${CHROME_REMOTE_DEBUGGING_PORT:-9222}" +export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" +export CHROME_URL="${CHROME_URL:-about:blank}" + +mkdir -p "$CHROME_USER_DATA_DIR" /tmp/.X11-unix + +if command -v pulseaudio >/dev/null 2>&1; then + pulseaudio --start --exit-idle-time=-1 || true +fi + +Xvfb "$DISPLAY" -screen 0 "${CHROME_WINDOW_SIZE}x24" -ac +extension RANDR >/tmp/xvfb.log 2>&1 & +xvfb_pid="$!" + +cleanup() { + kill "$xvfb_pid" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +exec google-chrome-stable \ + --remote-debugging-address="$CHROME_REMOTE_DEBUGGING_ADDRESS" \ + --remote-debugging-port="$CHROME_REMOTE_DEBUGGING_PORT" \ + --remote-allow-origins='*' \ + --user-data-dir="$CHROME_USER_DATA_DIR" \ + --window-size="$CHROME_WINDOW_SIZE" \ + --auto-accept-this-tab-capture \ + --autoplay-policy=no-user-gesture-required \ + --no-first-run \ + --no-default-browser-check \ + --disable-dev-shm-usage \ + --disable-background-timer-throttling \ + --disable-backgrounding-occluded-windows \ + --disable-renderer-backgrounding \ + --no-sandbox \ + "$CHROME_URL" diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 74f28c9f..619d5edf 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -11,9 +11,10 @@ import { browserLogCaptureCallback } from '../util/logger'; import { getWaitingPromise } from '../lib/promise'; import { retryActionWithWait } from '../util/resilience'; import { uploadDebugImage } from '../services/bugService'; -import createBrowserContext from '../lib/chromium'; +import createBrowserContext, { isExternalBrowserContext } from '../lib/chromium'; import { GOOGLE_LOBBY_MODE_HOST_TEXT, GOOGLE_REQUEST_DENIED, GOOGLE_REQUEST_TIMEOUT } from '../constants'; import { getRecordingMimeTypesForExtension } from '../lib/recording'; +import { getGoogleMeetDisplayName } from '../util/googleMeetDisplayName'; export class GoogleMeetBot extends MeetBotBase { private _logger: Logger; @@ -67,10 +68,17 @@ export class GoogleMeetBot extends MeetBotBase { // Guarantee chrome subprocess tree is reaped regardless of exit path. // No-op if a deeper code path already closed the browser. try { - const browser = this.page?.context().browser(); - if (browser?.isConnected()) { + const context = this.page?.context(); + const browser = context?.browser(); + if (isExternalBrowserContext(context)) { + await this.page?.close(); + this._logger.info('External browser page closed in join finally'); + } else if (browser?.isConnected()) { await browser.close(); this._logger.info('Browser closed in join finally'); + } else if (context) { + await context.close(); + this._logger.info('Persistent browser context closed in join finally'); } } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); @@ -86,18 +94,26 @@ export class GoogleMeetBot extends MeetBotBase { this._logger.info('Navigating to Google Meet URL...'); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); - const nameInputSelector = 'input[type="text"][aria-label="Your name"]'; - const waitForPreJoinReady = async () => { - const continueWithoutDevicesButton = this.page.getByRole('button', { name: 'Continue without microphone and camera' }); - const preJoinReady = await Promise.race([ - continueWithoutDevicesButton.waitFor({ timeout: 15000 }).then(() => 'device-check' as const).catch(() => null), - this.page.locator(nameInputSelector).first().waitFor({ state: 'visible', timeout: 15000 }).then(() => 'name-input' as const).catch(() => null), - ]); + const nameInputSelector = 'input[type="text"]'; + const clickContinueWithoutDevicesIfPresent = async (timeout = 5000) => { + const continueWithoutDevicesButton = this.page + .locator('button') + .filter({ hasText: /Continue without microphone and camera|Ohne Mikrofon und Kamera fortfahren/i }) + .first(); - if (preJoinReady === 'device-check') { + const hasContinuePrompt = await continueWithoutDevicesButton.isVisible({ timeout }).catch(() => false); + if (hasContinuePrompt) { this._logger.info('Clicking Continue without microphone and camera button...'); await continueWithoutDevicesButton.click(); + return true; } + + return false; + }; + + const waitForPreJoinReady = async () => { + await clickContinueWithoutDevicesIfPresent(); + await this.page.locator(nameInputSelector).first().waitFor({ state: 'visible', timeout: 15000 }); }; try { @@ -144,112 +160,194 @@ export class GoogleMeetBot extends MeetBotBase { this._logger.info('Google Meet bot is on the unsupported page...', { googleMeetPageStatus, userId, teamId }); } - this._logger.info('Waiting for the input field to be visible...'); - await retryActionWithWait( - 'Waiting for the input field', - async () => await this.page.waitForSelector(nameInputSelector, { timeout: 10000 }), - this._logger, - 3, - 15000, - async () => { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'text-input-field-wait', userId, this._logger, botId); - } - ); + const displayName = getGoogleMeetDisplayName(name); + if (displayName !== name?.trim()) { + this._logger.info('Adjusted Google Meet display name before joining...', { + originalName: name, + displayName, + userId, + teamId + }); + } + + let joinedMeeting = false; + const maxJoinRequestAttempts = Math.max(1, config.googleAnonymousJoinRequestAttempts); + try { + for (let joinRequestAttempt = 1; joinRequestAttempt <= maxJoinRequestAttempts; joinRequestAttempt++) { + if (joinRequestAttempt > 1) { + this._logger.info('Retrying anonymous Google Meet join request...', { + joinRequestAttempt, + maxJoinRequestAttempts, + userId, + teamId + }); + + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); - this._logger.info('Filling the input field with the name...'); - await this.page.fill(nameInputSelector, name ? name : 'ScreenApp Notetaker'); - - await retryActionWithWait( - 'Clicking the "Ask to join" button', - async () => { - // Using the Order of most probable detection - const possibleTexts = [ - 'Ask to join', - 'Join now', - 'Join anyway', - ]; - - let buttonClicked = false; - - for (const text of possibleTexts) { try { - const button = this.page.locator('button', { hasText: new RegExp(text, 'i') }).first(); - if (await button.isVisible({ timeout: 3000 }).catch(() => false)) { - await button.click({ timeout: 5000 }); - buttonClicked = true; - this._logger.info(`Success clicked using "${text}" action...`); - break; - } - } catch(err) { - this._logger.warn(`Unable to click using "${text}" action...`); + await waitForPreJoinReady(); + } catch (dismissError) { + this._logger.info('Continue without microphone and camera button is probably missing during retry!...'); } } - // Throws to initiate retries - if (!buttonClicked) { - throw new Error('Unable to complete the join action...'); - } - }, - this._logger, - 3, - 15000, - async () => { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'ask-to-join-button-click', userId, this._logger, botId); - } - ); - - // Do this to ensure meeting bot has joined the meeting - - try { - const wanderingTime = config.joinWaitTime * 60 * 1000; // Give some time to admit the bot + this._logger.info('Waiting for the input field to be visible...', { + joinRequestAttempt, + maxJoinRequestAttempts + }); + await retryActionWithWait( + 'Waiting for the input field', + async () => await this.page.locator(nameInputSelector).first().waitFor({ state: 'visible', timeout: 10000 }), + this._logger, + 3, + 15000, + async () => { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'text-input-field-wait', userId, this._logger, botId); + } + ); - let waitTimeout: NodeJS.Timeout; - let waitInterval: NodeJS.Timeout; - let redirectedFromMeetUrl: string | undefined; - let redirectedFromMeetBodyText: string | undefined; + this._logger.info('Filling the input field with the name...'); + await this.page.locator(nameInputSelector).first().fill(displayName); + + await retryActionWithWait( + 'Clicking the "Ask to join" button', + async () => { + // Using the Order of most probable detection + const possibleTexts = [ + 'Ask to join', + 'Join now', + 'Join anyway', + 'Teilnahme erbitten', + 'Jetzt teilnehmen', + 'Trotzdem teilnehmen', + ]; + + let buttonClicked = false; + + for (const text of possibleTexts) { + try { + const clickedByDomText = await this.page.evaluate((buttonText) => { + const buttons = Array.from(document.querySelectorAll('button')); + const button = buttons.find((element) => { + const rect = element.getBoundingClientRect(); + const visible = rect.width > 0 && rect.height > 0; + return visible && + !element.disabled && + (element.innerText || '').toLowerCase().includes(buttonText.toLowerCase()); + }); - const waitAtLobbyPromise = new Promise((resolveWaiting) => { - waitTimeout = setTimeout(() => { - clearInterval(waitInterval); - resolveWaiting(false); - }, wanderingTime); + if (!button) { + return false; + } - waitInterval = setInterval(async () => { - try { - const currentUrl = this.page.url(); - if (!currentUrl.includes('meet.google.com')) { - redirectedFromMeetUrl = currentUrl; - redirectedFromMeetBodyText = await this.page.evaluate(() => document.body.innerText).catch(() => ''); - this._logger.error('Google Meet Bot was redirected away from the meeting while waiting for admission...', { - currentUrl, - bodyText: redirectedFromMeetBodyText, - userId, - teamId - }); + button.click(); + return true; + }, text); - clearInterval(waitInterval); - clearTimeout(waitTimeout); + if (clickedByDomText) { + buttonClicked = true; + this._logger.info(`Success clicked using "${text}" action...`, { + joinRequestAttempt, + maxJoinRequestAttempts + }); + break; + } - try { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'google-meet-redirected-away', userId, this._logger, botId); - } catch (debugImageError) { - this._logger.warn('Unable to upload Google Meet redirect debug image...', { error: debugImageError }); + const button = this.page.locator('button', { hasText: new RegExp(text, 'i') }).first(); + if (await button.isVisible({ timeout: 3000 }).catch(() => false) && await button.isEnabled({ timeout: 3000 }).catch(() => false)) { + await button.click({ timeout: 5000 }); + buttonClicked = true; + this._logger.info(`Success clicked using "${text}" action...`, { + joinRequestAttempt, + maxJoinRequestAttempts + }); + break; + } + } catch(err) { + this._logger.warn(`Unable to click using "${text}" action...`); } + } - resolveWaiting(false); - return; + // Throws to initiate retries + if (!buttonClicked) { + throw new Error('Unable to complete the join action...'); } + }, + this._logger, + 3, + 15000, + async () => { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'ask-to-join-button-click', userId, this._logger, botId); + } + ); + + await clickContinueWithoutDevicesIfPresent(); + + // Do this to ensure meeting bot has joined the meeting + const wanderingTime = config.joinWaitTime * 60 * 1000; // Give some time to admit the bot + + let waitTimeout: NodeJS.Timeout; + let waitInterval: NodeJS.Timeout; + let redirectedFromMeetUrl: string | undefined; + let redirectedFromMeetBodyText: string | undefined; + let lobbyRequestTimedOut = false; + + const waitAtLobbyPromise = new Promise((resolveWaiting) => { + waitTimeout = setTimeout(() => { + clearInterval(waitInterval); + resolveWaiting(false); + }, wanderingTime); + + waitInterval = setInterval(async () => { + try { + const currentUrl = this.page.url(); + if (!currentUrl.includes('meet.google.com')) { + redirectedFromMeetUrl = currentUrl; + redirectedFromMeetBodyText = await this.page.evaluate(() => document.body.innerText).catch(() => ''); + this._logger.error('Google Meet Bot was redirected away from the meeting while waiting for admission...', { + currentUrl, + bodyText: redirectedFromMeetBodyText, + userId, + teamId, + joinRequestAttempt, + maxJoinRequestAttempts + }); + + clearInterval(waitInterval); + clearTimeout(waitTimeout); + + try { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'google-meet-redirected-away', userId, this._logger, botId); + } catch (debugImageError) { + this._logger.warn('Unable to upload Google Meet redirect debug image...', { error: debugImageError }); + } + + resolveWaiting(false); + return; + } const detectLobbyModeHostWaitingText = async (): Promise<'WAITING_FOR_HOST_TO_ADMIT_BOT' | 'WAITING_REQUEST_TIMEOUT' | 'LOBBY_MODE_NOT_ACTIVE' | 'UNABLE_TO_DETECT_LOBBY_MODE'> => { try { - const lobbyModeHostWaitingText = await this.page.getByText(GOOGLE_LOBBY_MODE_HOST_TEXT); - if (await lobbyModeHostWaitingText.count() > 0 && await lobbyModeHostWaitingText.isVisible()) { - return 'WAITING_FOR_HOST_TO_ADMIT_BOT'; + const lobbyHostWaitingTexts = [ + GOOGLE_LOBBY_MODE_HOST_TEXT, + 'Bitte warten Sie, bis Sie vom Organisator', + ]; + for (const text of lobbyHostWaitingTexts) { + const lobbyModeHostWaitingText = await this.page.getByText(text); + if (await lobbyModeHostWaitingText.count() > 0 && await lobbyModeHostWaitingText.first().isVisible()) { + return 'WAITING_FOR_HOST_TO_ADMIT_BOT'; + } } - const lobbyModeRequestTimeoutText = await this.page.getByText(GOOGLE_REQUEST_TIMEOUT); - if (await lobbyModeRequestTimeoutText.count() > 0 && await lobbyModeRequestTimeoutText.isVisible()) { - return 'WAITING_REQUEST_TIMEOUT'; + const requestTimeoutTexts = [ + GOOGLE_REQUEST_TIMEOUT, + 'Niemand hat auf Ihre Teilnahmeanfrage geantwortet', + ]; + for (const text of requestTimeoutTexts) { + const lobbyModeRequestTimeoutText = await this.page.getByText(text); + if (await lobbyModeRequestTimeoutText.count() > 0 && await lobbyModeRequestTimeoutText.first().isVisible()) { + return 'WAITING_REQUEST_TIMEOUT'; + } } return 'LOBBY_MODE_NOT_ACTIVE'; @@ -265,7 +363,7 @@ export class GoogleMeetBot extends MeetBotBase { let botWasDeniedAccess = false; try { - peopleElement = await this.page.locator('button[aria-label^="People"]').first().isVisible({ timeout: 500 }).catch(() => false); + peopleElement = await this.page.locator('button[aria-label^="People"], button[aria-label^="Personen"]').first().isVisible({ timeout: 500 }).catch(() => false); } catch(e) { this._logger.error( 'wait error', { error: e } @@ -274,7 +372,7 @@ export class GoogleMeetBot extends MeetBotBase { } try { - callButtonElement = await this.page.locator('button[aria-label="Leave call"]').first().isVisible({ timeout: 500 }).catch(() => false); + callButtonElement = await this.page.locator('button[aria-label="Leave call"], button[aria-label="Anruf verlassen"]').first().isVisible({ timeout: 500 }).catch(() => false); } catch(e) { this._logger.error( 'wait error', { error: e } @@ -289,6 +387,7 @@ export class GoogleMeetBot extends MeetBotBase { this._logger.info('Lobbdy Mode: Google Meet Bot is waiting for the host to admit it...', { userId, teamId }); } else if (lobbyModeHostWaitingText === 'WAITING_REQUEST_TIMEOUT') { this._logger.info('Lobby Mode: Google Meet Bot join request timed out...', { userId, teamId }); + lobbyRequestTimedOut = true; clearInterval(waitInterval); clearTimeout(waitTimeout); resolveWaiting(false); @@ -323,16 +422,21 @@ export class GoogleMeetBot extends MeetBotBase { const bodyText = document.body.innerText; if (bodyText.includes('You have joined the call') || bodyText.includes('other person in the call') || - bodyText.includes('people in the call')) { + bodyText.includes('people in the call') || + bodyText.includes('Du nimmst an diesem Anruf teil') || + bodyText.includes('Der Anruf hat einen weiteren Teilnehmer') || + bodyText.includes('Teilnehmer sind beigetreten')) { return true; } // Fallback: Check for Leave call button which indicates we're in a call - const leaveCallButton = document.querySelector('button[aria-label="Leave call"]'); + const leaveCallButton = document.querySelector('button[aria-label="Leave call"], button[aria-label="Anruf verlassen"]'); if (leaveCallButton) { // If we have Leave call button AND no lobby mode text, we're likely in the call const hasLobbyText = bodyText.includes('Asking to join') || - bodyText.includes('You\'re the only one here'); + bodyText.includes('You\'re the only one here') || + bodyText.includes('Teilnahme erbitten') || + bodyText.includes('Bitte warten Sie, bis Sie vom Organisator'); if (!hasLobbyText) { return true; } @@ -386,22 +490,51 @@ export class GoogleMeetBot extends MeetBotBase { }); const waitingAtLobbySuccess = await waitAtLobbyPromise; + if (waitingAtLobbySuccess) { + joinedMeeting = true; + break; + } + if (!waitingAtLobbySuccess) { const bodyText = redirectedFromMeetBodyText ?? await this.page.evaluate(() => document.body.innerText); const userDenied = (bodyText || '')?.includes(GOOGLE_REQUEST_DENIED); - this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess, redirectedFromMeetUrl, bodyText }); + this._logger.error('Cant finish wait at the lobby check', { + userDenied, + waitingAtLobbySuccess, + redirectedFromMeetUrl, + lobbyRequestTimedOut, + joinRequestAttempt, + maxJoinRequestAttempts, + bodyText + }); + + const shouldRetryJoinRequest = !userDenied && + joinRequestAttempt < maxJoinRequestAttempts && + (Boolean(redirectedFromMeetUrl) || lobbyRequestTimedOut); + + if (shouldRetryJoinRequest) { + continue; + } - // Don't retry lobby errors - if user doesn't admit bot, retrying won't help const errorMessage = redirectedFromMeetUrl ? `Google Meet bot was redirected away from the meeting while waiting for admission: ${redirectedFromMeetUrl}` : 'Google Meet bot could not enter the meeting...'; throw new WaitingAtLobbyRetryError(errorMessage, bodyText ?? '', false, 0); } + } + + if (!joinedMeeting) { + throw new WaitingAtLobbyRetryError('Google Meet bot could not enter the meeting...', '', false, 0); + } } catch(lobbyError) { this._logger.info('Closing the browser on error...', lobbyError); - await this.page.context().browser()?.close(); + if (isExternalBrowserContext(this.page.context())) { + await this.page.close(); + } else { + await this.page.context().browser()?.close(); + } throw lobbyError; } @@ -741,11 +874,11 @@ export class GoogleMeetBot extends MeetBotBase { function findPeopleButton() { try { // 1. Try to locate using attribute "starts with" - let btn: Element | null | undefined = document.querySelector('button[aria-label^="People -"]'); + let btn: Element | null | undefined = document.querySelector('button[aria-label^="People -"], button[aria-label^="Personen -"]'); if (btn) return btn; // 2. Try to locate using attribute "contains" - btn = document.querySelector('button[aria-label*="People"]'); + btn = document.querySelector('button[aria-label*="People"], button[aria-label*="Personen"]'); if (btn) return btn; // 3. Try via aria-labelledby pointing to element with "People" text @@ -754,7 +887,7 @@ export class GoogleMeetBot extends MeetBotBase { const labelledBy = b.getAttribute('aria-labelledby'); if (labelledBy) { const labelElement = document.getElementById(labelledBy); - if (labelElement && labelElement.textContent?.trim() === 'People') { + if (labelElement && ['People', 'Personen'].includes(labelElement.textContent?.trim() || '')) { return true; } } @@ -766,7 +899,7 @@ export class GoogleMeetBot extends MeetBotBase { const allBtnsWithLabel = Array.from(document.querySelectorAll('button[aria-label]')); btn = allBtnsWithLabel.find(b => { const label = b.getAttribute('aria-label'); - return label && /^People - \d+ joined$/.test(label); + return label && (/^People - \d+ joined$/.test(label) || /^Personen - \d+/.test(label)); }); if (btn) return btn; @@ -1012,9 +1145,9 @@ export class GoogleMeetBot extends MeetBotBase { dismissModalsInterval = setInterval(() => { try { const buttons = document.querySelectorAll('button'); - const dismissButtons = Array.from(buttons).filter((button) => button?.offsetParent !== null && button?.innerText?.includes('Got it')); + const dismissButtons = Array.from(buttons).filter((button) => button?.offsetParent !== null && /Got it|Ok/i.test(button?.innerText || '')); if (dismissButtons.length > 0) { - console.log('Found "Got it" button, clicking it...', dismissButtons[0]); + console.log('Found dismiss button, clicking it...', dismissButtons[0]); dismissButtons[0].click(); } @@ -1023,7 +1156,9 @@ export class GoogleMeetBot extends MeetBotBase { if (bodyText.includes('Microphone not found') || bodyText.includes('Make sure your microphone is plugged in') || bodyText.includes('Camera not found') || - bodyText.includes('Make sure your camera is plugged in')) { + bodyText.includes('Make sure your camera is plugged in') || + bodyText.includes('Mikrofonproblem') || + bodyText.includes('Kameraproblem')) { console.log('Found device notification (microphone/camera), attempting to dismiss...'); // Look for close button (X) near the notification const allButtons = Array.from(document.querySelectorAll('button')); @@ -1072,14 +1207,15 @@ export class GoogleMeetBot extends MeetBotBase { return false; } - if (currentBodyText.includes('No one responded to your request to join the call')) { + if (currentBodyText.includes('No one responded to your request to join the call') || + currentBodyText.includes('Niemand hat auf Ihre Teilnahmeanfrage geantwortet')) { console.warn('Bot was not admitted to the meeting - ending recording on team:', userId, teamId); return false; } // Check for basic Google Meet UI elements - const hasMeetElements = document.querySelector('button[aria-label="People"]') !== null || - document.querySelector('button[aria-label="Leave call"]') !== null; + const hasMeetElements = document.querySelector('button[aria-label="People"], button[aria-label^="People -"], button[aria-label="Personen"], button[aria-label^="Personen -"]') !== null || + document.querySelector('button[aria-label="Leave call"], button[aria-label="Anruf verlassen"]') !== null; if (!hasMeetElements) { console.warn('Google Meet UI elements not found - page may have changed state'); diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index f320eb72..16fece84 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -85,6 +85,9 @@ export class MicrosoftTeamsBot extends MeetBotBase { if (browser?.isConnected()) { await browser.close(); this._logger.info('Browser closed in join finally'); + } else if (this.page?.context()) { + await this.page.context().close(); + this._logger.info('Persistent browser context closed in join finally'); } } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 11b2b28a..7d44895f 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -78,6 +78,9 @@ export class ZoomBot extends BotBase { if (browser?.isConnected()) { await browser.close(); this._logger.info('Browser closed in join finally'); + } else if (this.page?.context()) { + await this.page.context().close(); + this._logger.info('Persistent browser context closed in join finally'); } } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); diff --git a/src/config.ts b/src/config.ts index fd9d8e97..556f8057 100644 --- a/src/config.ts +++ b/src/config.ts @@ -67,6 +67,12 @@ export default { Number(process.env.MAX_RECORDING_DURATION_MINUTES) : 180, // There's an upper limit on meeting duration 3 hours chromeExecutablePath: process.env.CHROME_PATH || '/usr/bin/google-chrome', // We use Google Chrome with Playwright for recording + googleChromeCdpUrl: process.env.GOOGLE_CHROME_CDP_URL, + googleChromeUserDataDir: process.env.GOOGLE_CHROME_USER_DATA_DIR, + googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, + googleAnonymousJoinRequestAttempts: process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS ? + Number(process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS) : + 10, inactivityLimit: process.env.MEETING_INACTIVITY_MINUTES ? Number(process.env.MEETING_INACTIVITY_MINUTES) : 1, activateInactivityDetectionAfter: process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES ? Number(process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES) : 1, loneParticipantExitDelaySeconds: process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS ? Number(process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS) : 10, diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index 487e749e..eda0edb0 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -11,10 +11,16 @@ chromium.use(stealthPlugin); export type BotType = 'microsoft' | 'google' | 'zoom'; -function attachBrowserErrorHandlers(browser: Browser, context: BrowserContext, page: Page, correlationId: string) { +const externalBrowserContexts = new WeakSet(); + +export function isExternalBrowserContext(context?: BrowserContext | null): boolean { + return Boolean(context && externalBrowserContexts.has(context)); +} + +function attachBrowserErrorHandlers(browser: Browser | null, context: BrowserContext, page: Page, correlationId: string) { const log = getCorrelationIdLog(correlationId); - browser.on('disconnected', () => { + browser?.on('disconnected', () => { console.log(`${log} Browser has disconnected!`); }); @@ -65,11 +71,53 @@ async function launchBrowserWithTimeout(launchFn: () => Promise, timeou }); } +async function launchPersistentContextWithTimeout(launchFn: () => Promise, timeoutMs: number, correlationId: string): Promise { + let timeoutId: NodeJS.Timeout; + let finished = false; + + return new Promise((resolve, reject) => { + timeoutId = setTimeout(() => { + if (!finished) { + finished = true; + reject(new Error(`Persistent browser launch timed out after ${timeoutMs}ms`)); + } + }, timeoutMs); + + launchFn() + .then(result => { + if (!finished) { + finished = true; + clearTimeout(timeoutId); + console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launch function success!`); + resolve(result); + } + }) + .catch(err => { + console.error(`${getCorrelationIdLog(correlationId)} Error launching persistent browser`, err); + if (!finished) { + finished = true; + clearTimeout(timeoutId); + reject(err); + } + }); + }); +} + async function createBrowserContext(url: string, correlationId: string, botType: BotType = 'google'): Promise { const size = { width: 1280, height: 720 }; - // Base browser args used by all bots - const baseBrowserArgs: string[] = [ + // Google Meet is sensitive to browser fingerprinting before admission. Keep + // its launch close to normal Chrome and reserve recording-heavy flags for + // platforms that need them. + const googleBrowserArgs: string[] = [ + '--no-sandbox', + '--disable-setuid-sandbox', + `--window-size=${size.width},${size.height}`, + '--auto-accept-this-tab-capture', + '--autoplay-policy=no-user-gesture-required', + ]; + + const recordingBrowserArgs: string[] = [ '--enable-usermedia-screen-capturing', '--allow-http-screen-capture', '--no-sandbox', @@ -96,9 +144,14 @@ async function createBrowserContext(url: string, correlationId: string, botType: // and don't need fake devices: // - Google Meet: clicks "Continue without microphone and camera" // - Zoom: expects "Cannot detect your camera/microphone" notifications - const browserArgs = botType === 'microsoft' - ? [...baseBrowserArgs, ...fakeDeviceArgs] - : baseBrowserArgs; + const browserArgs = botType === 'google' + ? googleBrowserArgs + : botType === 'microsoft' + ? [...recordingBrowserArgs, ...fakeDeviceArgs] + : recordingBrowserArgs; + const ignoreDefaultArgs = botType === 'google' + ? ['--mute-audio', '--enable-automation'] + : ['--mute-audio']; // Teams-specific display args: kiosk mode prevents address bar from showing in ffmpeg recording // Google Meet and Zoom don't need this since they use tab capture (getDisplayMedia) @@ -108,6 +161,81 @@ async function createBrowserContext(url: string, correlationId: string, botType: console.log(`${getCorrelationIdLog(correlationId)} Launching browser for ${botType} bot (fake devices: ${botType === 'microsoft'})`); + const contextOptions = { + ...(botType !== 'google' ? { + permissions: ['camera', 'microphone'], + } : {}), + viewport: size, + ignoreHTTPSErrors: true, + // Record video only in development for debugging. Keep Google Meet's + // anonymous admission context close to a regular incognito tab. + ...(process.env.NODE_ENV === 'development' && botType !== 'google' && { + recordVideo: { + dir: './debug-videos/', + size: size, + }, + }), + }; + + if (botType === 'google' && config.googleChromeCdpUrl) { + console.log(`${getCorrelationIdLog(correlationId)} Connecting Google bot to external Chrome`, { + cdpUrl: config.googleChromeCdpUrl, + }); + + const browser = await launchBrowserWithTimeout( + async () => await chromium.connectOverCDP(config.googleChromeCdpUrl!), + 60000, + correlationId + ); + + const context = browser.contexts()[0] ?? await browser.newContext({ + ...contextOptions, + ...(config.googleChromeStorageStatePath ? { + storageState: config.googleChromeStorageStatePath, + } : {}), + }); + externalBrowserContexts.add(context); + + const page = await context.newPage(); + await page.setViewportSize(size); + attachBrowserErrorHandlers(browser, context, page, correlationId); + + console.log(`${getCorrelationIdLog(correlationId)} External Chrome connected successfully!`); + + return page; + } + + if (botType === 'google' && config.googleChromeUserDataDir) { + console.log(`${getCorrelationIdLog(correlationId)} Launching Google bot with persistent Chrome profile`, { + userDataDir: config.googleChromeUserDataDir, + }); + + const context = await launchPersistentContextWithTimeout( + async () => await chromium.launchPersistentContext(config.googleChromeUserDataDir!, { + ...contextOptions, + headless: false, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + args: [ + ...browserArgs, + ...displayArgs, + ], + ignoreDefaultArgs, + executablePath: config.chromeExecutablePath, + }), + 60000, + correlationId + ); + + const page = context.pages()[0] ?? await context.newPage(); + attachBrowserErrorHandlers(context.browser(), context, page, correlationId); + + console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launched successfully!`); + + return page; + } + const browser = await launchBrowserWithTimeout( async () => await chromium.launch({ headless: false, @@ -122,7 +250,7 @@ async function createBrowserContext(url: string, correlationId: string, botType: ...browserArgs, ...displayArgs, ], - ignoreDefaultArgs: ['--mute-audio'], + ignoreDefaultArgs, executablePath: config.chromeExecutablePath, }), 60000, @@ -130,20 +258,16 @@ async function createBrowserContext(url: string, correlationId: string, botType: ); const context = await browser.newContext({ - permissions: ['camera', 'microphone'], - viewport: size, - ignoreHTTPSErrors: true, - // Record video only in development for debugging - ...(process.env.NODE_ENV === 'development' && { - recordVideo: { - dir: './debug-videos/', - size: size, - }, - }), + ...contextOptions, + ...(config.googleChromeStorageStatePath && botType === 'google' ? { + storageState: config.googleChromeStorageStatePath, + } : {}), }); // Grant permissions so Teams will play audio (Teams requires this unlike Google Meet) - await context.grantPermissions(['microphone', 'camera'], { origin: url }); + if (botType !== 'google') { + await context.grantPermissions(['microphone', 'camera'], { origin: url }); + } const page = await context.newPage(); diff --git a/src/util/googleMeetDisplayName.ts b/src/util/googleMeetDisplayName.ts new file mode 100644 index 00000000..d6cdafd3 --- /dev/null +++ b/src/util/googleMeetDisplayName.ts @@ -0,0 +1,22 @@ +const DEFAULT_GOOGLE_MEET_DISPLAY_NAME = 'ScreenApp AI Notes'; + +const RISKY_DISPLAY_NAME_PATTERNS: Array<[RegExp, string]> = [ + [/\b(?:ai[\s-]+)?note[\s-]*taker\b/gi, 'AI Notes'], + [/\bbot\b/gi, 'AI Notes'], + [/\brobot\b/gi, 'AI Notes'], +]; + +export const getGoogleMeetDisplayName = (name?: string): string => { + let displayName = name?.trim() || DEFAULT_GOOGLE_MEET_DISPLAY_NAME; + + for (const [pattern, replacement] of RISKY_DISPLAY_NAME_PATTERNS) { + displayName = displayName.replace(pattern, replacement); + } + + displayName = displayName + .replace(/\s{2,}/g, ' ') + .replace(/\s+([,.;:!?])/g, '$1') + .trim(); + + return displayName || DEFAULT_GOOGLE_MEET_DISPLAY_NAME; +}; From 4e7dbf00192fcd7b2dd626c3d0da14942ad20665 Mon Sep 17 00:00:00 2001 From: jakob Date: Fri, 5 Jun 2026 18:45:04 +0200 Subject: [PATCH 14/53] Add Chrome CDP service proxy --- Dockerfile.chrome-cdp | 3 ++- package-lock.json | 4 ++-- package.json | 2 +- scripts/start-chrome-cdp.sh | 6 ++++++ 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Dockerfile.chrome-cdp b/Dockerfile.chrome-cdp index b24b04e6..e95f6cfd 100644 --- a/Dockerfile.chrome-cdp +++ b/Dockerfile.chrome-cdp @@ -32,6 +32,7 @@ RUN apt-get update && \ libxss1 \ libxtst6 \ pulseaudio \ + socat \ wget \ xdg-utils \ xvfb && \ @@ -53,7 +54,7 @@ RUN chmod +x /usr/local/bin/start-chrome-cdp USER chrome ENV XDG_RUNTIME_DIR=/run/user/1001 -EXPOSE 9222 +EXPOSE 9222 9223 ENTRYPOINT ["dumb-init", "--"] CMD ["start-chrome-cdp"] diff --git a/package-lock.json b/package-lock.json index 313a7769..685d33b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.22", + "version": "1.2.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.22", + "version": "1.2.23", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 7e651079..b3e78bb4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.22", + "version": "1.2.23", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 717a60ff..9897dbb5 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -5,6 +5,8 @@ export DISPLAY="${DISPLAY:-:99}" export CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-/tmp/chrome-profile}" export CHROME_REMOTE_DEBUGGING_ADDRESS="${CHROME_REMOTE_DEBUGGING_ADDRESS:-127.0.0.1}" export CHROME_REMOTE_DEBUGGING_PORT="${CHROME_REMOTE_DEBUGGING_PORT:-9222}" +export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-0.0.0.0}" +export CHROME_CDP_PROXY_PORT="${CHROME_CDP_PROXY_PORT:-9223}" export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" export CHROME_URL="${CHROME_URL:-about:blank}" @@ -17,7 +19,11 @@ fi Xvfb "$DISPLAY" -screen 0 "${CHROME_WINDOW_SIZE}x24" -ac +extension RANDR >/tmp/xvfb.log 2>&1 & xvfb_pid="$!" +socat "TCP-LISTEN:${CHROME_CDP_PROXY_PORT},fork,reuseaddr,bind=${CHROME_CDP_PROXY_ADDRESS}" "TCP:127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}" & +socat_pid="$!" + cleanup() { + kill "$socat_pid" >/dev/null 2>&1 || true kill "$xvfb_pid" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM From 636c48c36e055e774cbdc5fbae22dcb56fe6ef58 Mon Sep 17 00:00:00 2001 From: jakob Date: Fri, 5 Jun 2026 19:12:40 +0200 Subject: [PATCH 15/53] Rewrite Chrome CDP proxy host header --- Dockerfile.chrome-cdp | 2 +- package-lock.json | 4 ++-- package.json | 2 +- scripts/start-chrome-cdp.sh | 29 ++++++++++++++++++++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Dockerfile.chrome-cdp b/Dockerfile.chrome-cdp index e95f6cfd..4da61910 100644 --- a/Dockerfile.chrome-cdp +++ b/Dockerfile.chrome-cdp @@ -31,8 +31,8 @@ RUN apt-get update && \ libxshmfence1 \ libxss1 \ libxtst6 \ + nginx \ pulseaudio \ - socat \ wget \ xdg-utils \ xvfb && \ diff --git a/package-lock.json b/package-lock.json index 685d33b5..858fa898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.23", + "version": "1.2.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.23", + "version": "1.2.24", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index b3e78bb4..8e5a2e6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.23", + "version": "1.2.24", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 9897dbb5..2abcd75c 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -19,11 +19,34 @@ fi Xvfb "$DISPLAY" -screen 0 "${CHROME_WINDOW_SIZE}x24" -ac +extension RANDR >/tmp/xvfb.log 2>&1 & xvfb_pid="$!" -socat "TCP-LISTEN:${CHROME_CDP_PROXY_PORT},fork,reuseaddr,bind=${CHROME_CDP_PROXY_ADDRESS}" "TCP:127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}" & -socat_pid="$!" +cat >/tmp/chrome-cdp-nginx.conf </dev/null 2>&1 || true + kill "$nginx_pid" >/dev/null 2>&1 || true kill "$xvfb_pid" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM From dedcc3b41caf034cd9db680adb184c788a1c25c9 Mon Sep 17 00:00:00 2001 From: jakob Date: Fri, 5 Jun 2026 19:43:47 +0200 Subject: [PATCH 16/53] Keep Chrome CDP proxy running --- package-lock.json | 4 ++-- package.json | 2 +- scripts/start-chrome-cdp.sh | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 858fa898..b49455ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.24", + "version": "1.2.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.24", + "version": "1.2.25", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 8e5a2e6b..a81d1897 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.24", + "version": "1.2.25", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 2abcd75c..2bc556c5 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -47,11 +47,14 @@ nginx_pid="$!" cleanup() { kill "$nginx_pid" >/dev/null 2>&1 || true + if [ -n "${chrome_pid:-}" ]; then + kill "$chrome_pid" >/dev/null 2>&1 || true + fi kill "$xvfb_pid" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM -exec google-chrome-stable \ +google-chrome-stable \ --remote-debugging-address="$CHROME_REMOTE_DEBUGGING_ADDRESS" \ --remote-debugging-port="$CHROME_REMOTE_DEBUGGING_PORT" \ --remote-allow-origins='*' \ @@ -66,4 +69,7 @@ exec google-chrome-stable \ --disable-backgrounding-occluded-windows \ --disable-renderer-backgrounding \ --no-sandbox \ - "$CHROME_URL" + "$CHROME_URL" & +chrome_pid="$!" + +wait -n "$nginx_pid" "$chrome_pid" From dcb67681405fa88b6e85e581ac2b3de8b6a5f23d Mon Sep 17 00:00:00 2001 From: jakob Date: Fri, 5 Jun 2026 20:08:28 +0200 Subject: [PATCH 17/53] Use writable nginx temp paths for Chrome CDP --- package-lock.json | 4 ++-- package.json | 2 +- scripts/start-chrome-cdp.sh | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b49455ec..17a8193c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.25", + "version": "1.2.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.25", + "version": "1.2.26", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index a81d1897..afbbc37b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.25", + "version": "1.2.26", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 2bc556c5..27fa1ccd 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -27,6 +27,11 @@ events {} http { access_log off; + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; server { listen ${CHROME_CDP_PROXY_ADDRESS}:${CHROME_CDP_PROXY_PORT}; From 0817d44378be2953302c970e81868d71aa7283e3 Mon Sep 17 00:00:00 2001 From: jakob Date: Tue, 9 Jun 2026 14:12:22 +0200 Subject: [PATCH 18/53] Add duration metadata to recording uploads --- package-lock.json | 4 ++-- package.json | 2 +- src/middleware/disk-uploader.ts | 17 +++++++++++++++++ .../providers/azure-blob-storage-provider.ts | 1 + src/uploader/providers/s3-storage-provider.ts | 1 + src/uploader/providers/storage-provider.ts | 1 + 6 files changed, 23 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 17a8193c..93f0a4d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.26", + "version": "1.2.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.26", + "version": "1.2.27", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index afbbc37b..0e907a36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.26", + "version": "1.2.27", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index 73ba60e1..84795040 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -649,6 +649,7 @@ class DiskUploader implements IUploader { filePath, key, contentType: this.contentType, + metadata: this.getObjectStorageMetadata(), logger: this._logger, partSize: chunkSize, concurrency: 4, @@ -743,6 +744,22 @@ class DiskUploader implements IUploader { } } + private getObjectStorageMetadata(): Record | undefined { + const metadata: Record = { + contentType: this.contentType, + uploaderType: config.uploaderType, + }; + + if (typeof this.recordingDuration === 'number' && Number.isFinite(this.recordingDuration) && this.recordingDuration > 0) { + const duration = String(Math.round(this.recordingDuration)); + metadata.duration = duration; + metadata.durationSeconds = duration; + metadata.recordingDurationSeconds = duration; + } + + return metadata; + } + public async uploadRecordingToRemoteStorage(options?: { forceUpload?: boolean }) { try { if (typeof options?.forceUpload === 'boolean') { diff --git a/src/uploader/providers/azure-blob-storage-provider.ts b/src/uploader/providers/azure-blob-storage-provider.ts index 4e1bb5c8..5baab657 100644 --- a/src/uploader/providers/azure-blob-storage-provider.ts +++ b/src/uploader/providers/azure-blob-storage-provider.ts @@ -76,6 +76,7 @@ export class AzureBlobStorageProvider implements StorageProvider { options.logger.info(`Starting Azure Blob upload for ${blobName}`); await blob.uploadFile(options.filePath, { blobHTTPHeaders: { blobContentType: options.contentType }, + metadata: options.metadata, concurrency: options.concurrency ?? config.azureBlobStorage.uploadConcurrency ?? 4, onProgress: (p: { loadedBytes?: number }) => { if (p.loadedBytes) { diff --git a/src/uploader/providers/s3-storage-provider.ts b/src/uploader/providers/s3-storage-provider.ts index 66f360aa..3014a362 100644 --- a/src/uploader/providers/s3-storage-provider.ts +++ b/src/uploader/providers/s3-storage-provider.ts @@ -51,6 +51,7 @@ export class S3StorageProvider implements StorageProvider { Key: options.key, Body: createReadStream(options.filePath), ContentType: options.contentType, + Metadata: options.metadata, }, queueSize: options.concurrency || 4, partSize: options.partSize || 50 * 1024 * 1024, diff --git a/src/uploader/providers/storage-provider.ts b/src/uploader/providers/storage-provider.ts index fbf3d22d..ac585787 100644 --- a/src/uploader/providers/storage-provider.ts +++ b/src/uploader/providers/storage-provider.ts @@ -5,6 +5,7 @@ export interface UploadOptions { filePath: string; key: string; contentType: ContentType; + metadata?: Record; logger: Logger; partSize?: number; concurrency?: number; From b0be72d2a1d41b8d29d0087462b27a3fd0719337 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 10 Jun 2026 16:09:27 +0200 Subject: [PATCH 19/53] Fix Google Meet CDP recording viewport --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- scripts/start-chrome-cdp.sh | 10 +++++++--- src/lib/chromium.ts | 26 +++++++++++++++++++++++++- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index dc82f687..6e7605c2 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Content-Type: application/json } ``` -For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. +For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. The Chrome window and virtual display should use the same 16:9 size as the bot viewport, normally `1280,720`, so tab recordings do not contain unused white space. For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` at `http://127.0.0.1:9222`. The included `Dockerfile.chrome-cdp` builds a minimal Google Chrome + Xvfb CDP backend for that setup. diff --git a/package-lock.json b/package-lock.json index 93f0a4d6..8ad333f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.27", + "version": "1.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.27", + "version": "1.2.28", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 0e907a36..6b182601 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.27", + "version": "1.2.28", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 27fa1ccd..0a4b6e4a 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -7,8 +7,10 @@ export CHROME_REMOTE_DEBUGGING_ADDRESS="${CHROME_REMOTE_DEBUGGING_ADDRESS:-127.0 export CHROME_REMOTE_DEBUGGING_PORT="${CHROME_REMOTE_DEBUGGING_PORT:-9222}" export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-0.0.0.0}" export CHROME_CDP_PROXY_PORT="${CHROME_CDP_PROXY_PORT:-9223}" -export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" +export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1280,720}" export CHROME_URL="${CHROME_URL:-about:blank}" +CHROME_WINDOW_SIZE_FOR_CHROME="${CHROME_WINDOW_SIZE/x/,}" +XVFB_SCREEN_SIZE="${CHROME_WINDOW_SIZE/,/x}" mkdir -p "$CHROME_USER_DATA_DIR" /tmp/.X11-unix @@ -16,7 +18,7 @@ if command -v pulseaudio >/dev/null 2>&1; then pulseaudio --start --exit-idle-time=-1 || true fi -Xvfb "$DISPLAY" -screen 0 "${CHROME_WINDOW_SIZE}x24" -ac +extension RANDR >/tmp/xvfb.log 2>&1 & +Xvfb "$DISPLAY" -screen 0 "${XVFB_SCREEN_SIZE}x24" -ac +extension RANDR >/tmp/xvfb.log 2>&1 & xvfb_pid="$!" cat >/tmp/chrome-cdp-nginx.conf < Date: Wed, 10 Jun 2026 17:00:28 +0200 Subject: [PATCH 20/53] Fix Google Meet recording height --- README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- scripts/start-chrome-cdp.sh | 2 +- src/lib/chromium.ts | 15 ++++++++------- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6e7605c2..7e1da2ef 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Content-Type: application/json } ``` -For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. The Chrome window and virtual display should use the same 16:9 size as the bot viewport, normally `1280,720`, so tab recordings do not contain unused white space. +For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. The Chrome window and virtual display should normally use `1280,800`; the bot then sets the page viewport to `1280x720`, leaving room for Chrome's top UI without clipping the Meet controls. For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` at `http://127.0.0.1:9222`. The included `Dockerfile.chrome-cdp` builds a minimal Google Chrome + Xvfb CDP backend for that setup. diff --git a/package-lock.json b/package-lock.json index 8ad333f6..1ba88cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.28", + "version": "1.2.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.28", + "version": "1.2.29", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 6b182601..217f4740 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.28", + "version": "1.2.29", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 0a4b6e4a..213b9d48 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -7,7 +7,7 @@ export CHROME_REMOTE_DEBUGGING_ADDRESS="${CHROME_REMOTE_DEBUGGING_ADDRESS:-127.0 export CHROME_REMOTE_DEBUGGING_PORT="${CHROME_REMOTE_DEBUGGING_PORT:-9222}" export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-0.0.0.0}" export CHROME_CDP_PROXY_PORT="${CHROME_CDP_PROXY_PORT:-9223}" -export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1280,720}" +export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1280,800}" export CHROME_URL="${CHROME_URL:-about:blank}" CHROME_WINDOW_SIZE_FOR_CHROME="${CHROME_WINDOW_SIZE/x/,}" XVFB_SCREEN_SIZE="${CHROME_WINDOW_SIZE/,/x}" diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index 3697e499..93d80dc1 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -17,7 +17,7 @@ export function isExternalBrowserContext(context?: BrowserContext | null): boole return Boolean(context && externalBrowserContexts.has(context)); } -async function resizePageForRecording(page: Page, size: { width: number; height: number }, correlationId: string) { +async function resizeBrowserWindow(page: Page, size: { width: number; height: number }, correlationId: string) { const log = getCorrelationIdLog(correlationId); try { @@ -36,8 +36,6 @@ async function resizePageForRecording(page: Page, size: { width: number; height: } catch (err: any) { console.warn(`${log} Unable to resize Chrome window through CDP`, err?.message ?? err); } - - await page.setViewportSize(size); } function attachBrowserErrorHandlers(browser: Browser | null, context: BrowserContext, page: Page, correlationId: string) { @@ -128,6 +126,7 @@ async function launchPersistentContextWithTimeout(launchFn: () => Promise { const size = { width: 1280, height: 720 }; + const browserWindowSize = { width: size.width, height: size.height + 80 }; // Google Meet is sensitive to browser fingerprinting before admission. Keep // its launch close to normal Chrome and reserve recording-heavy flags for @@ -135,7 +134,7 @@ async function createBrowserContext(url: string, correlationId: string, botType: const googleBrowserArgs: string[] = [ '--no-sandbox', '--disable-setuid-sandbox', - `--window-size=${size.width},${size.height}`, + `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, '--auto-accept-this-tab-capture', '--autoplay-policy=no-user-gesture-required', ]; @@ -148,7 +147,7 @@ async function createBrowserContext(url: string, correlationId: string, botType: '--disable-web-security', '--use-gl=angle', '--use-angle=swiftshader', - `--window-size=${size.width},${size.height}`, + `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, '--auto-accept-this-tab-capture', '--enable-features=MediaRecorder', '--enable-audio-service-out-of-process', @@ -220,7 +219,8 @@ async function createBrowserContext(url: string, correlationId: string, botType: externalBrowserContexts.add(context); const page = await context.newPage(); - await resizePageForRecording(page, size, correlationId); + await resizeBrowserWindow(page, browserWindowSize, correlationId); + await page.setViewportSize(size); attachBrowserErrorHandlers(browser, context, page, correlationId); console.log(`${getCorrelationIdLog(correlationId)} External Chrome connected successfully!`); @@ -252,7 +252,8 @@ async function createBrowserContext(url: string, correlationId: string, botType: ); const page = context.pages()[0] ?? await context.newPage(); - await resizePageForRecording(page, size, correlationId); + await resizeBrowserWindow(page, browserWindowSize, correlationId); + await page.setViewportSize(size); attachBrowserErrorHandlers(context.browser(), context, page, correlationId); console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launched successfully!`); From 4c92367545060252000ecfcfb3a71d04866c4989 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 10 Jun 2026 17:19:56 +0200 Subject: [PATCH 21/53] Improve recording codec handling --- docker-compose.yml | 36 +++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/bots/GoogleMeetBot.ts | 24 +++++++-------- src/bots/MicrosoftTeamsBot.ts | 2 +- src/lib/ffmpegRecorder.ts | 55 ++++++++++++++++++++++++----------- src/lib/recording.ts | 8 +++-- src/tasks/RecordingTask.ts | 24 +++++++-------- 8 files changed, 104 insertions(+), 51 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f10c7f40..59dd7596 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,54 @@ services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + command: ["redis-server", "--save", "", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - cache:/data + meeting-bot: + platform: linux/amd64 build: context: . dockerfile: Dockerfile.development environment: - NODE_ENV=development + # Use object storage upload path with pluggable providers + - UPLOADER_TYPE=s3 + # Enable Azure Blob Storage as the storage provider for testing + - STORAGE_PROVIDER=azure + # Azure configuration for local testing. Set this in your shell or .env file. + - AZURE_STORAGE_CONNECTION_STRING=${AZURE_STORAGE_CONNECTION_STRING:-} + # Choose an existing container in the meetingbottest account + - AZURE_STORAGE_CONTAINER=meeting-recordings + # Redis base connection (used by notifications if NOTIFY_REDIS_URI not set) + - REDIS_HOST=redis + - REDIS_PORT=6379 + # Enable Redis-based recording completion notifications (optional) + - NOTIFY_REDIS_ENABLED=true + - NOTIFY_REDIS_DB=1 # DB 0 not allowed; code will auto-switch to 1 if set to 0 + - NOTIFY_REDIS_LIST=jobs:meetbot:recordings + - GOOGLE_CHROME_CDP_URL=${GOOGLE_CHROME_CDP_URL:-} + - GOOGLE_CHROME_USER_DATA_DIR=${GOOGLE_CHROME_USER_DATA_DIR:-} + - GOOGLE_CHROME_STORAGE_STATE_PATH=${GOOGLE_CHROME_STORAGE_STATE_PATH:-} ports: - "3000:3000" volumes: - .:/usr/src/app + - ./.chrome:/usr/src/app/.chrome - ./assets/screenshots:/usr/src/app/assets/screenshots - ./debug-videos:/usr/src/app/debug-videos - /usr/src/app/node_modules # Persist node_modules command: ["/bin/bash", "./start.sh"] + depends_on: + redis: + condition: service_healthy volumes: diff --git a/package-lock.json b/package-lock.json index 1ba88cd9..5123fd9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.29", + "version": "1.2.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.29", + "version": "1.2.30", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 217f4740..0b9f40e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.29", + "version": "1.2.30", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 619d5edf..2ffe0f50 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -683,12 +683,12 @@ export class GoogleMeetBot extends MeetBotBase { } }); - const { primaryMimeType, secondaryMimeType } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); // Inject the MediaRecorder code into the browser context using page.evaluate await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: - { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes }: + { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[] }) => { let timeoutId: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; let isOnValidGoogleMeetPageInterval: NodeJS.Timeout; @@ -735,17 +735,14 @@ export class GoogleMeetBot extends MeetBotBase { console.warn('No audio tracks available for silence detection. Will rely only on presence detection.'); } - let options: MediaRecorderOptions = {}; - if (MediaRecorder.isTypeSupported(primaryMimeType)) { - console.log(`Media Recorder will use ${primaryMimeType} codecs...`); - options = { mimeType: primaryMimeType }; - } - else { - console.warn(`Media Recorder did not find primary mime type codecs ${primaryMimeType}, Using fallback codecs ${secondaryMimeType}`); - options = { mimeType: secondaryMimeType }; + const selectedMimeType = mimeTypes.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)); + if (!selectedMimeType) { + throw new Error(`MediaRecorder does not support requested codecs: ${mimeTypes.join(', ')}`); } - const mediaRecorder = new MediaRecorder(stream, { ...options }); + console.log(`Media Recorder will use ${selectedMimeType} codecs...`); + const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }); + console.log(`Media Recorder actual mime type: ${mediaRecorder.mimeType}`); let chunkUploadChain: Promise = Promise.resolve(); let isStoppingRecording = false; @@ -1262,8 +1259,7 @@ export class GoogleMeetBot extends MeetBotBase { slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - primaryMimeType, - secondaryMimeType + mimeTypes } ); diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index 16fece84..3bd754c0 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -374,7 +374,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Use the same temp folder as Google Meet bot (has proper permissions) const tempFolder = path.join(process.cwd(), 'dist', '_tempvideo'); - const outputPath = path.join(tempFolder, `recording-${botId || Date.now()}.mp4`); + const outputPath = path.join(tempFolder, `recording-${botId || Date.now()}${config.uploaderFileExtension}`); this._logger.info('Starting ffmpeg recording...', { outputPath, duration }); diff --git a/src/lib/ffmpegRecorder.ts b/src/lib/ffmpegRecorder.ts index f1210275..045eccd3 100644 --- a/src/lib/ffmpegRecorder.ts +++ b/src/lib/ffmpegRecorder.ts @@ -22,6 +22,43 @@ export class FFmpegRecorder { async start(): Promise { return new Promise((resolve, reject) => { try { + const isWebmOutput = this.outputPath.toLowerCase().endsWith('.webm'); + const encodingArgs = isWebmOutput ? [ + // WebM should use VP9 video and Opus audio. + '-c:v', 'libvpx-vp9', + '-deadline', 'realtime', + '-cpu-used', '4', + '-pix_fmt', 'yuv420p', + '-crf', '33', + '-b:v', '0', + '-g', '50', // Keyframe interval + '-threads', '0', + + // Audio encoding + '-c:a', 'libopus', + '-b:a', '128k', + '-ar', '48000', + '-ac', '2', + ] : [ + // MP4-compatible video encoding + '-c:v', 'libx264', + '-preset', 'faster', + '-pix_fmt', 'yuv420p', + '-crf', '23', + '-g', '50', // Keyframe interval + '-threads', '0', + + // Audio encoding + '-c:a', 'aac', + '-b:a', '128k', + '-ar', '44100', + '-ac', '2', + '-strict', 'experimental', + + // MP4 optimization + '-movflags', '+faststart', + ]; + // FFmpeg command to capture X11 display and PulseAudio monitor const ffmpegArgs = [ '-y', // Overwrite output file @@ -40,28 +77,12 @@ export class FFmpegRecorder { '-ar', '44100', '-i', 'virtual_output.monitor', - // Video encoding with better compatibility - '-c:v', 'libx264', - '-preset', 'faster', - '-pix_fmt', 'yuv420p', - '-crf', '23', - '-g', '50', // Keyframe interval - '-threads', '0', - - // Audio encoding - '-c:a', 'aac', - '-b:a', '128k', - '-ar', '44100', - '-ac', '2', - '-strict', 'experimental', + ...encodingArgs, // Sync and timing '-vsync', 'cfr', '-async', '1', - // MP4 optimization - '-movflags', '+faststart', - // Output this.outputPath ]; diff --git a/src/lib/recording.ts b/src/lib/recording.ts index 31c2a088..66a257f3 100644 --- a/src/lib/recording.ts +++ b/src/lib/recording.ts @@ -10,20 +10,24 @@ export const mp4ContentType: ContentType = 'video/mp4'; export const mp4MimeType = `${mp4ContentType};codecs="${mp4Codecs.h264},${mp4Codecs.aac}"`; export const webmContentType: ContentType = 'video/webm'; -export const webmMimeType = `${webmContentType};codecs="h264,opus"`; +export const webmMimeType = `${webmContentType};codecs=vp9,opus`; export const vp9ContentType: ContentType = 'video/webm'; -export const vp9MimeType = `${vp9ContentType};codecs="vp9,opus"`; +export const vp9MimeType = `${vp9ContentType};codecs=vp09.00.10.08,opus`; export const getRecordingMimeTypesForExtension = (extension: string) => { + const webmVp9MimeTypes = [webmMimeType, vp9MimeType]; + if (extension === '.mp4') { return { + mimeTypes: [mp4MimeType, ...webmVp9MimeTypes], primaryMimeType: mp4MimeType, secondaryMimeType: webmMimeType, }; } return { + mimeTypes: webmVp9MimeTypes, primaryMimeType: webmMimeType, secondaryMimeType: vp9MimeType, }; diff --git a/src/tasks/RecordingTask.ts b/src/tasks/RecordingTask.ts index dca83faf..0e3c1e0b 100644 --- a/src/tasks/RecordingTask.ts +++ b/src/tasks/RecordingTask.ts @@ -30,12 +30,12 @@ export class RecordingTask extends Task { } protected async execute(): Promise { - const { primaryMimeType, secondaryMimeType } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); const loneParticipantExitDelayMs = config.loneParticipantExitDelaySeconds * 1000; await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, primaryMimeType, secondaryMimeType }: - { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, primaryMimeType: string, secondaryMimeType: string }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes }: + { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[] }) => { let timeoutId: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; @@ -78,17 +78,14 @@ export class RecordingTask extends Task { preferCurrentTab: true, }); - let options: MediaRecorderOptions = {}; - if (MediaRecorder.isTypeSupported(primaryMimeType)) { - console.log(`Media Recorder will use ${primaryMimeType} codecs...`); - options = { mimeType: primaryMimeType }; - } - else { - console.warn(`Media Recorder did not find primary mime type codecs ${primaryMimeType}, Using fallback codecs ${secondaryMimeType}`); - options = { mimeType: secondaryMimeType }; + const selectedMimeType = mimeTypes.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)); + if (!selectedMimeType) { + throw new Error(`MediaRecorder does not support requested codecs: ${mimeTypes.join(', ')}`); } - const mediaRecorder = new MediaRecorder(stream, { ...options }); + console.log(`Media Recorder will use ${selectedMimeType} codecs...`); + const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }); + console.log(`Media Recorder actual mime type: ${mediaRecorder.mimeType}`); let chunkUploadChain: Promise = Promise.resolve(); let isStoppingRecording = false; @@ -320,8 +317,7 @@ export class RecordingTask extends Task { slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - primaryMimeType, - secondaryMimeType + mimeTypes } ); } From c2cf80b5271c993b26029625336e108f416c0a87 Mon Sep 17 00:00:00 2001 From: jakob Date: Mon, 15 Jun 2026 16:44:05 +0200 Subject: [PATCH 22/53] Track recording duration for Teams uploads --- package-lock.json | 4 ++-- package.json | 2 +- src/bots/MicrosoftTeamsBot.ts | 13 ++++++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5123fd9d..0fdf5dde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.30", + "version": "1.2.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.30", + "version": "1.2.31", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 0b9f40e2..f6ba7020 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.30", + "version": "1.2.31", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index 3bd754c0..df992f8d 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -418,12 +418,15 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Track FFmpeg status let ffmpegFailed = false; let ffmpegError: Error | null = null; + let recordingStartedAt: number | undefined; // Hoisted out of the try block so the matching finally can set it to signal // the silence detector (declared inside the try) to stop on its next tick. let meetingEnded = false; try { await recorder.start(); + recordingStartedAt = Date.now(); + const startedAt = recordingStartedAt; this._logger.info('FFmpeg recording started successfully'); // Monitor FFmpeg process - if it dies, stop recording immediately @@ -774,15 +777,14 @@ export class MicrosoftTeamsBot extends MeetBotBase { ); // Wait for either timeout, meeting end, or FFmpeg failure - const startTime = Date.now(); - while (!meetingEnded && !ffmpegFailed && (Date.now() - startTime) < duration) { + while (!meetingEnded && !ffmpegFailed && (Date.now() - startedAt) < duration) { await new Promise(resolve => setTimeout(resolve, 1000)); } this._logger.info('Recording period ended', { meetingEnded, ffmpegFailed, - recordedDuration: Math.floor((Date.now() - startTime) / 1000) + 's' + recordedDuration: Math.floor((Date.now() - startedAt) / 1000) + 's' }); // If FFmpeg failed during recording, throw the error @@ -812,6 +814,11 @@ export class MicrosoftTeamsBot extends MeetBotBase { let uploadSuccess = false; if (fs.existsSync(outputPath)) { + if (recordingStartedAt) { + const recordedDurationSeconds = Math.max(1, Math.round((Date.now() - recordingStartedAt) / 1000)); + uploader.setRecordingDuration(recordedDurationSeconds); + } + const fileBuffer = fs.readFileSync(outputPath); await uploader.saveDataToTempFile(fileBuffer); From a9665cf4f2ccca30f20ee69afe9c25ae0307daa5 Mon Sep 17 00:00:00 2001 From: jakob Date: Mon, 15 Jun 2026 17:45:17 +0200 Subject: [PATCH 23/53] fix: suppress Chrome first-run prompt --- auto_launch_protocols.json | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- src/lib/chromium.ts | 10 ++++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/auto_launch_protocols.json b/auto_launch_protocols.json index dc47ddf5..7287bed3 100644 --- a/auto_launch_protocols.json +++ b/auto_launch_protocols.json @@ -1,4 +1,6 @@ { + "DefaultBrowserSettingEnabled": false, + "MetricsReportingEnabled": false, "AutoLaunchProtocolsFromOrigins": [ { "allowed_origins": [ diff --git a/package-lock.json b/package-lock.json index 0fdf5dde..7ddefcc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.31", + "version": "1.2.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.31", + "version": "1.2.32", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index f6ba7020..3a024ba3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.31", + "version": "1.2.32", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index 93d80dc1..afefdc96 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -131,7 +131,16 @@ async function createBrowserContext(url: string, correlationId: string, botType: // Google Meet is sensitive to browser fingerprinting before admission. Keep // its launch close to normal Chrome and reserve recording-heavy flags for // platforms that need them. + const firstRunSuppressionArgs: string[] = [ + '--no-first-run', + '--no-default-browser-check', + '--disable-first-run-ui', + '--disable-default-browser-promo', + '--disable-default-apps', + ]; + const googleBrowserArgs: string[] = [ + ...firstRunSuppressionArgs, '--no-sandbox', '--disable-setuid-sandbox', `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, @@ -140,6 +149,7 @@ async function createBrowserContext(url: string, correlationId: string, botType: ]; const recordingBrowserArgs: string[] = [ + ...firstRunSuppressionArgs, '--enable-usermedia-screen-capturing', '--allow-http-screen-capture', '--no-sandbox', From 611ae124b4a0a2953635ce4fb3bec6e6843ca38b Mon Sep 17 00:00:00 2001 From: Dilushan Date: Wed, 17 Jun 2026 15:58:44 +0530 Subject: [PATCH 24/53] fix(google): make chrome-cdp sidecar work cross-container and add local docker test rig --- .env.example | 3 ++ .gitattributes | 1 + README.md | 2 ++ docker-compose.yml | 61 +++++++++++++++++++++++++------------ package-lock.json | 4 +-- package.json | 2 +- scripts/start-chrome-cdp.sh | 10 ++++++ 7 files changed, 61 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index e30b4622..fc1ba6cc 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,9 @@ REDIS_HOST=host.docker.internal REDIS_PORT=6379 REDIS_USERNAME= REDIS_PASSWORD= +# docker-compose only: host port to publish the bundled redis on (default 6379). +# Set this if another local Redis already uses 6379, e.g. REDIS_PORT_HOST=6380 +# REDIS_PORT_HOST=6380 # Uploader UPLOADER_FILE_EXTENSION=.webm diff --git a/.gitattributes b/.gitattributes index 12e0fd0a..eda61eca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ +*.sh text eol=lf start.sh text eol=lf xvfb-run-wrapper text eol=lf diff --git a/README.md b/README.md index 7e1da2ef..bd0266f4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ For Google Meet, you can optionally connect to an already-running Chrome via `GO For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` at `http://127.0.0.1:9222`. The included `Dockerfile.chrome-cdp` builds a minimal Google Chrome + Xvfb CDP backend for that setup. +For **local testing**, `docker compose up --build` starts the `chrome-cdp` sidecar alongside the bot and wires `GOOGLE_CHROME_CDP_URL` to it automatically (via the sidecar's nginx proxy on `9223`, which rewrites the `Host` header so cross-container CDP works). If host port `6379` is already taken by another local Redis, start with `REDIS_PORT_HOST=6380 docker compose up --build`. Join a Meet that allows guests, make sure a second participant is present (the bot leaves if it's alone), then `POST /google/join` (see above) and follow `docker compose logs -f meeting-bot`. The recording **upload will fail** without configured storage credentials — that's expected; the join and recording are what this exercises. Set `GOOGLE_CHROME_CDP_URL=` (empty) to fall back to the bot's in-container Chromium. This sidecar joins as an anonymous guest; meetings that require sign-in need a signed-in profile (`GOOGLE_CHROME_USER_DATA_DIR`/`GOOGLE_CHROME_STORAGE_STATE_PATH`), not yet wired into the local compose. + #### Join a Microsoft Teams Meeting ```bash POST /microsoft/join diff --git a/docker-compose.yml b/docker-compose.yml index 59dd7596..2e583374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,12 @@ services: redis: image: redis:7-alpine + # Host-published port is configurable via REDIS_PORT_HOST (default 6379) so it + # can dodge another local Redis already using :6379: + # REDIS_PORT_HOST=6380 docker compose up + # The bot itself always reaches Redis over the compose network as `redis:6379`. ports: - - "6379:6379" + - "${REDIS_PORT_HOST:-6379}:6379" command: ["redis-server", "--save", "", "--appendonly", "no"] healthcheck: test: ["CMD", "redis-cli", "ping"] @@ -17,26 +21,23 @@ services: build: context: . dockerfile: Dockerfile.development + # App config (storage provider, uploader, Azure/S3 creds, notifications, etc.) + # is the source of truth in .env — copy .env.example -> .env. Only the values + # that must differ inside the compose network are overridden in `environment`. + env_file: + - .env environment: - NODE_ENV=development - # Use object storage upload path with pluggable providers - - UPLOADER_TYPE=s3 - # Enable Azure Blob Storage as the storage provider for testing - - STORAGE_PROVIDER=azure - # Azure configuration for local testing. Set this in your shell or .env file. - - AZURE_STORAGE_CONNECTION_STRING=${AZURE_STORAGE_CONNECTION_STRING:-} - # Choose an existing container in the meetingbottest account - - AZURE_STORAGE_CONTAINER=meeting-recordings - # Redis base connection (used by notifications if NOTIFY_REDIS_URI not set) - - REDIS_HOST=redis - - REDIS_PORT=6379 - # Enable Redis-based recording completion notifications (optional) - - NOTIFY_REDIS_ENABLED=true - - NOTIFY_REDIS_DB=1 # DB 0 not allowed; code will auto-switch to 1 if set to 0 - - NOTIFY_REDIS_LIST=jobs:meetbot:recordings - - GOOGLE_CHROME_CDP_URL=${GOOGLE_CHROME_CDP_URL:-} - - GOOGLE_CHROME_USER_DATA_DIR=${GOOGLE_CHROME_USER_DATA_DIR:-} - - GOOGLE_CHROME_STORAGE_STATE_PATH=${GOOGLE_CHROME_STORAGE_STATE_PATH:-} + # Defaults to the bundled redis service over the compose network (.env's + # host.docker.internal is wrong for the in-compose redis). Override to attach + # the bot to another Redis — e.g. a local backend's queue so UI-triggered + # jobs reach it: REDIS_HOST=host.docker.internal docker compose up + - REDIS_HOST=${REDIS_HOST:-redis} + # Default to the bundled chrome-cdp sidecar so Google Meet joins use a real, + # signed-in-capable Chrome instead of the bot's in-container Chromium (which + # Google increasingly blocks). Set GOOGLE_CHROME_CDP_URL= (empty) to fall + # back to the in-container browser. See README.md (Usage → Join a Google Meet). + - GOOGLE_CHROME_CDP_URL=${GOOGLE_CHROME_CDP_URL-http://chrome-cdp:9223} ports: - "3000:3000" volumes: @@ -49,6 +50,28 @@ services: depends_on: redis: condition: service_healthy + chrome-cdp: + condition: service_healthy + + # Real Google Chrome + Xvfb exposed over the Chrome DevTools Protocol, used by + # the Google Meet bot via GOOGLE_CHROME_CDP_URL. Port 9223 is an nginx proxy + # that rewrites the Host header so Playwright can drive Chrome's DevTools + # endpoint from another container (Chrome's raw 9222 is loopback-only and + # rejects cross-container Host headers). Kept on the internal compose network + # only — never publish the CDP port to the host. See README.md (Usage → Join a Google Meet). + chrome-cdp: + platform: linux/amd64 + build: + context: . + dockerfile: Dockerfile.chrome-cdp + shm_size: "1gb" + expose: + - "9223" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9223/json/version"] + interval: 5s + timeout: 3s + retries: 20 volumes: diff --git a/package-lock.json b/package-lock.json index 7ddefcc6..f7d817cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.32", + "version": "1.2.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.32", + "version": "1.2.33", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 3a024ba3..65d93bd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.32", + "version": "1.2.33", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 213b9d48..b57bd9a8 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -43,6 +43,16 @@ http { proxy_set_header Host 127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; + # Chrome reports its DevTools socket using the (rewritten) Host header, so + # /json* responses contain ws://127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}/... + # which a remote client (e.g. the bot in another container) can't reach. + # Rewrite it back to the address the client used so connectOverCDP's second + # hop comes through this proxy. Disable upstream compression so sub_filter + # can see the body. + proxy_set_header Accept-Encoding ""; + sub_filter_types application/json; + sub_filter_once off; + sub_filter "127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}" "\$http_host"; proxy_pass http://127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}; } } From 4cde188c01dae4db713e91ce1b8a37a98b9cf7b6 Mon Sep 17 00:00:00 2001 From: Dilushan Date: Wed, 17 Jun 2026 19:13:20 +0530 Subject: [PATCH 25/53] ci: run docker build on PRs (build-only) and parameterize chrome-cdp image name --- .github/workflows/docker-build.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 7034b962..75a01585 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,11 +4,15 @@ on: push: branches: - '**' # Build and push on all branches + pull_request: + branches: + - main # Build-only (no push) so the required "build" check reports on PRs, + # including PRs from forks (no registry secrets needed since we don't push) env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - CHROME_CDP_IMAGE_NAME: jakobstadlhuber/meeting-bot-chrome-cdp + CHROME_CDP_IMAGE_NAME: ${{ github.repository }}-chrome-cdp jobs: build: @@ -92,7 +96,9 @@ jobs: with: context: . file: ./Dockerfile.production - platforms: linux/amd64,linux/arm64 + # Multi-arch on push; amd64-only on PRs to keep the build check fast + # (arm64 builds under QEMU emulation are very slow for this image). + platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 346239a9d54160a3f5c12aa14c7e9437493c8d99 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 11:32:23 +0200 Subject: [PATCH 26/53] feat(zoom): support external Chrome via CDP (ZOOM_CHROME_CDP_URL) Generalize the connectOverCDP branch in src/lib/chromium.ts so Zoom bots can reuse an already-running Chrome, mirroring the Google Meet path. - Google keeps reusing the browser's first context (logged-in profile / storageState); Zoom always gets a fresh isolated context per meeting to avoid /wc/join/ state bleeding across runs. - Non-Google bots now get camera/mic grantPermissions for parity with the non-CDP launch path. - Opt-in: with ZOOM_CHROME_CDP_URL unset, Zoom falls back to chromium.launch(). - This does not change Zoom's SDK-vs-webclient posture; the bot still drives the web client and records via tab capture. # Conflicts: # docker-compose.yml --- .env.example | 6 ++++++ docker-compose.yml | 5 +++++ src/config.ts | 1 + src/lib/chromium.ts | 37 +++++++++++++++++++++++++++---------- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index fc1ba6cc..6a61a09e 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ RETRY_COUNT=2 # Re-submit anonymous guest requests if Google redirects while still waiting for host admission. GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 +# Optional: connect Zoom bots to an already-running Chrome via CDP. +# Launch that Chrome with --remote-debugging-port and the recording flags the +# Zoom bot needs (see recordingBrowserArgs in src/lib/chromium.ts). Unlike Google, +# Zoom needs no storageState: each meeting gets a fresh isolated browser context. +# ZOOM_CHROME_CDP_URL=http://host.docker.internal:9222 + # GCP bucket configuration for uploading debugging screenshots GCP_ACCESS_KEY_ID= GCP_SECRET_ACCESS_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 2e583374..f3b2c589 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,11 @@ services: # Google increasingly blocks). Set GOOGLE_CHROME_CDP_URL= (empty) to fall # back to the in-container browser. See README.md (Usage → Join a Google Meet). - GOOGLE_CHROME_CDP_URL=${GOOGLE_CHROME_CDP_URL-http://chrome-cdp:9223} + # Optional: connect Zoom bots to an already-running Chrome via CDP. Defaults + # to empty (opt-in) because the bundled chrome-cdp sidecar lacks Zoom's + # recording flags (swiftshader/MediaRecorder); point this at a Chrome launched + # with the full recordingBrowserArgs set. See src/lib/chromium.ts. + - ZOOM_CHROME_CDP_URL=${ZOOM_CHROME_CDP_URL:-} ports: - "3000:3000" volumes: diff --git a/src/config.ts b/src/config.ts index 556f8057..bdb2850f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,6 +70,7 @@ export default { googleChromeCdpUrl: process.env.GOOGLE_CHROME_CDP_URL, googleChromeUserDataDir: process.env.GOOGLE_CHROME_USER_DATA_DIR, googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, + zoomChromeCdpUrl: process.env.ZOOM_CHROME_CDP_URL, googleAnonymousJoinRequestAttempts: process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS ? Number(process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS) : 10, diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index afefdc96..adb58bee 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -209,23 +209,34 @@ async function createBrowserContext(url: string, correlationId: string, botType: }), }; - if (botType === 'google' && config.googleChromeCdpUrl) { - console.log(`${getCorrelationIdLog(correlationId)} Connecting Google bot to external Chrome`, { - cdpUrl: config.googleChromeCdpUrl, + // External (already-running) Chrome via CDP. Supported for Google Meet and + // Zoom. Google reuses the browser's first context because it is tied to a + // logged-in profile/storageState; Zoom joins each meeting anonymously, so + // always spin up a fresh isolated context to avoid state bleeding between + // meetings (the /wc/join/ web client keeps IndexedDB/localStorage per origin). + const cdpUrl = botType === 'google' ? config.googleChromeCdpUrl + : botType === 'zoom' ? config.zoomChromeCdpUrl + : undefined; + + if (cdpUrl) { + console.log(`${getCorrelationIdLog(correlationId)} Connecting ${botType} bot to external Chrome`, { + cdpUrl, }); const browser = await launchBrowserWithTimeout( - async () => await chromium.connectOverCDP(config.googleChromeCdpUrl!), + async () => await chromium.connectOverCDP(cdpUrl!), 60000, correlationId ); - const context = browser.contexts()[0] ?? await browser.newContext({ - ...contextOptions, - ...(config.googleChromeStorageStatePath ? { - storageState: config.googleChromeStorageStatePath, - } : {}), - }); + const context = botType === 'google' + ? (browser.contexts()[0] ?? await browser.newContext({ + ...contextOptions, + ...(config.googleChromeStorageStatePath ? { + storageState: config.googleChromeStorageStatePath, + } : {}), + })) + : await browser.newContext(contextOptions); externalBrowserContexts.add(context); const page = await context.newPage(); @@ -233,6 +244,12 @@ async function createBrowserContext(url: string, correlationId: string, botType: await page.setViewportSize(size); attachBrowserErrorHandlers(browser, context, page, correlationId); + // Parity with the non-CDP launch path: Zoom/Teams expect camera/mic grants + // for the pre-join audio check. Harmless if the web client doesn't use them. + if (botType !== 'google') { + await context.grantPermissions(['microphone', 'camera'], { origin: url }); + } + console.log(`${getCorrelationIdLog(correlationId)} External Chrome connected successfully!`); return page; From 1d598abd942a3448d6bca2b2e9e02c973d5d9e1d Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 11:45:33 +0200 Subject: [PATCH 27/53] chore: bump version to 1.2.35 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09a4459e..93178fd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.34", + "version": "1.2.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.34", + "version": "1.2.35", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index f62ba0bd..abc37d50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.34", + "version": "1.2.35", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From c45d9316c04ab76ce6e4d1520d659885f78483b3 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 11:48:18 +0200 Subject: [PATCH 28/53] chore: bump version to 1.2.36 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93178fd1..135a05a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.35", + "version": "1.2.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.35", + "version": "1.2.36", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index abc37d50..2496ed53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.35", + "version": "1.2.36", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From afb2ec8609108835b674827c118fc9f145079a2a Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 11:55:07 +0200 Subject: [PATCH 29/53] chore: bump version to 1.2.37 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 135a05a6..d33fa877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.2.36", + "version": "1.2.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.36", + "version": "1.2.37", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 2496ed53..2c0f86b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.36", + "version": "1.2.37", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From 8acc160f05c6af4af4a41535effd52cc766af47e Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 13:10:45 +0200 Subject: [PATCH 30/53] chore: bump version to 1.3.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d33fa877..1b15e1d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.37", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 2c0f86b4..1186ff95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.2.37", + "version": "1.3.0", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From 0243c6bdbee07e44b3a6a5a1741612cbd487ff8a Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 14:29:32 +0200 Subject: [PATCH 31/53] fix(zoom): prefer VP8 + set video bitrate to fix laggy recordings Zoom records via real-time tab capture (getDisplayMedia + MediaRecorder), often under software GL (swiftshader). VP9 can't keep up there: the encoder gets starved, drops frames, and emits a stuttery, very low-bitrate stream (observed ~187 kbps at 1280x662) that looks laggy on playback. ffprobe on a test clip showed avg_frame_rate=0/0 and nb_frames=N/A -- the classic signature of MediaRecorder frame drops. - recording.ts: add vp8MimeType and a preferVp8 flag on getRecordingMimeTypesForExtension. When true, VP8 is tried first (Chrome always supports video/webm;codecs=vp8,opus), with VP9 kept as fallback. - RecordingTask (Zoom-only): pass preferVp8=true and set MediaRecorder videoBitsPerSecond from config (default 4 Mbps) so the encoder isn't starved. - Google Meet is unchanged: it still calls the helper without preferVp8, so it keeps its VP9 default. --- .env.example | 4 ++++ src/config.ts | 5 +++++ src/lib/recording.ts | 16 ++++++++++++---- src/tasks/RecordingTask.ts | 19 +++++++++++-------- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 6a61a09e..5c9f6e83 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ JOIN_WAIT_TIME_MINUTES=2 # RETRY_COUNT=2 means: 1 initial attempt + 2 retries = 3 total attempts RETRY_COUNT=2 +# MediaRecorder target video bitrate (bits/sec) for Zoom tab-capture recordings. +# VP9 under software GL starved the encoder and produced ~187 kbps; this sets a sane target. +# RECORDING_VIDEO_BITS_PER_SECOND=4000000 + # Optional Google Meet identity. # Prefer a dedicated, consented Google account that is invited to meetings. # If you use CDP, launch that Chrome with --auto-accept-this-tab-capture so diff --git a/src/config.ts b/src/config.ts index bdb2850f..22d2a23e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,6 +77,11 @@ export default { inactivityLimit: process.env.MEETING_INACTIVITY_MINUTES ? Number(process.env.MEETING_INACTIVITY_MINUTES) : 1, activateInactivityDetectionAfter: process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES ? Number(process.env.INACTIVITY_DETECTION_START_DELAY_MINUTES) : 1, loneParticipantExitDelaySeconds: process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS ? Number(process.env.LONE_PARTICIPANT_EXIT_DELAY_SECONDS) : 10, + // Target video bitrate for MediaRecorder (bits/sec). The VP9 default produced + // ~187 kbps because the encoder was starved; a sane target avoids blocky/stuttery output. + recordingVideoBitsPerSecond: process.env.RECORDING_VIDEO_BITS_PER_SECOND + ? Number(process.env.RECORDING_VIDEO_BITS_PER_SECOND) + : 4_000_000, serviceKey: process.env.SCREENAPP_BACKEND_SERVICE_API_KEY, joinWaitTime: process.env.JOIN_WAIT_TIME_MINUTES ? Number(process.env.JOIN_WAIT_TIME_MINUTES) : 10, // Number of retries for transient errors (not applied to WaitingAtLobbyRetryError) diff --git a/src/lib/recording.ts b/src/lib/recording.ts index 66a257f3..38f0bc83 100644 --- a/src/lib/recording.ts +++ b/src/lib/recording.ts @@ -15,19 +15,27 @@ export const webmMimeType = `${webmContentType};codecs=vp9,opus`; export const vp9ContentType: ContentType = 'video/webm'; export const vp9MimeType = `${vp9ContentType};codecs=vp09.00.10.08,opus`; -export const getRecordingMimeTypesForExtension = (extension: string) => { - const webmVp9MimeTypes = [webmMimeType, vp9MimeType]; +// VP8 encodes far faster than VP9 in real time. VP9 under software GL +// (swiftshader) starves the encoder -> dropped frames + a stuttery, very +// low-bitrate stream that looks laggy on playback. VP8 is the classic +// MediaRecorder screen-capture codec. +export const vp8MimeType = `${webmContentType};codecs=vp8,opus`; + +export const getRecordingMimeTypesForExtension = (extension: string, preferVp8 = false) => { + const webmMimeTypes = preferVp8 + ? [vp8MimeType, webmMimeType, vp9MimeType] + : [webmMimeType, vp9MimeType]; if (extension === '.mp4') { return { - mimeTypes: [mp4MimeType, ...webmVp9MimeTypes], + mimeTypes: [mp4MimeType, ...webmMimeTypes], primaryMimeType: mp4MimeType, secondaryMimeType: webmMimeType, }; } return { - mimeTypes: webmVp9MimeTypes, + mimeTypes: webmMimeTypes, primaryMimeType: webmMimeType, secondaryMimeType: vp9MimeType, }; diff --git a/src/tasks/RecordingTask.ts b/src/tasks/RecordingTask.ts index 0e3c1e0b..e501e782 100644 --- a/src/tasks/RecordingTask.ts +++ b/src/tasks/RecordingTask.ts @@ -30,12 +30,14 @@ export class RecordingTask extends Task { } protected async execute(): Promise { - const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + // Zoom records via real-time tab capture, often under software GL (swiftshader). + // VP9 can't keep up there and drops frames -> laggy playback, so prefer VP8. + const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension, true); const loneParticipantExitDelayMs = config.loneParticipantExitDelaySeconds * 1000; await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes }: - { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[] }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes, videoBitsPerSecond }: + { teamId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, userId: string, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[], videoBitsPerSecond: number }) => { let timeoutId: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; @@ -84,7 +86,7 @@ export class RecordingTask extends Task { } console.log(`Media Recorder will use ${selectedMimeType} codecs...`); - const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }); + const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType, videoBitsPerSecond }); console.log(`Media Recorder actual mime type: ${mediaRecorder.mimeType}`); let chunkUploadChain: Promise = Promise.resolve(); let isStoppingRecording = false; @@ -308,16 +310,17 @@ export class RecordingTask extends Task { // Start the recording await startRecording(); }, - { +{ teamId: this.teamId, duration: this.duration, - inactivityLimit: this.inactivityLimit, + inactivityLimit: this.inactivityLimit, loneParticipantExitDelayMs, - userId: this.userId, + userId: this.userId, slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - mimeTypes + mimeTypes, + videoBitsPerSecond: config.recordingVideoBitsPerSecond, } ); } From 4b71e1e58c789f3996fe7dcf7ca489273937f35c Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 18 Jun 2026 14:30:05 +0200 Subject: [PATCH 32/53] chore: bump version to 1.3.1 --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1b15e1d3..32889fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { @@ -1172,7 +1172,7 @@ } }, "node_modules/@azure/logger": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", "license": "MIT", @@ -2888,7 +2888,7 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, @@ -3863,7 +3863,7 @@ } }, "node_modules/es-errors": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "engines": { @@ -4673,7 +4673,7 @@ } }, "node_modules/get-intrinsic": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", @@ -6905,7 +6905,7 @@ } }, "node_modules/string_decoder": { - "version": "1.3.0", + "version": "1.3.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", diff --git a/package.json b/package.json index 1186ff95..ed6e5e62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.0", + "version": "1.3.1", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From d5b1fb8740927ad61fc9ddf001a80558ba3cb066 Mon Sep 17 00:00:00 2001 From: Dilushan Date: Thu, 25 Jun 2026 07:39:45 +0530 Subject: [PATCH 33/53] fix(zoom): join Zoom web client over the chrome-cdp sidecar (swiftshader WebGL) --- docker-compose.yml | 11 ++++++----- scripts/start-chrome-cdp.sh | 4 ++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f3b2c589..1f2d3954 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,11 +38,12 @@ services: # Google increasingly blocks). Set GOOGLE_CHROME_CDP_URL= (empty) to fall # back to the in-container browser. See README.md (Usage → Join a Google Meet). - GOOGLE_CHROME_CDP_URL=${GOOGLE_CHROME_CDP_URL-http://chrome-cdp:9223} - # Optional: connect Zoom bots to an already-running Chrome via CDP. Defaults - # to empty (opt-in) because the bundled chrome-cdp sidecar lacks Zoom's - # recording flags (swiftshader/MediaRecorder); point this at a Chrome launched - # with the full recordingBrowserArgs set. See src/lib/chromium.ts. - - ZOOM_CHROME_CDP_URL=${ZOOM_CHROME_CDP_URL:-} + # Connect Zoom bots to the bundled chrome-cdp sidecar by default (like Google). + # Set GOOGLE/ZOOM_CHROME_CDP_URL= (empty) to fall back to in-container Chromium. + # Caveat: the bundled sidecar lacks Zoom's rendering flags (swiftshader/MediaRecorder), + # so capture quality may suffer — point at a Chrome with the full recordingBrowserArgs + # for production-quality Zoom recording. See src/lib/chromium.ts. + - ZOOM_CHROME_CDP_URL=${ZOOM_CHROME_CDP_URL-http://chrome-cdp:9223} ports: - "3000:3000" volumes: diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index b57bd9a8..b111e6f1 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -81,6 +81,10 @@ google-chrome-stable \ --force-device-scale-factor=1 \ --auto-accept-this-tab-capture \ --autoplay-policy=no-user-gesture-required \ + --use-gl=angle \ + --use-angle=swiftshader \ + --enable-unsafe-swiftshader \ + --ignore-gpu-blocklist \ --no-first-run \ --no-default-browser-check \ --disable-dev-shm-usage \ From 80f42e232bbcef6d53119ce604bfa96df504a4f4 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 15 Jul 2026 12:19:07 +0200 Subject: [PATCH 34/53] chore: bump version to 1.3.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32889fa8..22e93e25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.2.37", + "version": "1.3.2", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index ed6e5e62..2bf00cac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.1", + "version": "1.3.2", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", From 087709cfeabbf0eaa5c0f2a63f603d4b57847ddc Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 15 Jul 2026 16:32:36 +0200 Subject: [PATCH 35/53] fix zoom CDP web join --- package-lock.json | 4 +- package.json | 2 +- src/bots/ZoomBot.ts | 99 +++++++++++++++++++++++---------------------- 3 files changed, 53 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index 22e93e25..28b4cc85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.2", + "version": "1.3.3", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 2bf00cac..579127ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.2", + "version": "1.3.3", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 58bad13d..1dcf98d1 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -64,7 +64,7 @@ export class ZoomBot extends BotBase { _state.push('failed'); await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); - + if (error instanceof WaitingAtLobbyRetryError) { await handleWaitingAtLobbyError({ token: bearerToken, botId, eventId, provider: 'zoom', error }, this._logger); } @@ -94,12 +94,36 @@ export class ZoomBot extends BotBase { this.page = await createBrowserContext(url, this._correlationId, 'zoom'); - await this.page.route('**/*.exe', (route) => { + await this.page.route('**/*.exe', async (route) => { this._logger.info(`Detected .exe download: ${route.request().url()?.split('download')[0]}`); + await route.abort(); }); - this._logger.info('Navigating to Zoom Meeting URL...'); - await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + let usingDirectWebClient = false; + const visitWebClientByUrl = async (): Promise => { + usingDirectWebClient = true; + try { + const wcUrl = new URL(url); + wcUrl.pathname = wcUrl.pathname.replace('/j/', '/wc/join/'); + this._logger.info('Navigating to Zoom Web Client URL...', { wcUrl: wcUrl.toString(), botId: params.botId, userId: params.userId }); + await this.page.goto(wcUrl.toString(), { waitUntil: 'domcontentloaded' }); + return true; + } catch(err) { + usingDirectWebClient = false; + this._logger.info('Failed to access ZOOM web client by URL', { botId: params.botId, userId: params.userId }); + return false; + } + }; + + if (config.zoomChromeCdpUrl) { + this._logger.info('Zoom CDP is enabled; navigating directly to the web client...'); + if (!await visitWebClientByUrl()) { + throw new Error('Unable to join meeting after trying to access the web client by /wc/join/'); + } + } else { + this._logger.info('Navigating to Zoom Meeting URL...'); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + } // Accept cookies try { @@ -109,7 +133,7 @@ export class ZoomBot extends BotBase { this._logger.info('Clicking the "Accept Cookies" button...', await acceptCookies.count()); await acceptCookies.click({ force: true }); - + } catch (error) { this._logger.info('Unable to accept cookies...', error); } @@ -118,7 +142,6 @@ export class ZoomBot extends BotBase { this._logger.info(`Page focus status: ${hasFocus}`); const attempts = 3; - let usingDirectWebClient = false; const findAndEnableJoinFromBrowserButton = async (retry: number): Promise => { try { if (retry >= attempts) { @@ -128,15 +151,6 @@ export class ZoomBot extends BotBase { const launchMeetingGetByRole = this.page.getByRole('button', { name: /Launch Meeting/i }).first(); this._logger.info('Does Launch Meeting exist', await launchMeetingGetByRole.isVisible({ timeout: 1000 }).catch(() => false)); - const launchDownloadGetByRole = this.page.getByRole('button', { name: /Download Now/i }).first(); - const launchDownloadVisible = await launchDownloadGetByRole.isVisible({ timeout: 1000 }).catch(() => false); - this._logger.info('Does Download Now exist', launchDownloadVisible); - - if (launchDownloadVisible) { - this._logger.info('Click on Download Now...'); - await launchDownloadGetByRole.click({ force: true }); - } - const joinFromBrowser = this.page.locator('a', { hasText: 'Join from your browser' }).first(); await joinFromBrowser.waitFor({ timeout: 4000 }); @@ -157,21 +171,6 @@ export class ZoomBot extends BotBase { } }; - const visitWebClientByUrl = async (): Promise => { - usingDirectWebClient = true; - try { - const wcUrl = new URL(url); - wcUrl.pathname = wcUrl.pathname.replace('/j/', '/wc/join/'); - this._logger.info('Navigating to Zoom Web Client URL...', { wcUrl: wcUrl.toString(), botId: params.botId, userId: params.userId }); - await this.page.goto(wcUrl.toString(), { waitUntil: 'domcontentloaded' }); - return true; - } catch(err) { - usingDirectWebClient = false; - this._logger.info('Failed to access ZOOM web client by URL', { botId: params.botId, userId: params.userId }); - return false; - } - }; - const waitForJoinFromBrowserNav = async (): Promise => { try { const maxAttempts = 10; @@ -220,25 +219,27 @@ export class ZoomBot extends BotBase { } }; - // Join from browser - this._logger.info('Waiting for Join from your browser to be visible...'); - const foundAndClickedJoinFromBrowser = await findAndEnableJoinFromBrowserButton(0); - - let navSuccess = false; - if (foundAndClickedJoinFromBrowser) { - this._logger.info('Verify the meeting web client is visible...'); - // Ensure the page has navigated to the web client... - navSuccess = await waitForJoinFromBrowserNav(); - } - - if (!foundAndClickedJoinFromBrowser || !navSuccess) { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'enable-join-from-browser', params.userId, this._logger, params.botId); - this._logger.info('Failed to enable Join from your browser button...', params.userId); - this._logger.info('Zoom Bot will now attempt to access the Web Client by URL...', params.userId); - const canAccess = await visitWebClientByUrl(); - if (!canAccess) { - await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'direct-access-webclient', params.userId, this._logger, params.botId); - throw new Error('Unable to join meeting after trying to access the web client by /wc/join/'); + if (!usingDirectWebClient) { + // Join from browser + this._logger.info('Waiting for Join from your browser to be visible...'); + const foundAndClickedJoinFromBrowser = await findAndEnableJoinFromBrowserButton(0); + + let navSuccess = false; + if (foundAndClickedJoinFromBrowser) { + this._logger.info('Verify the meeting web client is visible...'); + // Ensure the page has navigated to the web client... + navSuccess = await waitForJoinFromBrowserNav(); + } + + if (!foundAndClickedJoinFromBrowser || !navSuccess) { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'enable-join-from-browser', params.userId, this._logger, params.botId); + this._logger.info('Failed to enable Join from your browser button...', params.userId); + this._logger.info('Zoom Bot will now attempt to access the Web Client by URL...', params.userId); + const canAccess = await visitWebClientByUrl(); + if (!canAccess) { + await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'direct-access-webclient', params.userId, this._logger, params.botId); + throw new Error('Unable to join meeting after trying to access the web client by /wc/join/'); + } } } From df3bf06003c0e3e5e407adb9e09e48dc2d6b182d Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 15 Jul 2026 18:44:10 +0200 Subject: [PATCH 36/53] feat(zoom): add configurable RTMS recording --- .env.example | 15 + DEPLOYMENT.md | 6 +- Dockerfile.development | 14 +- Dockerfile.production | 15 +- README.md | 15 +- package-lock.json | 415 ++++++++++++++++++++++++- package.json | 9 +- src/app/index.ts | 3 +- src/app/zoom.ts | 2 + src/bots/ZoomBot.ts | 8 +- src/config.ts | 24 ++ src/index.ts | 2 + src/middleware/disk-uploader.ts | 43 +++ src/rtms/RtmsMediaRecorder.test.ts | 34 +++ src/rtms/RtmsMediaRecorder.ts | 331 ++++++++++++++++++++ src/rtms/ZoomRtmsApi.test.ts | 90 ++++++ src/rtms/ZoomRtmsApi.ts | 176 +++++++++++ src/rtms/ZoomRtmsEventStore.test.ts | 54 ++++ src/rtms/ZoomRtmsEventStore.ts | 298 ++++++++++++++++++ src/rtms/ZoomRtmsSdkClient.ts | 230 ++++++++++++++ src/rtms/ZoomRtmsTransport.ts | 453 ++++++++++++++++++++++++++++ src/rtms/types.ts | 26 ++ src/rtms/utils.test.ts | 44 +++ src/rtms/utils.ts | 71 +++++ src/rtms/webhook.test.ts | 112 +++++++ src/rtms/webhook.ts | 101 +++++++ 26 files changed, 2578 insertions(+), 13 deletions(-) create mode 100644 src/rtms/RtmsMediaRecorder.test.ts create mode 100644 src/rtms/RtmsMediaRecorder.ts create mode 100644 src/rtms/ZoomRtmsApi.test.ts create mode 100644 src/rtms/ZoomRtmsApi.ts create mode 100644 src/rtms/ZoomRtmsEventStore.test.ts create mode 100644 src/rtms/ZoomRtmsEventStore.ts create mode 100644 src/rtms/ZoomRtmsSdkClient.ts create mode 100644 src/rtms/ZoomRtmsTransport.ts create mode 100644 src/rtms/types.ts create mode 100644 src/rtms/utils.test.ts create mode 100644 src/rtms/utils.ts create mode 100644 src/rtms/webhook.test.ts create mode 100644 src/rtms/webhook.ts diff --git a/.env.example b/.env.example index 5c9f6e83..f88728d0 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,21 @@ GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 # Zoom needs no storageState: each meeting gets a fresh isolated browser context. # ZOOM_CHROME_CDP_URL=http://host.docker.internal:9222 +# Zoom recording transport: browser (default) or rtms. +# RTMS is a separate Zoom app-based transport and does not use Chrome/CDP. +ZOOM_RECORDING_TRANSPORT=browser +# ZOOM_RTMS_CLIENT_ID= +# ZOOM_RTMS_CLIENT_SECRET= +# ZOOM_RTMS_WEBHOOK_SECRET= +# Prefer account-level S2S OAuth credentials. A short-lived access token is useful for local tests. +# ZOOM_RTMS_OAUTH_ACCESS_TOKEN= +# ZOOM_RTMS_OAUTH_ACCOUNT_ID= +# ZOOM_RTMS_OAUTH_CLIENT_ID= +# ZOOM_RTMS_OAUTH_CLIENT_SECRET= +# Required with account-level control when Zoom cannot infer the authorized participant. +# ZOOM_RTMS_PARTICIPANT_USER_ID= +# ZOOM_RTMS_EVENT_TTL_SECONDS=3600 + # GCP bucket configuration for uploading debugging screenshots GCP_ACCESS_KEY_ID= GCP_SECRET_ACCESS_KEY= diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fad47fda..131631fe 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -28,7 +28,7 @@ The workflow is defined in `.github/workflows/docker-build.yml` and includes: ### Production Image (`Dockerfile.production`) -- **Base**: Node.js 20 Alpine +- **Base**: Node.js 22 Bookworm - **Multi-stage build** for smaller final image - **Security**: Runs as non-root user - **Optimized**: Only production dependencies @@ -36,7 +36,7 @@ The workflow is defined in `.github/workflows/docker-build.yml` and includes: ### Development Image (`Dockerfile`) -- **Base**: Node.js 20 +- **Base**: Node.js 22 Bookworm - **Full dependencies** for development - **Hot reload** support - **Debugging tools** included @@ -269,4 +269,4 @@ For high availability, consider: ### Contributing -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get involved with the project. \ No newline at end of file +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on how to get involved with the project. diff --git a/Dockerfile.development b/Dockerfile.development index 3baa9c49..db59db31 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -1,4 +1,9 @@ -FROM node:20 +FROM gcc:13-bookworm AS rtms-cxx-runtime +RUN cp "$(g++ -print-file-name=libstdc++.so.6)" /libstdc++.so.6 + +FROM node:22-bookworm + +COPY --from=rtms-cxx-runtime /libstdc++.so.6 /opt/rtms/libstdc++.so.6 WORKDIR /usr/src/app @@ -57,6 +62,11 @@ RUN npm install -g nodemon COPY package*.json ./ RUN npm install +RUN if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ + cp /opt/rtms/libstdc++.so.6 /usr/src/app/node_modules/@zoom/rtms/build/Release/libstdc++.so.6 && \ + node -e "const rtms=require('@zoom/rtms').default;if(typeof rtms.Client!=='function')process.exit(1)"; \ + fi + # Install Playwright and its dependencies RUN npx playwright install --with-deps @@ -64,7 +74,7 @@ RUN npx playwright install --with-deps COPY . . # Build app -RUN npm run build +RUN npm test && npm run build # Set permissions RUN chmod +x /usr/src/app/start.sh diff --git a/Dockerfile.production b/Dockerfile.production index 059ae13f..bcc1cf9f 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -1,5 +1,10 @@ +FROM gcc:13-bookworm AS rtms-cxx-runtime +RUN cp "$(g++ -print-file-name=libstdc++.so.6)" /libstdc++.so.6 + # Use the official Node.js image as a base image -FROM node:20 +FROM node:22-bookworm + +COPY --from=rtms-cxx-runtime /libstdc++.so.6 /opt/rtms/libstdc++.so.6 # Set the working directory WORKDIR /usr/src/app @@ -63,6 +68,12 @@ COPY package*.json ./ # Install all dependencies (including dev dependencies for build) RUN npm ci && npm cache clean --force +# The Zoom RTMS Linux SDK requires GCC 13's C++ runtime and currently supports amd64 only. +RUN if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ + cp /opt/rtms/libstdc++.so.6 /usr/src/app/node_modules/@zoom/rtms/build/Release/libstdc++.so.6 && \ + node -e "const rtms=require('@zoom/rtms').default;if(typeof rtms.Client!=='function')process.exit(1)"; \ + fi + # Install Playwright and its dependencies RUN npx playwright install --with-deps @@ -77,7 +88,7 @@ RUN dos2unix /usr/src/app/xvfb-run-wrapper && \ chmod +x /usr/src/app/xvfb-run-wrapper # Build the TypeScript code -RUN npm run build +RUN npm test && npm run build USER root RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix diff --git a/README.md b/README.md index bd0266f4..2c999e47 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ An open-source automation bot for joining and recording video meetings across mu ### Prerequisites -- Node.js 20+ +- Node.js 22+ - Docker and Docker Compose (for containerized deployment) - Git @@ -125,6 +125,19 @@ Content-Type: application/json } ``` +Zoom uses the browser recorder by default. `ZOOM_CHROME_CDP_URL` remains available for that transport. + +To record through Zoom Realtime Media Streams instead, set `ZOOM_RECORDING_TRANSPORT=rtms` and configure a user-managed Zoom General app with RTMS enabled. Set its public HTTPS webhook endpoint to `POST /zoom/rtms/webhook` and subscribe to `meeting.rtms_started`, `meeting.rtms_stopped`, and `meeting.rtms_interrupted`. + +Required settings: + +- `ZOOM_RTMS_CLIENT_ID`, `ZOOM_RTMS_CLIENT_SECRET`, `ZOOM_RTMS_WEBHOOK_SECRET` +- Prefer a Server-to-Server OAuth app with `ZOOM_RTMS_OAUTH_ACCOUNT_ID`, `ZOOM_RTMS_OAUTH_CLIENT_ID`, and `ZOOM_RTMS_OAUTH_CLIENT_SECRET`; a short-lived `ZOOM_RTMS_OAUTH_ACCESS_TOKEN` is also supported for local testing +- `ZOOM_RTMS_PARTICIPANT_USER_ID` to select and correlate the authorized host when using account-level OAuth +- `REDIS_CONSUMER_ENABLED=true` for staging/production so webhook events reach the correct recording replica + +The General app needs `meeting:read:meeting_audio` and `meeting:read:meeting_video`; the OAuth app needs `meeting:update:participant_rtms_app_status` or its admin variant. The app must be authorized by the meeting host/account. RTMS does not anonymously join arbitrary Zoom links; the password in a join URL is not used. The RTMS output contains Zoom's mixed audio and active-speaker H.264 video and is uploaded as MP4 through the existing uploader. The official RTMS Node SDK currently requires Linux x64 (or macOS arm64); the browser transport remains available on other image architectures. + ### Recording Completion Notifications (Optional) You can configure Meeting Bot to notify external systems when a recording has finished and is ready. Two channels are supported: diff --git a/package-lock.json b/package-lock.json index 28b4cc85..09de96e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.3", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.3", + "version": "1.3.4", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", @@ -43,6 +43,12 @@ "@typescript-eslint/parser": "^5.55.0", "eslint": "^8.36.0", "nodemon": "^3.1.4" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "@zoom/rtms": "1.1.0" } }, "node_modules/@aws-crypto/crc32": { @@ -1517,6 +1523,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2894,6 +2913,31 @@ "dev": true, "license": "ISC" }, + "node_modules/@zoom/rtms": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@zoom/rtms/-/rtms-1.1.0.tgz", + "integrity": "sha512-xxd8OpYjpi9mbjMpv3sh4RX0Q0/lT/5Y/tHR4TR+kyOfz8bNuBxy2C80q+GlyQrDgUyPV+fgI5taI3W9RxsC5g==", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "linux", + "darwin" + ], + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.5.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.6" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3194,12 +3238,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", "license": "MIT" }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -3399,6 +3465,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/chromium-bidi": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", @@ -3610,6 +3686,32 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3720,6 +3822,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1312386", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", @@ -4249,6 +4361,16 @@ "node": ">=0.8.x" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", @@ -4426,6 +4548,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4582,6 +4711,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fs-extra": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", @@ -4737,6 +4873,13 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5067,6 +5210,13 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -5628,6 +5778,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5639,6 +5802,39 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -5664,6 +5860,13 @@ "node": ">=0.10.0" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -5690,6 +5893,13 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5720,6 +5930,29 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6070,6 +6303,71 @@ "resolved": "https://registry.npmjs.org/playwright-extra-plugin-stealth/-/playwright-extra-plugin-stealth-0.0.1.tgz", "integrity": "sha512-eI0Ujf4MXbcupzlVEXaaOnb+Exjt1sFi7t/3KxIA5pVww+WRAXRWdhqTz0glX62jJq2YM8fLu+GyvULpjTpZrw==" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6400,6 +6698,32 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6763,6 +7087,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6979,6 +7350,23 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", @@ -7220,6 +7608,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7535,6 +7936,16 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 579127ae..e43ae383 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.3", + "version": "1.3.4", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", @@ -9,8 +9,12 @@ "dev": "docker compose up --build", "stop": "docker compose down", "build": "tsc", + "test": "node --test -r ts-node/register/transpile-only src/rtms/*.test.ts", "lint": "eslint \"*/**/*.{js,ts}\" --quiet --fix" }, + "engines": { + "node": ">=22.0.0" + }, "keywords": [], "author": "screenappai", "devDependencies": { @@ -48,5 +52,8 @@ "ts-node": "^10.9.2", "uuid": "^11.1.0", "winston": "^3.17.0" + }, + "optionalDependencies": { + "@zoom/rtms": "1.1.0" } } diff --git a/src/app/index.ts b/src/app/index.ts index be543803..994381c6 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -7,10 +7,11 @@ import microsoftRouter from './microsoft'; import zoomRouter from './zoom'; import { globalJobStore } from '../lib/globalJobStore'; import { RedisConsumerService } from '../connect/RedisConsumerService'; +import { captureRawBody } from '../rtms/webhook'; const app = express(); -app.use(express.json()); +app.use(express.json({ verify: captureRawBody })); // Initialize Redis consumer service export const redisConsumerService = new RedisConsumerService(); diff --git a/src/app/zoom.ts b/src/app/zoom.ts index 20753ee9..580ac4d6 100644 --- a/src/app/zoom.ts +++ b/src/app/zoom.ts @@ -8,6 +8,7 @@ import { getRecordingNamePrefix } from '../util/recordingName'; import { encodeFileNameSafebase64 } from '../util/strings'; import { MeetingJoinParams, notifyMeetingJoinFailure } from './common'; import { globalJobStore } from '../lib/globalJobStore'; +import zoomRtmsWebhookRouter from '../rtms/webhook'; const router = express.Router(); @@ -126,5 +127,6 @@ const joinZoom = async (req: Request, res: Response) => { }; router.post('/join', joinZoom); +router.use('/rtms/webhook', zoomRtmsWebhookRouter); export default router; diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 1dcf98d1..40d2c743 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -13,6 +13,7 @@ import { uploadDebugImage } from '../services/bugService'; import { Logger } from 'winston'; import { handleWaitingAtLobbyError } from './MeetBotBase'; import { ZOOM_REQUEST_DENIED } from '../constants'; +import { ZoomRtmsTransport } from '../rtms/ZoomRtmsTransport'; class BotBase extends AbstractMeetBot { protected page: Page; @@ -49,7 +50,12 @@ export class ZoomBot extends BotBase { try { const pushState = (st: BotStatus) => _state.push(st); - await this.joinMeeting({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, pushState, uploader }); + const joinParams = { url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }; + if (config.zoomRecordingTransport === 'rtms') { + await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); + } else { + await this.joinMeeting({ ...joinParams, pushState }); + } await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); // Finish the upload from the temp video diff --git a/src/config.ts b/src/config.ts index 22d2a23e..09e30cbe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,6 +55,18 @@ const parseOptionalNumber = (value?: string) => { return Number(value); }; +const zoomRecordingTransport = process.env.ZOOM_RECORDING_TRANSPORT ?? 'browser'; +if (!['browser', 'rtms'].includes(zoomRecordingTransport)) { + throw new Error('ZOOM_RECORDING_TRANSPORT must be browser or rtms'); +} + +const zoomRtmsEventTtlSeconds = process.env.ZOOM_RTMS_EVENT_TTL_SECONDS + ? Number(process.env.ZOOM_RTMS_EVENT_TTL_SECONDS) + : 3600; +if (!Number.isFinite(zoomRtmsEventTtlSeconds) || zoomRtmsEventTtlSeconds <= 0) { + throw new Error('ZOOM_RTMS_EVENT_TTL_SECONDS must be a positive number'); +} + export default { port: process.env.PORT || 3000, db: { @@ -71,6 +83,18 @@ export default { googleChromeUserDataDir: process.env.GOOGLE_CHROME_USER_DATA_DIR, googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, zoomChromeCdpUrl: process.env.ZOOM_CHROME_CDP_URL, + zoomRecordingTransport: zoomRecordingTransport as 'browser' | 'rtms', + zoomRtms: { + clientId: process.env.ZOOM_RTMS_CLIENT_ID, + clientSecret: process.env.ZOOM_RTMS_CLIENT_SECRET, + webhookSecret: process.env.ZOOM_RTMS_WEBHOOK_SECRET, + oauthAccessToken: process.env.ZOOM_RTMS_OAUTH_ACCESS_TOKEN, + oauthAccountId: process.env.ZOOM_RTMS_OAUTH_ACCOUNT_ID, + oauthClientId: process.env.ZOOM_RTMS_OAUTH_CLIENT_ID, + oauthClientSecret: process.env.ZOOM_RTMS_OAUTH_CLIENT_SECRET, + participantUserId: process.env.ZOOM_RTMS_PARTICIPANT_USER_ID, + eventTtlSeconds: zoomRtmsEventTtlSeconds, + }, googleAnonymousJoinRequestAttempts: process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS ? Number(process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS) : 10, diff --git a/src/index.ts b/src/index.ts index 1ab49917..a79cd638 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import messageBroker from './connect/messageBroker'; import config from './config'; import { isPodMarkedForDeletion } from './util/k8sLifecycle'; import { loggerFactory } from './util/logger'; +import { zoomRtmsEventStore } from './rtms/ZoomRtmsEventStore'; const port = 3000; @@ -104,6 +105,7 @@ export const gracefulShutdownApp = () => { } else { console.log('Redis services not running - skipping Redis shutdown'); } + await zoomRtmsEventStore.close(); console.log('Exiting.....'); process.exit(0); diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index 84795040..1f66eae7 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -49,6 +49,7 @@ function isNoSuchUploadError(err: any, userId: string, logger: Logger): boolean export interface IUploader { uploadRecordingToRemoteStorage(options?: { forceUpload?: boolean }): Promise; saveDataToTempFile(data: Buffer): Promise; + useExternalFile(filePath: string, extension: string): Promise; setRecordingDuration(durationSeconds: number): void; } @@ -324,6 +325,48 @@ class DiskUploader implements IUploader { } } + public async useExternalFile(filePath: string, extension: string): Promise { + await this.waitForWritingFlag(); + if (this.queue.length > 0) { + throw new Error('Cannot replace the recording while disk writes are pending'); + } + + const normalizedExtension = extension.startsWith('.') ? extension : `.${extension}`; + const targetPath = DiskUploader.getFilePath( + this._userId, + this._tempFileId, + normalizedExtension + ); + const sourcePath = path.resolve(filePath); + const resolvedTarget = path.resolve(targetPath); + + if (sourcePath !== resolvedTarget) { + await fs.promises.unlink(resolvedTarget).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + try { + await fs.promises.rename(sourcePath, resolvedTarget); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== 'EXDEV') throw error; + await fs.promises.copyFile(sourcePath, resolvedTarget); + await fs.promises.unlink(sourcePath); + } + } + + const stats = await fs.promises.stat(resolvedTarget); + if (!stats.isFile() || stats.size === 0) { + throw new Error('External recording file is empty'); + } + + this.fileExtension = normalizedExtension; + this.contentType = extensionToContentType[normalizedExtension] ?? 'video/mp4'; + this.firstChunkReceivedAt = this.firstChunkReceivedAt ?? Date.now(); + this._logger.info('Using externally recorded media file for upload', { + extension: normalizedExtension, + size: stats.size, + }); + } + public setRecordingDuration(durationSeconds: number): void { if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { this._logger.warn('Ignoring invalid recording duration from recorder', { durationSeconds }); diff --git a/src/rtms/RtmsMediaRecorder.test.ts b/src/rtms/RtmsMediaRecorder.test.ts new file mode 100644 index 00000000..5981df63 --- /dev/null +++ b/src/rtms/RtmsMediaRecorder.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Logger } from 'winston'; +import config from '../config'; +import { boundMediaGapMilliseconds, RtmsMediaRecorder } from './RtmsMediaRecorder'; + +test('preserves media gaps longer than 60 seconds up to the recording limit', () => { + const twoMinuteGap = 120_000; + + assert.equal(boundMediaGapMilliseconds(twoMinuteGap), twoMinuteGap); + assert.equal( + boundMediaGapMilliseconds(config.maxRecordingDuration * 60_000 + 1), + config.maxRecordingDuration * 60_000 + ); +}); + +test('keeps a media gap longer than 60 seconds in the recorded timeline', async () => { + const logger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + } as unknown as Logger; + const recorder = await RtmsMediaRecorder.create(logger); + + try { + recorder.writeAudio(Buffer.alloc(3_200), 1_000_000); + recorder.writeAudio(Buffer.alloc(3_200), 1_065_000); + + const recording = await recorder.finalize(); + assert.ok(recording.durationSeconds >= 65); + } finally { + await recorder.cleanup(); + } +}); diff --git a/src/rtms/RtmsMediaRecorder.ts b/src/rtms/RtmsMediaRecorder.ts new file mode 100644 index 00000000..c00b9055 --- /dev/null +++ b/src/rtms/RtmsMediaRecorder.ts @@ -0,0 +1,331 @@ +import { execFile } from 'child_process'; +import fs, { WriteStream } from 'fs'; +import path from 'path'; +import { promisify } from 'util'; +import { Logger } from 'winston'; +import config from '../config'; + +const execFileAsync = promisify(execFile); +const AUDIO_BYTES_PER_MILLISECOND = 16_000 * 2 / 1000; +const VIDEO_FRAME_DURATION_MILLISECONDS = 40; +const INITIAL_VIDEO_BUFFER_LIMIT_MILLISECONDS = 60_000; +const MAX_PENDING_VIDEO_BYTES = 64 * 1024 * 1024; + +export const boundMediaGapMilliseconds = (milliseconds: number): number => { + const maximum = config.maxRecordingDuration * 60_000; + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + throw new Error('Zoom RTMS media gap is invalid'); + } + if (!Number.isFinite(maximum) || maximum <= 0) { + throw new Error('MAX_RECORDING_DURATION_MINUTES must be a positive number'); + } + return Math.min(milliseconds, maximum); +}; + +interface PendingVideoPacket { + buffer: Buffer; + timestamp: number; +} + +const closeStream = (stream: WriteStream): Promise => new Promise((resolve, reject) => { + stream.once('error', reject); + stream.end(resolve); +}); + +const writeStream = (stream: WriteStream, data: Buffer): Promise => new Promise((resolve, reject) => { + const onError = (error: Error) => { + stream.off('drain', onDrain); + reject(error); + }; + const onDrain = () => { + stream.off('error', onError); + resolve(); + }; + + stream.once('error', onError); + if (stream.write(data)) { + stream.off('error', onError); + resolve(); + } else { + stream.once('drain', onDrain); + } +}); + +export class RtmsMediaRecorder { + private readonly audioPath: string; + private readonly videoPath: string; + private readonly outputPath: string; + private readonly audioStream: WriteStream; + private readonly videoStream: WriteStream; + private writeQueue: Promise = Promise.resolve(); + private writeError?: Error; + private audioBytes = 0; + private videoBytes = 0; + private videoFrames = 0; + private pendingVideoBytes = 0; + private readonly pendingVideoPackets: PendingVideoPacket[] = []; + private expectedAudioTimestamp?: number; + private expectedVideoTimestamp?: number; + private firstAudioTimestamp?: number; + private lastAudioTimestamp?: number; + private timelineStartTimestamp?: number; + + private constructor( + private readonly folderPath: string, + private readonly logger: Logger, + private readonly blackFrame: Buffer + ) { + this.audioPath = path.join(folderPath, 'audio.raw'); + this.videoPath = path.join(folderPath, 'video.h264'); + this.outputPath = path.join(folderPath, 'recording.mp4'); + this.audioStream = fs.createWriteStream(this.audioPath, { highWaterMark: 4 * 1024 * 1024 }); + this.videoStream = fs.createWriteStream(this.videoPath, { highWaterMark: 4 * 1024 * 1024 }); + const rememberWriteError = (error: Error) => { + this.writeError = this.writeError ?? error; + }; + this.audioStream.on('error', rememberWriteError); + this.videoStream.on('error', rememberWriteError); + } + + static async create(logger: Logger): Promise { + const root = path.join(process.cwd(), 'dist', '_tempvideo'); + await fs.promises.mkdir(root, { recursive: true }); + const folderPath = await fs.promises.mkdtemp(path.join(root, 'rtms-')); + const blackFrame = await RtmsMediaRecorder.createBlackFrame(folderPath, logger); + return new RtmsMediaRecorder(folderPath, logger, blackFrame); + } + + writeAudio(buffer: Buffer, timestamp: number): void { + const data = Buffer.from(buffer); + this.enqueue(async () => { + const pendingVideoStart = this.pendingVideoPackets[0]?.timestamp; + if (typeof this.firstAudioTimestamp !== 'number') { + this.timelineStartTimestamp = this.timelineStartTimestamp ?? Math.min( + timestamp, + pendingVideoStart ?? timestamp + ); + } + + const expectedTimestamp = this.expectedAudioTimestamp ?? this.timelineStartTimestamp; + if (typeof expectedTimestamp === 'number' && timestamp > expectedTimestamp + 20) { + const missingMilliseconds = this.capGap(timestamp - expectedTimestamp, 'audio'); + await this.writeSilence(missingMilliseconds); + } + + await writeStream(this.audioStream, data); + this.audioBytes += data.length; + const packetEnd = timestamp + data.length / AUDIO_BYTES_PER_MILLISECOND; + this.firstAudioTimestamp = this.firstAudioTimestamp ?? timestamp; + this.lastAudioTimestamp = Math.max(this.lastAudioTimestamp ?? packetEnd, packetEnd); + this.expectedAudioTimestamp = Math.max(this.expectedAudioTimestamp ?? packetEnd, packetEnd); + await this.flushPendingVideo(); + }); + } + + writeVideo(buffer: Buffer, timestamp: number): void { + const data = Buffer.from(buffer); + this.enqueue(async () => { + if ( + typeof this.firstAudioTimestamp !== 'number' + && typeof this.timelineStartTimestamp !== 'number' + ) { + this.pendingVideoPackets.push({ buffer: data, timestamp }); + this.pendingVideoBytes += data.length; + const bufferedMilliseconds = timestamp - this.pendingVideoPackets[0].timestamp; + if ( + bufferedMilliseconds < INITIAL_VIDEO_BUFFER_LIMIT_MILLISECONDS + && this.pendingVideoBytes < MAX_PENDING_VIDEO_BYTES + ) { + return; + } + + this.timelineStartTimestamp = this.pendingVideoPackets[0].timestamp; + this.logger.warn('Zoom RTMS audio did not arrive before the initial video buffer limit'); + await this.flushPendingVideo(); + return; + } + + await this.writeVideoPacket(data, timestamp); + }); + } + + async finalize(): Promise<{ filePath: string; durationSeconds: number }> { + await this.writeQueue; + if (this.writeError) throw this.writeError; + if (this.pendingVideoPackets.length > 0) { + this.timelineStartTimestamp = this.timelineStartTimestamp + ?? this.pendingVideoPackets[0].timestamp; + await this.flushPendingVideo(); + } + if ( + typeof this.lastAudioTimestamp === 'number' + && typeof this.expectedVideoTimestamp === 'number' + && this.lastAudioTimestamp > this.expectedVideoTimestamp + VIDEO_FRAME_DURATION_MILLISECONDS + ) { + await this.writeBlackFrames( + this.capGap(this.lastAudioTimestamp - this.expectedVideoTimestamp, 'video') + ); + } else if ( + typeof this.lastAudioTimestamp === 'number' + && typeof this.expectedVideoTimestamp === 'number' + && this.expectedVideoTimestamp > this.lastAudioTimestamp + 20 + ) { + await this.writeSilence( + this.capGap(this.expectedVideoTimestamp - this.lastAudioTimestamp, 'audio') + ); + } + + await Promise.all([closeStream(this.audioStream), closeStream(this.videoStream)]); + if (this.audioBytes === 0 && this.videoBytes === 0) { + throw new Error('Zoom RTMS stream ended without audio or video data'); + } + + const args = this.ffmpegArgs(); + this.logger.info('Muxing Zoom RTMS media into MP4', { + audioBytes: this.audioBytes, + videoBytes: this.videoBytes, + }); + await execFileAsync('ffmpeg', args, { maxBuffer: 10 * 1024 * 1024 }); + + const output = await fs.promises.stat(this.outputPath); + if (output.size === 0) throw new Error('Zoom RTMS muxer produced an empty recording'); + + const audioDuration = this.audioBytes / AUDIO_BYTES_PER_MILLISECOND; + const videoDuration = this.videoFrames * VIDEO_FRAME_DURATION_MILLISECONDS; + const durationSeconds = Math.max(1, Math.ceil(Math.max(audioDuration, videoDuration) / 1000)); + return { filePath: this.outputPath, durationSeconds }; + } + + async cleanup(): Promise { + await this.writeQueue; + this.audioStream.destroy(); + this.videoStream.destroy(); + await fs.promises.rm(this.folderPath, { recursive: true, force: true }); + } + + private enqueue(operation: () => Promise): void { + this.writeQueue = this.writeQueue.then(operation).catch((error: Error) => { + this.writeError = this.writeError ?? error; + }); + } + + private capGap(milliseconds: number, media: 'audio' | 'video'): number { + const boundedMilliseconds = boundMediaGapMilliseconds(milliseconds); + if (boundedMilliseconds === milliseconds) return milliseconds; + this.logger.warn('Capping an unexpectedly large Zoom RTMS media gap', { + media, + milliseconds, + cappedAt: boundedMilliseconds, + }); + return boundedMilliseconds; + } + + private async writeSilence(milliseconds: number): Promise { + let remainingBytes = Math.round(milliseconds * AUDIO_BYTES_PER_MILLISECOND); + const silence = Buffer.alloc(64 * 1024); + while (remainingBytes > 0) { + const bytes = Math.min(remainingBytes, silence.length); + await writeStream(this.audioStream, silence.subarray(0, bytes)); + this.audioBytes += bytes; + remainingBytes -= bytes; + } + } + + private async writeBlackFrames(milliseconds: number): Promise { + const frames = Math.floor(milliseconds / VIDEO_FRAME_DURATION_MILLISECONDS); + const batchSize = 100; + const fullBatch = Buffer.concat(Array(batchSize).fill(this.blackFrame)); + let remaining = frames; + while (remaining > 0) { + const currentBatchSize = Math.min(batchSize, remaining); + const data = currentBatchSize === batchSize + ? fullBatch + : Buffer.concat(Array(currentBatchSize).fill(this.blackFrame)); + await writeStream(this.videoStream, data); + this.videoBytes += data.length; + this.videoFrames += currentBatchSize; + remaining -= currentBatchSize; + } + } + + private async flushPendingVideo(): Promise { + if (this.pendingVideoPackets.length === 0) return; + const packets = this.pendingVideoPackets.splice(0); + this.pendingVideoBytes = 0; + for (const packet of packets) { + await this.writeVideoPacket(packet.buffer, packet.timestamp); + } + } + + private async writeVideoPacket(buffer: Buffer, timestamp: number): Promise { + const expectedTimestamp = this.expectedVideoTimestamp ?? this.timelineStartTimestamp; + if ( + typeof expectedTimestamp === 'number' + && timestamp > expectedTimestamp + VIDEO_FRAME_DURATION_MILLISECONDS * 2 + ) { + await this.writeBlackFrames(this.capGap(timestamp - expectedTimestamp, 'video')); + } + await writeStream(this.videoStream, buffer); + this.videoBytes += buffer.length; + this.videoFrames += 1; + this.expectedVideoTimestamp = Math.max( + this.expectedVideoTimestamp ?? 0, + timestamp + VIDEO_FRAME_DURATION_MILLISECONDS + ); + } + + private static async createBlackFrame( + folderPath: string, + logger: Logger + ): Promise { + const filePath = path.join(folderPath, 'black-frame.h264'); + try { + await execFileAsync('ffmpeg', [ + '-y', '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'color=c=black:s=1280x720:r=25', + '-frames:v', '1', '-c:v', 'libx264', '-profile:v', 'baseline', + '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'h264', filePath, + ], { maxBuffer: 1024 * 1024 }); + const frame = await fs.promises.readFile(filePath); + await fs.promises.unlink(filePath); + return frame; + } catch (error) { + await fs.promises.unlink(filePath).catch(() => undefined); + logger.error('Unable to create Zoom RTMS video gap frame', { error }); + throw new Error('Unable to initialize Zoom RTMS media muxing'); + } + } + + private ffmpegArgs(): string[] { + const common = ['-y', '-hide_banner', '-loglevel', 'error']; + const output = ['-movflags', '+faststart', this.outputPath]; + + if (this.audioBytes > 0 && this.videoBytes > 0) { + return [ + ...common, + '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', this.audioPath, + '-framerate', '25', '-f', 'h264', '-i', this.videoPath, + '-map', '1:v:0', '-map', '0:a:0', + '-c:v', 'copy', '-c:a', 'aac', + ...output, + ]; + } + + if (this.videoBytes > 0) { + return [ + ...common, + '-framerate', '25', '-f', 'h264', '-i', this.videoPath, + '-c:v', 'copy', + ...output, + ]; + } + + return [ + ...common, + '-f', 's16le', '-ar', '16000', '-ac', '1', '-i', this.audioPath, + '-f', 'lavfi', '-i', 'color=c=black:s=1280x720:r=25', + '-shortest', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', + ...output, + ]; + } +} diff --git a/src/rtms/ZoomRtmsApi.test.ts b/src/rtms/ZoomRtmsApi.test.ts new file mode 100644 index 00000000..04da6499 --- /dev/null +++ b/src/rtms/ZoomRtmsApi.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import axios from 'axios'; +import config from '../config'; +import { KnownError } from '../error'; +import { ZoomRtmsApi } from './ZoomRtmsApi'; + +const originalPatch = axios.patch; +const originalClientId = config.zoomRtms.clientId; +const originalAccessToken = config.zoomRtms.oauthAccessToken; + +test.beforeEach(() => { + config.zoomRtms.clientId = 'rtms-client'; + config.zoomRtms.oauthAccessToken = 'access-token'; +}); + +test.afterEach(() => { + axios.patch = originalPatch; + config.zoomRtms.clientId = originalClientId; + config.zoomRtms.oauthAccessToken = originalAccessToken; +}); + +const zoomError = (status: number, code?: number) => ({ + isAxiosError: true, + response: { + status, + data: { code, message: 'Zoom API error' }, + }, +}); + +test('retries transient start failures with bounded exponential backoff', async () => { + let now = 0; + let attempts = 0; + const waits: number[] = []; + const requestTimeouts: number[] = []; + const failures = [zoomError(400, 3000), zoomError(429), zoomError(503)]; + axios.patch = (async (_url, _body, requestConfig) => { + requestTimeouts.push(requestConfig?.timeout ?? 0); + const failure = failures[attempts++]; + if (failure) throw failure; + }) as typeof axios.patch; + + const api = new ZoomRtmsApi(async (milliseconds) => { + waits.push(milliseconds); + now += milliseconds; + }, () => now); + + await api.start('123456789', 8_000); + + assert.equal(attempts, 4); + assert.deepEqual(waits, [1_000, 2_000, 4_000]); + assert.deepEqual(requestTimeouts, [8_000, 7_000, 5_000, 1_000]); +}); + +test('stops retrying when the caller timeout is reached', async () => { + let now = 0; + let attempts = 0; + const waits: number[] = []; + axios.patch = (async () => { + attempts += 1; + throw zoomError(500); + }) as typeof axios.patch; + + const api = new ZoomRtmsApi(async (milliseconds) => { + waits.push(milliseconds); + now += milliseconds; + }, () => now); + + await assert.rejects(api.start('123456789', 2_500), KnownError); + assert.equal(attempts, 2); + assert.deepEqual(waits, [1_000, 1_500]); +}); + +test('does not retry fatal start failures or stop failures', async () => { + let attempts = 0; + let waits = 0; + axios.patch = (async () => { + attempts += 1; + throw zoomError(attempts === 1 ? 403 : 500); + }) as typeof axios.patch; + + const api = new ZoomRtmsApi(async () => { + waits += 1; + }); + + await assert.rejects(api.start('123456789', 10_000), KnownError); + await assert.rejects(api.stop('123456789'), KnownError); + assert.equal(attempts, 2); + assert.equal(waits, 0); +}); diff --git a/src/rtms/ZoomRtmsApi.ts b/src/rtms/ZoomRtmsApi.ts new file mode 100644 index 00000000..94053912 --- /dev/null +++ b/src/rtms/ZoomRtmsApi.ts @@ -0,0 +1,176 @@ +import axios, { AxiosError } from 'axios'; +import config from '../config'; +import { KnownError } from '../error'; + +type RtmsAction = 'start' | 'stop'; + +const REQUEST_TIMEOUT_MS = 15_000; +const START_RETRY_INITIAL_DELAY_MS = 1_000; +const START_RETRY_MAX_DELAY_MS = 10_000; + +interface CachedToken { + value: string; + expiresAt: number; +} + +interface ZoomApiErrorBody { + code?: number; + message?: string; + reason?: string; +} + +const asAxiosError = (error: unknown): AxiosError | undefined => + axios.isAxiosError(error) ? error : undefined; + +export class ZoomRtmsApi { + private cachedToken?: CachedToken; + + constructor( + private readonly wait: (milliseconds: number) => Promise = + (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + private readonly now: () => number = Date.now + ) {} + + async start(meetingId: string, retryTimeoutMs = 0): Promise { + const retryWindowMs = Number.isFinite(retryTimeoutMs) + ? Math.max(0, retryTimeoutMs) + : 0; + const deadline = this.now() + retryWindowMs; + let retryDelayMs = START_RETRY_INITIAL_DELAY_MS; + + while (true) { + try { + const remainingMs = deadline - this.now(); + const requestTimeoutMs = retryWindowMs > 0 + ? Math.max(1, Math.min(REQUEST_TIMEOUT_MS, remainingMs)) + : REQUEST_TIMEOUT_MS; + await this.sendStatusRequest(meetingId, 'start', requestTimeoutMs); + return; + } catch (error: unknown) { + const remainingMs = deadline - this.now(); + if (!this.isRetryableStartError(error) || remainingMs <= 0) { + throw this.toKnownError(error, 'start'); + } + + await this.wait(Math.min(retryDelayMs, remainingMs)); + if (this.now() >= deadline) { + throw this.toKnownError(error, 'start'); + } + retryDelayMs = Math.min(retryDelayMs * 2, START_RETRY_MAX_DELAY_MS); + } + } + } + + async stop(meetingId: string): Promise { + try { + await this.sendStatusRequest(meetingId, 'stop', REQUEST_TIMEOUT_MS); + } catch (error: unknown) { + throw this.toKnownError(error, 'stop'); + } + } + + private async sendStatusRequest( + meetingId: string, + action: RtmsAction, + timeout: number + ): Promise { + const clientId = config.zoomRtms.clientId; + if (!clientId) { + throw new KnownError('ZOOM_RTMS_CLIENT_ID is required for RTMS', false, 0); + } + + const accessToken = await this.getAccessToken(); + const settings: { client_id: string; participant_user_id?: string } = { + client_id: clientId, + }; + if (config.zoomRtms.participantUserId) { + settings.participant_user_id = config.zoomRtms.participantUserId; + } + + await axios.patch( + `https://api.zoom.us/v2/live_meetings/${encodeURIComponent(meetingId)}/rtms_app/status`, + { action, settings }, + { + headers: { Authorization: `Bearer ${accessToken}` }, + timeout, + } + ); + } + + private isRetryableStartError(error: unknown): boolean { + const axiosError = asAxiosError(error); + if (!axiosError) return false; + const status = Number(axiosError.response?.status); + const code = Number(axiosError.response?.data?.code); + return !axiosError.response + || code === 3000 + || status === 429 + || (status >= 500 && status < 600); + } + + private toKnownError(error: unknown, action: RtmsAction): KnownError { + if (error instanceof KnownError) return error; + + const axiosError = asAxiosError(error); + const status = axiosError?.response?.status; + const message = axiosError?.response?.data?.message + || (error instanceof Error ? error.message : undefined) + || 'Unknown Zoom API error'; + return new KnownError( + `Zoom RTMS ${action} failed${status ? ` (${status})` : ''}: ${message}`, + false, + 0 + ); + } + + private async getAccessToken(): Promise { + if (config.zoomRtms.oauthAccessToken) { + return config.zoomRtms.oauthAccessToken; + } + + if (this.cachedToken && this.cachedToken.expiresAt > Date.now()) { + return this.cachedToken.value; + } + + const { oauthAccountId, oauthClientId, oauthClientSecret } = config.zoomRtms; + if (!oauthAccountId || !oauthClientId || !oauthClientSecret) { + throw new KnownError( + 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the ZOOM_RTMS_OAUTH_ACCOUNT_ID/CLIENT_ID/CLIENT_SECRET settings', + false, + 0 + ); + } + + try { + const response = await axios.post<{ access_token: string; expires_in?: number }>( + 'https://zoom.us/oauth/token', + undefined, + { + auth: { username: oauthClientId, password: oauthClientSecret }, + params: { + grant_type: 'account_credentials', + account_id: oauthAccountId, + }, + timeout: 15_000, + } + ); + const expiresIn = Math.max(60, response.data.expires_in ?? 3600); + this.cachedToken = { + value: response.data.access_token, + expiresAt: Date.now() + (expiresIn - 30) * 1000, + }; + return this.cachedToken.value; + } catch (error: unknown) { + const axiosError = asAxiosError(error); + const status = axiosError?.response?.status; + const message = axiosError?.response?.data?.reason + || (error instanceof Error ? error.message : undefined) + || 'Unknown OAuth error'; + throw new KnownError( + `Unable to obtain Zoom RTMS OAuth token${status ? ` (${status})` : ''}: ${message}`, + false, + 0 + ); + } + } +} diff --git a/src/rtms/ZoomRtmsEventStore.test.ts b/src/rtms/ZoomRtmsEventStore.test.ts new file mode 100644 index 00000000..d10b56ae --- /dev/null +++ b/src/rtms/ZoomRtmsEventStore.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import config from '../config'; +import { ZoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { ZoomRtmsWebhookEvent } from './types'; + +const originalRedisEnabled = config.isRedisEnabled; + +test.beforeEach(() => { + config.isRedisEnabled = false; +}); + +test.afterEach(() => { + config.isRedisEnabled = originalRedisEnabled; +}); + +const startEvent = (eventTs: number): ZoomRtmsWebhookEvent => ({ + event: 'meeting.rtms_started', + event_ts: eventTs, + payload: { + meeting_uuid: 'meeting-uuid', + meeting_id: '12345678901', + operator_id: 'operator-id', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.zoom.us', + }, +}); + +test('routes initial and recovery start events to their respective queues', async () => { + const store = new ZoomRtmsEventStore(); + const initial = startEvent(1); + + assert.equal(await store.publish(initial), true); + assert.equal(await store.publish(initial), false); + assert.deepEqual(await store.waitForMeetingStart('12345678901', 1), initial); + + await store.markStreamActive('stream-id'); + const recovery = startEvent(2); + assert.equal(await store.publish(recovery), true); + assert.deepEqual(await store.waitForStreamEvent('stream-id', 1), recovery); +}); + +test('releases meeting reservations only for their owner', async () => { + const store = new ZoomRtmsEventStore(); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, 'operator-id'), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, 'operator-id'), false); + + await store.releaseMeeting('12345678901', 'owner-b', 'operator-id'); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, 'operator-id'), false); + + await store.releaseMeeting('12345678901', 'owner-a', 'operator-id'); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, 'operator-id'), true); +}); diff --git a/src/rtms/ZoomRtmsEventStore.ts b/src/rtms/ZoomRtmsEventStore.ts new file mode 100644 index 00000000..0cf1e2f2 --- /dev/null +++ b/src/rtms/ZoomRtmsEventStore.ts @@ -0,0 +1,298 @@ +import crypto from 'crypto'; +import { createClient } from 'redis'; +import config from '../config'; +import { normalizeZoomMeetingId } from './utils'; +import { ZoomRtmsWebhookEvent } from './types'; + +type RedisClient = ReturnType; +type LocalWaiter = (event: ZoomRtmsWebhookEvent | null) => void; + +const PREFIX = 'meeting-bot:zoom:rtms'; + +export class ZoomRtmsEventStore { + private commandClient?: RedisClient; + private blockingClient?: RedisClient; + private connectPromise?: Promise; + private readonly localQueues = new Map(); + private readonly localWaiters = new Map(); + private readonly localActiveStreams = new Set(); + private readonly localDedupe = new Set(); + private readonly localReservations = new Map(); + + private meetingQueue(meetingId: string): string { + return `${PREFIX}:meeting:${meetingId}:events`; + } + + private streamQueue(streamId: string): string { + return `${PREFIX}:stream:${streamId}:events`; + } + + private activeStreamKey(streamId: string): string { + return `${PREFIX}:stream:${streamId}:active`; + } + + private reservationKey(meetingId: string, operatorId?: string): string { + const operator = operatorId || 'token-user'; + const digest = crypto.createHash('sha256').update(operator).digest('hex'); + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${digest}:owner`; + } + + private dedupeKey(event: ZoomRtmsWebhookEvent): string { + const identity = [ + event.event, + event.event_ts ?? '', + event.payload.meeting_uuid, + event.payload.rtms_stream_id, + event.payload.server_urls ?? '', + event.payload.stop_reason ?? '', + ].join(':'); + const digest = crypto + .createHash('sha256') + .update(identity) + .digest('hex'); + return `${PREFIX}:event:${digest}`; + } + + private async connect(): Promise { + if (!config.isRedisEnabled) return; + if (this.commandClient?.isReady && this.blockingClient?.isReady) return; + + if (!this.connectPromise) { + this.commandClient = createClient({ url: config.redisUri, name: 'zoom-rtms-events' }); + this.blockingClient = createClient({ url: config.redisUri, name: 'zoom-rtms-blocking' }); + this.commandClient.on('error', (error) => console.error('Zoom RTMS Redis error', error)); + this.blockingClient.on('error', (error) => console.error('Zoom RTMS blocking Redis error', error)); + this.connectPromise = Promise.all([ + this.commandClient.connect(), + this.blockingClient.connect(), + ]).then(() => undefined).catch(async (error) => { + const openClients = [this.commandClient, this.blockingClient] + .filter((client): client is RedisClient => Boolean(client?.isOpen)); + await Promise.all(openClients.map((client) => client.disconnect())); + this.commandClient = undefined; + this.blockingClient = undefined; + this.connectPromise = undefined; + throw error; + }); + } + + await this.connectPromise; + } + + private getCommandClient(): RedisClient { + if (!this.commandClient) throw new Error('Zoom RTMS Redis client is unavailable'); + return this.commandClient; + } + + private getBlockingClient(): RedisClient { + if (!this.blockingClient) throw new Error('Zoom RTMS blocking Redis client is unavailable'); + return this.blockingClient; + } + + async publish(event: ZoomRtmsWebhookEvent): Promise { + const streamId = event.payload.rtms_stream_id; + if (!streamId) throw new Error('RTMS webhook is missing rtms_stream_id'); + + if (!config.isRedisEnabled) { + return this.publishLocally(event); + } + + await this.connect(); + const client = this.getCommandClient(); + const ttl = config.zoomRtms.eventTtlSeconds; + let queueKey: string; + if (event.event === 'meeting.rtms_started') { + const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); + const isActive = await client.exists(this.activeStreamKey(streamId)); + queueKey = isActive + ? this.streamQueue(streamId) + : this.meetingQueue(meetingId); + } else { + queueKey = this.streamQueue(streamId); + } + + const published = await client.sendCommand([ + 'EVAL', + 'if redis.call("SET", KEYS[1], "1", "EX", ARGV[2], "NX") then redis.call("RPUSH", KEYS[2], ARGV[1]); redis.call("EXPIRE", KEYS[2], ARGV[2]); return 1; end; return 0;', + '2', + this.dedupeKey(event), + queueKey, + JSON.stringify(event), + String(ttl), + ]); + return Number(published) === 1; + } + + async waitForMeetingStart( + meetingId: string, + timeoutSeconds: number + ): Promise { + return this.waitFor(this.meetingQueue(normalizeZoomMeetingId(meetingId)), timeoutSeconds); + } + + async waitForStreamEvent( + streamId: string, + timeoutSeconds: number + ): Promise { + return this.waitFor(this.streamQueue(streamId), timeoutSeconds); + } + + async markStreamActive(streamId: string): Promise { + if (!config.isRedisEnabled) { + this.localActiveStreams.add(streamId); + return; + } + + await this.connect(); + const activeTtl = Math.max( + config.zoomRtms.eventTtlSeconds, + config.maxRecordingDuration * 60 + 600 + ); + await this.getCommandClient().set( + this.activeStreamKey(streamId), + '1', + { EX: activeTtl } + ); + } + + async markStreamInactive(streamId: string): Promise { + if (!config.isRedisEnabled) { + this.localActiveStreams.delete(streamId); + return; + } + + await this.connect(); + await this.getCommandClient().del(this.activeStreamKey(streamId)); + } + + async reserveMeeting( + meetingId: string, + ownerId: string, + ttlSeconds: number, + operatorId?: string + ): Promise { + const key = this.reservationKey(meetingId, operatorId); + if (!config.isRedisEnabled) { + if (this.localReservations.has(key)) return false; + this.localReservations.set(key, ownerId); + return true; + } + + await this.connect(); + const result = await this.getCommandClient().set(key, ownerId, { + EX: Math.max(1, Math.ceil(ttlSeconds)), + NX: true, + }); + return result === 'OK'; + } + + async releaseMeeting( + meetingId: string, + ownerId: string, + operatorId?: string + ): Promise { + const key = this.reservationKey(meetingId, operatorId); + if (!config.isRedisEnabled) { + if (this.localReservations.get(key) === ownerId) { + this.localReservations.delete(key); + } + return; + } + + await this.connect(); + await this.getCommandClient().sendCommand([ + 'EVAL', + 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]); end; return 0;', + '1', + key, + ownerId, + ]); + } + + async close(): Promise { + const clients = [this.commandClient, this.blockingClient] + .filter((client): client is RedisClient => Boolean(client?.isOpen)); + await Promise.all(clients.map((client) => client.quit())); + this.commandClient = undefined; + this.blockingClient = undefined; + this.connectPromise = undefined; + } + + private async waitFor( + queueKey: string, + timeoutSeconds: number + ): Promise { + if (!config.isRedisEnabled) { + return this.waitForLocal(queueKey, timeoutSeconds); + } + + await this.connect(); + const result = await this.getBlockingClient().blPop( + queueKey, + Math.max(1, Math.ceil(timeoutSeconds)) + ); + return result ? JSON.parse(result.element) as ZoomRtmsWebhookEvent : null; + } + + private publishLocally(event: ZoomRtmsWebhookEvent): boolean { + const dedupeKey = this.dedupeKey(event); + if (this.localDedupe.has(dedupeKey)) return false; + this.localDedupe.add(dedupeKey); + const timer = setTimeout( + () => this.localDedupe.delete(dedupeKey), + config.zoomRtms.eventTtlSeconds * 1000 + ); + timer.unref(); + + const streamId = event.payload.rtms_stream_id; + let queueKey: string; + if (event.event === 'meeting.rtms_started') { + const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); + queueKey = this.localActiveStreams.has(streamId) + ? this.streamQueue(streamId) + : this.meetingQueue(meetingId); + } else { + queueKey = this.streamQueue(streamId); + } + + const waiter = this.localWaiters.get(queueKey)?.shift(); + if (waiter) { + waiter(event); + } else { + const queue = this.localQueues.get(queueKey) ?? []; + queue.push(event); + this.localQueues.set(queueKey, queue); + } + return true; + } + + private waitForLocal( + queueKey: string, + timeoutSeconds: number + ): Promise { + const queued = this.localQueues.get(queueKey)?.shift(); + if (queued) return Promise.resolve(queued); + + return new Promise((resolve) => { + const waiters = this.localWaiters.get(queueKey) ?? []; + let settled = false; + const finish = (event: ZoomRtmsWebhookEvent | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(event); + }; + waiters.push(finish); + this.localWaiters.set(queueKey, waiters); + + const timer = setTimeout(() => { + const current = this.localWaiters.get(queueKey) ?? []; + const index = current.indexOf(finish); + if (index >= 0) current.splice(index, 1); + finish(null); + }, Math.max(1, timeoutSeconds) * 1000); + }); + } +} + +export const zoomRtmsEventStore = new ZoomRtmsEventStore(); diff --git a/src/rtms/ZoomRtmsSdkClient.ts b/src/rtms/ZoomRtmsSdkClient.ts new file mode 100644 index 00000000..316ff168 --- /dev/null +++ b/src/rtms/ZoomRtmsSdkClient.ts @@ -0,0 +1,230 @@ +import { Logger } from 'winston'; +import { createRequire } from 'module'; +import config from '../config'; +import { KnownError } from '../error'; +import { ZoomRtmsPayload } from './types'; +import { RtmsMediaRecorder } from './RtmsMediaRecorder'; + +interface RtmsClient { + setAudioParams(params: Record): boolean; + setVideoParams(params: Record): boolean; + onJoinConfirm(callback: (reason: number) => void): boolean; + onLeave(callback: (reason: number) => void): boolean; + onAudioData(callback: (buffer: Buffer, size: number, timestamp: number) => void): boolean; + onVideoData(callback: (buffer: Buffer, size: number, timestamp: number) => void): boolean; + onMediaConnectionInterrupted(callback: (timestamp: number) => void): boolean; + join(params: Record): boolean; + leave(): boolean; +} + +interface RtmsSdk { + Client: new () => RtmsClient; + AudioContentType: { RAW_AUDIO: number }; + AudioCodec: { L16: number }; + AudioSampleRate: { SR_16K: number }; + AudioChannel: { MONO: number }; + AudioDataOption: { AUDIO_MIXED_STREAM: number }; + VideoContentType: { RAW_VIDEO: number }; + VideoCodec: { H264: number }; + VideoResolution: { HD: number }; + VideoDataOption: { VIDEO_SINGLE_ACTIVE_STREAM: number }; +} + +export type ZoomRtmsSdkConnectionIssue = + | { type: 'media_interrupted'; timestamp: number } + | { type: 'left'; reason: number }; + +interface ActiveClient { + client: RtmsClient; + suppressIssues: boolean; +} + +const isSupportedPlatform = (): boolean => + (process.platform === 'linux' && process.arch === 'x64') + || (process.platform === 'darwin' && process.arch === 'arm64'); + +const requireModule = createRequire(__filename); + +const loadSdk = (): RtmsSdk => { + if (!isSupportedPlatform()) { + throw new KnownError( + `Zoom RTMS SDK is not supported on ${process.platform}-${process.arch}`, + false, + 0 + ); + } + + try { + const module = requireModule('@zoom/rtms'); + const sdk = module.default as RtmsSdk; + if (!sdk || typeof sdk.Client !== 'function') throw new Error('Client export is missing'); + return sdk; + } catch (error: unknown) { + throw new KnownError( + `Unable to load Zoom RTMS SDK: ${error instanceof Error ? error.message : String(error)}`, + false, + 0 + ); + } +}; + +export const assertZoomRtmsSdkAvailable = (): void => { + loadSdk(); +}; + +export class ZoomRtmsSdkClient { + private activeClient?: ActiveClient; + private connectionIssue?: ZoomRtmsSdkConnectionIssue; + + constructor( + private readonly recorder: RtmsMediaRecorder, + private readonly logger: Logger + ) {} + + async connect(payload: ZoomRtmsPayload, timeoutMs = 30_000): Promise { + await this.close(); + this.connectionIssue = undefined; + + const clientId = config.zoomRtms.clientId; + const clientSecret = config.zoomRtms.clientSecret; + if (!clientId || !clientSecret) { + throw new KnownError( + 'ZOOM_RTMS_CLIENT_ID and ZOOM_RTMS_CLIENT_SECRET are required for RTMS', + false, + 0 + ); + } + if (!payload.meeting_uuid || !payload.rtms_stream_id || !payload.server_urls) { + throw new KnownError('Zoom RTMS start event is missing connection details', false, 0); + } + + const sdk = loadSdk(); + const client = new sdk.Client(); + const activeClient: ActiveClient = { client, suppressIssues: false }; + this.activeClient = activeClient; + const boundedTimeoutMs = Math.max(1_000, Math.min(30_000, timeoutMs)); + + const audioConfigured = client.setAudioParams({ + contentType: sdk.AudioContentType.RAW_AUDIO, + codec: sdk.AudioCodec.L16, + sampleRate: sdk.AudioSampleRate.SR_16K, + channel: sdk.AudioChannel.MONO, + dataOpt: sdk.AudioDataOption.AUDIO_MIXED_STREAM, + duration: 100, + frameSize: 1600, + }); + const videoConfigured = client.setVideoParams({ + contentType: sdk.VideoContentType.RAW_VIDEO, + codec: sdk.VideoCodec.H264, + resolution: sdk.VideoResolution.HD, + dataOpt: sdk.VideoDataOption.VIDEO_SINGLE_ACTIVE_STREAM, + fps: 25, + }); + if (!audioConfigured || !videoConfigured) { + await this.close(); + throw new KnownError('Unable to configure Zoom RTMS audio/video streams', false, 0); + } + + const audioCallbackSet = client.onAudioData((buffer, size, timestamp) => { + const data = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + this.recorder.writeAudio(data.subarray(0, size), timestamp); + }); + const videoCallbackSet = client.onVideoData((buffer, size, timestamp) => { + const data = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + this.recorder.writeVideo(data.subarray(0, size), timestamp); + }); + if (!audioCallbackSet || !videoCallbackSet) { + await this.close(); + throw new KnownError('Unable to register Zoom RTMS media callbacks', false, 0); + } + + try { + await new Promise((resolve, reject) => { + let settled = false; + let joined = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + error ? reject(error) : resolve(); + }; + const timeout = setTimeout( + () => finish(new Error('Timed out connecting to the Zoom RTMS stream')), + boundedTimeoutMs + ); + + const joinCallbackSet = client.onJoinConfirm((reason) => { + if (reason === 0) { + joined = true; + finish(); + } else { + finish(new Error(`Zoom RTMS SDK rejected the stream connection (${reason})`)); + } + }); + const leaveCallbackSet = client.onLeave((reason) => { + this.logger.info('Zoom RTMS SDK left the stream', { reason }); + if (!joined) { + finish(new Error(`Zoom RTMS SDK left before connecting (${reason})`)); + } else if ( + !activeClient.suppressIssues + && this.activeClient === activeClient + && !this.connectionIssue + ) { + this.connectionIssue = { type: 'left', reason }; + } + }); + const interruptionCallbackSet = client.onMediaConnectionInterrupted((timestamp) => { + this.logger.warn('Zoom RTMS media connection interrupted', { timestamp }); + if (!joined) { + finish(new Error('Zoom RTMS media connection was interrupted while connecting')); + } else if ( + !activeClient.suppressIssues + && this.activeClient === activeClient + && !this.connectionIssue + ) { + this.connectionIssue = { type: 'media_interrupted', timestamp }; + } + }); + + if (!joinCallbackSet || !leaveCallbackSet || !interruptionCallbackSet) { + finish(new Error('Unable to register Zoom RTMS connection callbacks')); + return; + } + + const joining = client.join({ + meeting_uuid: payload.meeting_uuid, + rtms_stream_id: payload.rtms_stream_id, + server_urls: payload.server_urls, + client: clientId, + secret: clientSecret, + timeout: boundedTimeoutMs, + pollInterval: 10, + is_verify_cert: 1, + }); + if (!joining) finish(new Error('Zoom RTMS SDK could not start the stream connection')); + }); + } catch (error) { + await this.close(); + throw error; + } + } + + takeConnectionIssue(): ZoomRtmsSdkConnectionIssue | undefined { + const issue = this.connectionIssue; + this.connectionIssue = undefined; + return issue; + } + + async close(): Promise { + const activeClient = this.activeClient; + this.activeClient = undefined; + this.connectionIssue = undefined; + if (!activeClient) return; + activeClient.suppressIssues = true; + try { + activeClient.client.leave(); + } catch (error) { + this.logger.warn('Unable to close Zoom RTMS SDK client cleanly', { error }); + } + } +} diff --git a/src/rtms/ZoomRtmsTransport.ts b/src/rtms/ZoomRtmsTransport.ts new file mode 100644 index 00000000..2be4e79a --- /dev/null +++ b/src/rtms/ZoomRtmsTransport.ts @@ -0,0 +1,453 @@ +import { Logger } from 'winston'; +import { randomUUID } from 'crypto'; +import { JoinParams } from '../bots/AbstractMeetBot'; +import config, { NODE_ENV } from '../config'; +import { KnownError } from '../error'; +import { BotStatus } from '../types'; +import { RtmsMediaRecorder } from './RtmsMediaRecorder'; +import { ZoomRtmsApi } from './ZoomRtmsApi'; +import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { + assertZoomRtmsSdkAvailable, + ZoomRtmsSdkClient, + ZoomRtmsSdkConnectionIssue, +} from './ZoomRtmsSdkClient'; +import { ZoomRtmsPayload, ZoomRtmsWebhookEvent } from './types'; +import { extractZoomMeetingId } from './utils'; + +const START_EVENT_CLOCK_SKEW_MS = 5_000; +const SIGNAL_RECONNECT_WINDOW_MS = 60_000; +const MEDIA_RECONNECT_WINDOW_MS = 30_000; +const STREAM_EVENT_POLL_SECONDS = 1; +const RECONNECT_DELAYS_MS = [3_000, 6_000, 12_000, 24_000, 30_000]; +const MAX_STREAM_RESTARTS = RECONNECT_DELAYS_MS.length; + +const isTransientStopReason = (reason?: number): boolean => + typeof reason === 'number' && ((reason >= 10 && reason <= 19) || reason === 24); + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const sleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export class ZoomRtmsTransport { + private readonly api = new ZoomRtmsApi(); + + constructor(private readonly logger: Logger) {} + + async record( + params: JoinParams, + pushState: (state: BotStatus) => void + ): Promise { + const meetingId = extractZoomMeetingId(params.url); + let recorder: RtmsMediaRecorder | undefined; + let client: ZoomRtmsSdkClient | undefined; + let streamId: string | undefined; + let lastStartPayload: ZoomRtmsPayload | undefined; + let terminalStopReceived = false; + let rtmsRequested = false; + let streamRestartAttempts = 0; + const reservationOwnerId = randomUUID(); + let meetingReserved = false; + + try { + if (!config.zoomRtms.clientId || !config.zoomRtms.clientSecret || !config.zoomRtms.webhookSecret) { + throw new KnownError( + 'ZOOM_RTMS_CLIENT_ID, ZOOM_RTMS_CLIENT_SECRET and ZOOM_RTMS_WEBHOOK_SECRET are required', + false, + 0 + ); + } + if (!config.isRedisEnabled && (NODE_ENV === 'production' || NODE_ENV === 'staging')) { + throw new KnownError( + 'Zoom RTMS requires REDIS_CONSUMER_ENABLED=true in multi-replica environments', + false, + 0 + ); + } + assertZoomRtmsSdkAvailable(); + recorder = await RtmsMediaRecorder.create(this.logger); + + const reservationTtlSeconds = + config.joinWaitTime * 60 + config.maxRecordingDuration * 60 + 600; + meetingReserved = await zoomRtmsEventStore.reserveMeeting( + meetingId, + reservationOwnerId, + reservationTtlSeconds, + config.zoomRtms.participantUserId + ); + if (!meetingReserved) { + throw new KnownError( + 'A Zoom RTMS recording is already active for this meeting and operator', + false, + 0 + ); + } + + this.logger.info('Requesting Zoom RTMS stream', { meetingId }); + const startRequestedAt = Date.now(); + const initialJoinDeadline = startRequestedAt + config.joinWaitTime * 60 * 1000; + rtmsRequested = true; + await this.api.start(meetingId, Math.max(0, initialJoinDeadline - Date.now())); + const initialEventWaitMs = initialJoinDeadline - Date.now(); + if (initialEventWaitMs <= 0) { + throw new KnownError('Timed out requesting the Zoom RTMS stream', false, 0); + } + + const startEvent = await this.waitForFreshStart( + meetingId, + startRequestedAt, + Math.ceil(initialEventWaitMs / 1000) + ); + if (!startEvent) { + throw new KnownError('Timed out waiting for a fresh Zoom RTMS start event', false, 0); + } + + streamId = startEvent.payload.rtms_stream_id; + lastStartPayload = startEvent.payload; + await zoomRtmsEventStore.markStreamActive(streamId); + client = new ZoomRtmsSdkClient(recorder, this.logger); + await this.connectWithBackoff( + client, + lastStartPayload, + 'initial RTMS connection', + this.eventDeadline(startEvent, SIGNAL_RECONNECT_WINDOW_MS), + true + ); + + pushState('joined'); + this.logger.info('Zoom RTMS recording started', { meetingId, streamId }); + + const recordingDeadline = Date.now() + config.maxRecordingDuration * 60 * 1000; + while (!terminalStopReceived) { + const remainingSeconds = Math.ceil((recordingDeadline - Date.now()) / 1000); + if (remainingSeconds <= 0) break; + + const event = await zoomRtmsEventStore.waitForStreamEvent( + streamId, + Math.min(STREAM_EVENT_POLL_SECONDS, remainingSeconds) + ); + + if (event?.event === 'meeting.rtms_stopped') { + rtmsRequested = false; + if (event.payload.stop_reason === 8) { + throw new KnownError( + 'Zoom RTMS consent was revoked; the recording was discarded', + false, + 0 + ); + } + + if (isTransientStopReason(event.payload.stop_reason)) { + if (streamRestartAttempts >= MAX_STREAM_RESTARTS) { + throw new Error( + `Zoom RTMS stream restart limit reached after stop reason ${event.payload.stop_reason}` + ); + } + streamRestartAttempts += 1; + + const stoppedStreamId = streamId; + this.logger.warn('Restarting terminated Zoom RTMS stream', { + meetingId, + streamId: stoppedStreamId, + stopReason: event.payload.stop_reason, + attempt: streamRestartAttempts, + maxAttempts: MAX_STREAM_RESTARTS, + }); + await client.close(); + await zoomRtmsEventStore.markStreamInactive(stoppedStreamId); + + const restartRequestedAt = Date.now(); + const restartJoinDeadline = Math.min( + recordingDeadline, + restartRequestedAt + config.joinWaitTime * 60 * 1000 + ); + rtmsRequested = true; + await this.api.start( + meetingId, + Math.max(0, restartJoinDeadline - Date.now()) + ); + const restartEventWaitMs = restartJoinDeadline - Date.now(); + if (restartEventWaitMs <= 0) { + throw new Error('Timed out requesting the Zoom RTMS stream restart'); + } + const restartedEvent = await this.waitForFreshStart( + meetingId, + restartRequestedAt, + Math.ceil(restartEventWaitMs / 1000) + ); + if (!restartedEvent) { + throw new Error('Timed out waiting for Zoom RTMS to restart'); + } + + streamId = restartedEvent.payload.rtms_stream_id; + lastStartPayload = restartedEvent.payload; + await zoomRtmsEventStore.markStreamActive(streamId); + await this.connectWithBackoff( + client, + lastStartPayload, + 'restarted RTMS stream', + Math.min( + recordingDeadline, + this.eventDeadline(restartedEvent, SIGNAL_RECONNECT_WINDOW_MS) + ), + true + ); + this.logger.info('Zoom RTMS stream restarted', { meetingId, streamId }); + continue; + } + + terminalStopReceived = true; + continue; + } + + if (event?.event === 'meeting.rtms_started') { + lastStartPayload = event.payload; + this.logger.warn('Zoom RTMS server changed; reconnecting immediately', { + meetingId, + streamId, + }); + await this.connectWithBackoff( + client, + lastStartPayload, + 'RTMS server failover', + Math.min( + recordingDeadline, + this.eventDeadline(event, SIGNAL_RECONNECT_WINDOW_MS) + ), + true + ); + continue; + } + + if (event?.event === 'meeting.rtms_interrupted') { + const reconnectPayload = this.mergeInterruptedPayload(lastStartPayload, event); + lastStartPayload = reconnectPayload; + this.logger.warn('Zoom RTMS signaling interrupted; reconnecting', { + meetingId, + streamId, + }); + await this.connectWithBackoff( + client, + reconnectPayload, + 'interrupted RTMS signaling connection', + Math.min( + recordingDeadline, + this.eventDeadline(event, SIGNAL_RECONNECT_WINDOW_MS) + ), + false + ); + continue; + } + + const connectionIssue = client.takeConnectionIssue(); + if (connectionIssue) { + if (!lastStartPayload) throw new Error('Zoom RTMS reconnect details are unavailable'); + const reconnectWindow = connectionIssue.type === 'media_interrupted' + ? MEDIA_RECONNECT_WINDOW_MS + : SIGNAL_RECONNECT_WINDOW_MS; + this.logger.warn('Zoom RTMS SDK connection lost; reconnecting the full stream', { + meetingId, + streamId, + issue: connectionIssue, + }); + await this.reconnectAfterSdkIssue( + client, + lastStartPayload, + connectionIssue, + Math.min(recordingDeadline, Date.now() + reconnectWindow) + ); + } + } + + if (!terminalStopReceived) { + this.logger.info('Maximum Zoom RTMS recording duration reached; stopping stream', { + meetingId, + streamId, + }); + await this.api.stop(meetingId); + rtmsRequested = false; + } + + await client.close(); + const recording = await recorder.finalize(); + params.uploader.setRecordingDuration(recording.durationSeconds); + await params.uploader.useExternalFile(recording.filePath, '.mp4'); + pushState('finished'); + this.logger.info('Zoom RTMS recording finalized', { + meetingId, + streamId, + durationSeconds: recording.durationSeconds, + }); + } catch (error: unknown) { + if (error instanceof KnownError) throw error; + throw new KnownError( + `Zoom RTMS recording failed: ${errorMessage(error)}`, + false, + 0 + ); + } finally { + await client?.close(); + if (streamId) { + await zoomRtmsEventStore.markStreamInactive(streamId).catch((error) => { + this.logger.warn('Unable to release Zoom RTMS stream ownership', { error, streamId }); + }); + } + if (rtmsRequested && !terminalStopReceived) { + await this.api.stop(meetingId).catch((error) => { + this.logger.warn('Unable to stop Zoom RTMS during cleanup', { error, meetingId }); + }); + } + await recorder?.cleanup().catch((error) => { + this.logger.warn('Unable to remove Zoom RTMS temporary files', { error }); + }); + if (meetingReserved) { + await zoomRtmsEventStore.releaseMeeting( + meetingId, + reservationOwnerId, + config.zoomRtms.participantUserId + ).catch((error) => { + this.logger.warn('Unable to release Zoom RTMS meeting reservation', { + error, + meetingId, + }); + }); + } + } + } + + private async waitForFreshStart( + meetingId: string, + requestedAt: number, + timeoutSeconds: number + ): Promise { + const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000; + + while (Date.now() < deadline) { + const event = await zoomRtmsEventStore.waitForMeetingStart( + meetingId, + Math.max(1, Math.ceil((deadline - Date.now()) / 1000)) + ); + if (!event) return null; + + const eventTimestamp = event.event_ts; + if ( + event.event !== 'meeting.rtms_started' + || typeof eventTimestamp !== 'number' + || eventTimestamp < requestedAt - START_EVENT_CLOCK_SKEW_MS + ) { + this.logger.warn('Ignoring stale Zoom RTMS start event', { + meetingId, + requestedAt, + eventTimestamp, + }); + continue; + } + + const expectedOperatorId = config.zoomRtms.participantUserId; + if ( + expectedOperatorId + && String(event.payload.operator_id ?? '') !== expectedOperatorId + ) { + this.logger.warn('Ignoring Zoom RTMS start event for another operator', { + meetingId, + expectedOperatorId, + operatorId: event.payload.operator_id, + }); + continue; + } + + return event; + } + + return null; + } + + private mergeInterruptedPayload( + lastStartPayload: ZoomRtmsPayload | undefined, + event: ZoomRtmsWebhookEvent + ): ZoomRtmsPayload { + if (!lastStartPayload) throw new Error('Zoom RTMS reconnect details are unavailable'); + return { + ...lastStartPayload, + ...event.payload, + server_urls: event.payload.server_urls ?? lastStartPayload.server_urls, + }; + } + + private eventDeadline(event: ZoomRtmsWebhookEvent, windowMs: number): number { + const eventTimestamp = typeof event.event_ts === 'number' ? event.event_ts : Date.now(); + return eventTimestamp + windowMs; + } + + private async reconnectAfterSdkIssue( + client: ZoomRtmsSdkClient, + payload: ZoomRtmsPayload, + issue: ZoomRtmsSdkConnectionIssue, + deadline: number + ): Promise { + const description = issue.type === 'media_interrupted' + ? 'interrupted RTMS media connection' + : `RTMS SDK leave (${issue.reason})`; + await this.connectWithBackoff(client, payload, description, deadline, false); + } + + private async connectWithBackoff( + client: ZoomRtmsSdkClient, + payload: ZoomRtmsPayload, + description: string, + deadline: number, + immediate: boolean + ): Promise { + let lastError: unknown; + let attempt = 0; + + if (immediate) { + attempt += 1; + try { + await this.connectBeforeDeadline(client, payload, deadline); + return; + } catch (error) { + lastError = error; + this.logger.warn(`Zoom ${description} attempt failed`, { + attempt, + error: errorMessage(error), + }); + } + } + + for (const delayMs of RECONNECT_DELAYS_MS) { + if (Date.now() + delayMs >= deadline) break; + await sleep(delayMs); + attempt += 1; + try { + await this.connectBeforeDeadline(client, payload, deadline); + return; + } catch (error) { + lastError = error; + this.logger.warn(`Zoom ${description} attempt failed`, { + attempt, + delayMs, + error: errorMessage(error), + }); + } + } + + throw new Error( + `Unable to restore ${description} within Zoom's reconnect window after ${attempt} attempts${ + lastError ? `: ${errorMessage(lastError)}` : '' + }` + ); + } + + private async connectBeforeDeadline( + client: ZoomRtmsSdkClient, + payload: ZoomRtmsPayload, + deadline: number + ): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs < 1_000) throw new Error('Zoom RTMS reconnect window expired'); + await client.connect(payload, Math.min(30_000, remainingMs)); + } +} diff --git a/src/rtms/types.ts b/src/rtms/types.ts new file mode 100644 index 00000000..d09904de --- /dev/null +++ b/src/rtms/types.ts @@ -0,0 +1,26 @@ +export type ZoomRtmsEventName = + | 'meeting.rtms_started' + | 'meeting.rtms_stopped' + | 'meeting.rtms_interrupted'; + +export interface ZoomRtmsPayload { + meeting_uuid: string; + meeting_id?: string | number; + operator_id?: string | number; + rtms_stream_id: string; + server_urls?: string; + stop_reason?: number; + [key: string]: unknown; +} + +export interface ZoomRtmsWebhookEvent { + event: ZoomRtmsEventName; + event_ts: number; + payload: ZoomRtmsPayload; +} + +export interface ZoomWebhookBody { + event?: string; + event_ts?: number; + payload?: Record; +} diff --git a/src/rtms/utils.test.ts b/src/rtms/utils.test.ts new file mode 100644 index 00000000..d6c8305d --- /dev/null +++ b/src/rtms/utils.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildZoomWebhookSignature, + extractZoomMeetingId, + verifyZoomWebhookSignature, +} from './utils'; + +test('extracts a meeting ID from Zoom join URLs', () => { + assert.equal( + extractZoomMeetingId('https://us05web.zoom.us/j/12345678901?pwd=secret'), + '12345678901' + ); + assert.equal( + extractZoomMeetingId('https://zoom.us/wc/join/123456789'), + '123456789' + ); +}); + +test('rejects non-Zoom meeting URLs', () => { + assert.throws(() => extractZoomMeetingId('https://example.com/j/123456789')); +}); + +test('verifies signed Zoom webhooks within the timestamp tolerance', () => { + const body = Buffer.from('{"event":"meeting.rtms_started"}'); + const timestamp = '1720000000'; + const secret = 'webhook-secret'; + const signature = buildZoomWebhookSignature(body, timestamp, secret); + + assert.equal(verifyZoomWebhookSignature({ + rawBody: body, + timestamp, + signature, + secret, + now: 1_720_000_100_000, + }), true); + assert.equal(verifyZoomWebhookSignature({ + rawBody: body, + timestamp, + signature, + secret, + now: 1_720_001_000_000, + }), false); +}); diff --git a/src/rtms/utils.ts b/src/rtms/utils.ts new file mode 100644 index 00000000..5468fef2 --- /dev/null +++ b/src/rtms/utils.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto'; + +const ZOOM_HOST_SUFFIXES = ['.zoom.us', '.zoom.com', '.zoomgov.com']; + +export const normalizeZoomMeetingId = (value: string | number): string => { + const meetingId = String(value).replace(/\D/g, ''); + if (!meetingId) { + throw new Error('Zoom meeting ID is missing or invalid'); + } + return meetingId; +}; + +export const extractZoomMeetingId = (meetingUrl: string): string => { + const url = new URL(meetingUrl); + const hostname = url.hostname.toLowerCase(); + const isZoomHost = hostname === 'zoom.us' + || hostname === 'zoom.com' + || hostname === 'zoomgov.com' + || ZOOM_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); + + if (!isZoomHost) { + throw new Error('RTMS requires a Zoom meeting URL'); + } + + const pathMatch = url.pathname.match(/\/(?:j|wc\/join)\/(\d+)/i); + const queryMeetingId = url.searchParams.get('confno'); + return normalizeZoomMeetingId(pathMatch?.[1] ?? queryMeetingId ?? ''); +}; + +export const buildZoomWebhookSignature = ( + rawBody: Buffer | string, + timestamp: string, + secret: string +): string => { + const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody; + const message = `v0:${timestamp}:${body}`; + return `v0=${crypto.createHmac('sha256', secret).update(message).digest('hex')}`; +}; + +export const verifyZoomWebhookSignature = ({ + rawBody, + timestamp, + signature, + secret, + now = Date.now(), + toleranceSeconds = 300, +}: { + rawBody?: Buffer; + timestamp?: string; + signature?: string; + secret?: string; + now?: number; + toleranceSeconds?: number; +}): boolean => { + if (!rawBody || !timestamp || !signature || !secret) return false; + + const timestampSeconds = Number(timestamp); + if (!Number.isFinite(timestampSeconds)) return false; + + const ageSeconds = Math.abs(Math.floor(now / 1000) - timestampSeconds); + if (ageSeconds > toleranceSeconds) return false; + + const expected = Buffer.from(buildZoomWebhookSignature(rawBody, timestamp, secret)); + const actual = Buffer.from(signature); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +}; + +export const buildZoomUrlValidationResponse = (plainToken: string, secret: string) => ({ + plainToken, + encryptedToken: crypto.createHmac('sha256', secret).update(plainToken).digest('hex'), +}); diff --git a/src/rtms/webhook.test.ts b/src/rtms/webhook.test.ts new file mode 100644 index 00000000..4eafca94 --- /dev/null +++ b/src/rtms/webhook.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Request, Response } from 'express'; +import config from '../config'; +import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { buildZoomWebhookSignature, buildZoomUrlValidationResponse } from './utils'; +import { handleZoomRtmsWebhook } from './webhook'; + +interface TestResponse { + statusCode: number; + body?: unknown; + status(code: number): TestResponse; + json(body: unknown): TestResponse; + sendStatus(code: number): TestResponse; +} + +const createResponse = (): TestResponse => ({ + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + sendStatus(code) { + this.statusCode = code; + return this; + }, +}); + +const send = async ({ + body, + timestamp, + signature, +}: { + body: Record; + timestamp?: string; + signature?: string; +}): Promise => { + const rawBody = Buffer.from(JSON.stringify(body)); + const req = { + body, + rawBody, + headers: { + 'x-zm-request-timestamp': timestamp, + 'x-zm-signature': signature, + }, + } as unknown as Request; + const res = createResponse(); + await handleZoomRtmsWebhook(req, res as unknown as Response); + return res; +}; + +test('validates Zoom challenges and signed RTMS events', async () => { + const originalSecret = config.zoomRtms.webhookSecret; + const originalRedisEnabled = config.isRedisEnabled; + config.zoomRtms.webhookSecret = 'webhook-secret'; + config.isRedisEnabled = false; + + try { + const challenge = await send({ + body: { + event: 'endpoint.url_validation', + payload: { plainToken: 'plain-token' }, + }, + }); + assert.equal(challenge.statusCode, 200); + assert.deepEqual( + challenge.body, + buildZoomUrlValidationResponse('plain-token', 'webhook-secret') + ); + + const timestamp = String(Math.floor(Date.now() / 1000)); + const body = { + event: 'meeting.rtms_started', + event_ts: Date.now(), + payload: { + meeting_uuid: 'meeting-uuid', + meeting_id: '12345678901', + operator_id: 'operator-id', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.zoom.us', + }, + }; + const signed = await send({ + body, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(body), + timestamp, + 'webhook-secret' + ), + }); + assert.equal(signed.statusCode, 204); + assert.equal( + (await zoomRtmsEventStore.waitForMeetingStart('12345678901', 1))?.payload.rtms_stream_id, + 'stream-id' + ); + + const rejected = await send({ + body, + timestamp, + signature: 'v0=invalid', + }); + assert.equal(rejected.statusCode, 401); + } finally { + config.zoomRtms.webhookSecret = originalSecret; + config.isRedisEnabled = originalRedisEnabled; + } +}); diff --git a/src/rtms/webhook.ts b/src/rtms/webhook.ts new file mode 100644 index 00000000..e918f1d0 --- /dev/null +++ b/src/rtms/webhook.ts @@ -0,0 +1,101 @@ +import express, { Request, Response } from 'express'; +import config from '../config'; +import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; +import { ZoomRtmsEventName, ZoomRtmsWebhookEvent, ZoomWebhookBody } from './types'; +import { + buildZoomUrlValidationResponse, + verifyZoomWebhookSignature, +} from './utils'; + +interface RawBodyRequest extends Request { + rawBody?: Buffer; +} + +const RTMS_EVENTS = new Set([ + 'meeting.rtms_started', + 'meeting.rtms_stopped', + 'meeting.rtms_interrupted', +]); + +export const captureRawBody = (req: Request, _res: unknown, buffer: Buffer): void => { + (req as RawBodyRequest).rawBody = Buffer.from(buffer); +}; + +const headerValue = (value: string | string[] | undefined): string | undefined => + Array.isArray(value) ? value[0] : value; + +const router = express.Router(); + +export const handleZoomRtmsWebhook = async (req: RawBodyRequest, res: Response) => { + const body = req.body as ZoomWebhookBody; + const secret = config.zoomRtms.webhookSecret; + + if (body.event === 'endpoint.url_validation') { + const plainToken = body.payload?.plainToken; + if (typeof plainToken !== 'string' || !secret) { + return res.status(503).json({ error: 'Zoom RTMS webhook is not configured' }); + } + return res.json(buildZoomUrlValidationResponse(plainToken, secret)); + } + + const verified = verifyZoomWebhookSignature({ + rawBody: req.rawBody, + timestamp: headerValue(req.headers['x-zm-request-timestamp']), + signature: headerValue(req.headers['x-zm-signature']), + secret, + }); + if (!verified) { + return res.status(401).json({ error: 'Invalid Zoom webhook signature' }); + } + + if (!body.event || !RTMS_EVENTS.has(body.event as ZoomRtmsEventName)) { + return res.sendStatus(204); + } + + const payload = body.payload; + if ( + typeof body.event_ts !== 'number' + || !Number.isFinite(body.event_ts) + || !payload + || typeof payload.meeting_uuid !== 'string' + || typeof payload.rtms_stream_id !== 'string' + ) { + return res.status(400).json({ error: 'Invalid Zoom RTMS webhook payload' }); + } + + if ( + body.event === 'meeting.rtms_started' + && ( + !['string', 'number'].includes(typeof payload.meeting_id) + || typeof payload.operator_id !== 'string' + || typeof payload.server_urls !== 'string' + ) + ) { + return res.status(400).json({ error: 'Invalid Zoom RTMS start event' }); + } + + if ( + body.event === 'meeting.rtms_stopped' + && (typeof payload.stop_reason !== 'number' || !Number.isFinite(payload.stop_reason)) + ) { + return res.status(400).json({ error: 'Invalid Zoom RTMS stop event' }); + } + + const event: ZoomRtmsWebhookEvent = { + event: body.event as ZoomRtmsEventName, + event_ts: body.event_ts, + payload: payload as ZoomRtmsWebhookEvent['payload'], + }; + + try { + await zoomRtmsEventStore.publish(event); + return res.sendStatus(204); + } catch (error) { + console.error('Unable to persist Zoom RTMS webhook event', error); + return res.status(503).json({ error: 'Zoom RTMS event queue unavailable' }); + } +}; + +router.post('/', handleZoomRtmsWebhook); + +export default router; From 3a88183692410bf0a2c3363f5404a1d3cbd8e424 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 15 Jul 2026 18:48:59 +0200 Subject: [PATCH 37/53] fix(docker): load RTMS C++ runtime --- Dockerfile.development | 1 + Dockerfile.production | 1 + 2 files changed, 2 insertions(+) diff --git a/Dockerfile.development b/Dockerfile.development index db59db31..79cd87b3 100644 --- a/Dockerfile.development +++ b/Dockerfile.development @@ -4,6 +4,7 @@ RUN cp "$(g++ -print-file-name=libstdc++.so.6)" /libstdc++.so.6 FROM node:22-bookworm COPY --from=rtms-cxx-runtime /libstdc++.so.6 /opt/rtms/libstdc++.so.6 +ENV LD_LIBRARY_PATH=/opt/rtms WORKDIR /usr/src/app diff --git a/Dockerfile.production b/Dockerfile.production index bcc1cf9f..2e41c4fd 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -5,6 +5,7 @@ RUN cp "$(g++ -print-file-name=libstdc++.so.6)" /libstdc++.so.6 FROM node:22-bookworm COPY --from=rtms-cxx-runtime /libstdc++.so.6 /opt/rtms/libstdc++.so.6 +ENV LD_LIBRARY_PATH=/opt/rtms # Set the working directory WORKDIR /usr/src/app From 902539eaff7f5d051f03813eae1ce4379ae3fcb4 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 15 Jul 2026 22:28:21 +0200 Subject: [PATCH 38/53] fix(zoom): finalize and recover RTMS stop reasons --- package-lock.json | 4 +- package.json | 2 +- src/rtms/ZoomRtmsTransport.test.ts | 28 +++++ src/rtms/ZoomRtmsTransport.ts | 163 +++++++++++++++++++---------- src/rtms/types.ts | 5 + 5 files changed, 141 insertions(+), 61 deletions(-) create mode 100644 src/rtms/ZoomRtmsTransport.test.ts diff --git a/package-lock.json b/package-lock.json index 09de96e7..24d12211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.4", + "version": "1.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.4", + "version": "1.3.5", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index e43ae383..fde5cef9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.4", + "version": "1.3.5", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/rtms/ZoomRtmsTransport.test.ts b/src/rtms/ZoomRtmsTransport.test.ts new file mode 100644 index 00000000..3836491f --- /dev/null +++ b/src/rtms/ZoomRtmsTransport.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + isTerminalSdkLeaveReason, + isTransientStopReason, +} from './ZoomRtmsTransport'; + +test('treats non-retryable Zoom SDK stop reasons as terminal', () => { + for (const reason of [1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 25, 26]) { + assert.equal(isTerminalSdkLeaveReason(reason), true, `reason ${reason}`); + } +}); + +test('keeps transient and unknown Zoom SDK leave reasons reconnectable', () => { + for (const reason of [0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 24, 27, 960]) { + assert.equal(isTerminalSdkLeaveReason(reason), false, `reason ${reason}`); + } +}); + +test('restarts the complete RTMS stream for every retryable Zoom stop reason', () => { + for (const reason of [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 24]) { + assert.equal(isTransientStopReason(reason), true, `reason ${reason}`); + } + + for (const reason of [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 25, 26, 27, 960]) { + assert.equal(isTransientStopReason(reason), false, `reason ${reason}`); + } +}); diff --git a/src/rtms/ZoomRtmsTransport.ts b/src/rtms/ZoomRtmsTransport.ts index 2be4e79a..c5168715 100644 --- a/src/rtms/ZoomRtmsTransport.ts +++ b/src/rtms/ZoomRtmsTransport.ts @@ -12,7 +12,7 @@ import { ZoomRtmsSdkClient, ZoomRtmsSdkConnectionIssue, } from './ZoomRtmsSdkClient'; -import { ZoomRtmsPayload, ZoomRtmsWebhookEvent } from './types'; +import { ZoomRtmsPayload, ZoomRtmsStopReason, ZoomRtmsWebhookEvent } from './types'; import { extractZoomMeetingId } from './utils'; const START_EVENT_CLOCK_SKEW_MS = 5_000; @@ -21,10 +21,17 @@ const MEDIA_RECONNECT_WINDOW_MS = 30_000; const STREAM_EVENT_POLL_SECONDS = 1; const RECONNECT_DELAYS_MS = [3_000, 6_000, 12_000, 24_000, 30_000]; const MAX_STREAM_RESTARTS = RECONNECT_DELAYS_MS.length; +const FIRST_KNOWN_STOP_REASON = 1; +const LAST_KNOWN_STOP_REASON = 26; -const isTransientStopReason = (reason?: number): boolean => +export const isTransientStopReason = (reason?: number): reason is number => typeof reason === 'number' && ((reason >= 10 && reason <= 19) || reason === 24); +export const isTerminalSdkLeaveReason = (reason: number): boolean => + reason >= FIRST_KNOWN_STOP_REASON + && reason <= LAST_KNOWN_STOP_REASON + && !isTransientStopReason(reason); + const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -120,6 +127,67 @@ export class ZoomRtmsTransport { this.logger.info('Zoom RTMS recording started', { meetingId, streamId }); const recordingDeadline = Date.now() + config.maxRecordingDuration * 60 * 1000; + const activeClient = client; + const restartTerminatedStream = async (stopReason: number): Promise => { + rtmsRequested = false; + if (streamRestartAttempts >= MAX_STREAM_RESTARTS) { + throw new Error( + `Zoom RTMS stream restart limit reached after stop reason ${stopReason}` + ); + } + streamRestartAttempts += 1; + + const stoppedStreamId = streamId; + if (!stoppedStreamId) throw new Error('Zoom RTMS stream ID is unavailable'); + this.logger.warn('Restarting terminated Zoom RTMS stream', { + meetingId, + streamId: stoppedStreamId, + stopReason, + attempt: streamRestartAttempts, + maxAttempts: MAX_STREAM_RESTARTS, + }); + await activeClient.close(); + await zoomRtmsEventStore.markStreamInactive(stoppedStreamId); + + const restartRequestedAt = Date.now(); + const restartJoinDeadline = Math.min( + recordingDeadline, + restartRequestedAt + config.joinWaitTime * 60 * 1000 + ); + rtmsRequested = true; + await this.api.start( + meetingId, + Math.max(0, restartJoinDeadline - Date.now()) + ); + const restartEventWaitMs = restartJoinDeadline - Date.now(); + if (restartEventWaitMs <= 0) { + throw new Error('Timed out requesting the Zoom RTMS stream restart'); + } + const restartedEvent = await this.waitForFreshStart( + meetingId, + restartRequestedAt, + Math.ceil(restartEventWaitMs / 1000) + ); + if (!restartedEvent) { + throw new Error('Timed out waiting for Zoom RTMS to restart'); + } + + streamId = restartedEvent.payload.rtms_stream_id; + lastStartPayload = restartedEvent.payload; + await zoomRtmsEventStore.markStreamActive(streamId); + await this.connectWithBackoff( + activeClient, + lastStartPayload, + 'restarted RTMS stream', + Math.min( + recordingDeadline, + this.eventDeadline(restartedEvent, SIGNAL_RECONNECT_WINDOW_MS) + ), + true + ); + this.logger.info('Zoom RTMS stream restarted', { meetingId, streamId }); + }; + while (!terminalStopReceived) { const remainingSeconds = Math.ceil((recordingDeadline - Date.now()) / 1000); if (remainingSeconds <= 0) break; @@ -131,7 +199,7 @@ export class ZoomRtmsTransport { if (event?.event === 'meeting.rtms_stopped') { rtmsRequested = false; - if (event.payload.stop_reason === 8) { + if (event.payload.stop_reason === ZoomRtmsStopReason.StreamRevoked) { throw new KnownError( 'Zoom RTMS consent was revoked; the recording was discarded', false, @@ -140,61 +208,7 @@ export class ZoomRtmsTransport { } if (isTransientStopReason(event.payload.stop_reason)) { - if (streamRestartAttempts >= MAX_STREAM_RESTARTS) { - throw new Error( - `Zoom RTMS stream restart limit reached after stop reason ${event.payload.stop_reason}` - ); - } - streamRestartAttempts += 1; - - const stoppedStreamId = streamId; - this.logger.warn('Restarting terminated Zoom RTMS stream', { - meetingId, - streamId: stoppedStreamId, - stopReason: event.payload.stop_reason, - attempt: streamRestartAttempts, - maxAttempts: MAX_STREAM_RESTARTS, - }); - await client.close(); - await zoomRtmsEventStore.markStreamInactive(stoppedStreamId); - - const restartRequestedAt = Date.now(); - const restartJoinDeadline = Math.min( - recordingDeadline, - restartRequestedAt + config.joinWaitTime * 60 * 1000 - ); - rtmsRequested = true; - await this.api.start( - meetingId, - Math.max(0, restartJoinDeadline - Date.now()) - ); - const restartEventWaitMs = restartJoinDeadline - Date.now(); - if (restartEventWaitMs <= 0) { - throw new Error('Timed out requesting the Zoom RTMS stream restart'); - } - const restartedEvent = await this.waitForFreshStart( - meetingId, - restartRequestedAt, - Math.ceil(restartEventWaitMs / 1000) - ); - if (!restartedEvent) { - throw new Error('Timed out waiting for Zoom RTMS to restart'); - } - - streamId = restartedEvent.payload.rtms_stream_id; - lastStartPayload = restartedEvent.payload; - await zoomRtmsEventStore.markStreamActive(streamId); - await this.connectWithBackoff( - client, - lastStartPayload, - 'restarted RTMS stream', - Math.min( - recordingDeadline, - this.eventDeadline(restartedEvent, SIGNAL_RECONNECT_WINDOW_MS) - ), - true - ); - this.logger.info('Zoom RTMS stream restarted', { meetingId, streamId }); + await restartTerminatedStream(event.payload.stop_reason); continue; } @@ -243,6 +257,39 @@ export class ZoomRtmsTransport { const connectionIssue = client.takeConnectionIssue(); if (connectionIssue) { + if ( + connectionIssue.type === 'left' + && connectionIssue.reason === ZoomRtmsStopReason.StreamRevoked + ) { + throw new KnownError( + 'Zoom RTMS consent was revoked; the recording was discarded', + false, + 0 + ); + } + + if ( + connectionIssue.type === 'left' + && isTerminalSdkLeaveReason(connectionIssue.reason) + ) { + terminalStopReceived = true; + rtmsRequested = false; + this.logger.info('Zoom RTMS stream ended; finalizing recording', { + meetingId, + streamId, + stopReason: connectionIssue.reason, + }); + continue; + } + + if ( + connectionIssue.type === 'left' + && isTransientStopReason(connectionIssue.reason) + ) { + await restartTerminatedStream(connectionIssue.reason); + continue; + } + if (!lastStartPayload) throw new Error('Zoom RTMS reconnect details are unavailable'); const reconnectWindow = connectionIssue.type === 'media_interrupted' ? MEDIA_RECONNECT_WINDOW_MS diff --git a/src/rtms/types.ts b/src/rtms/types.ts index d09904de..c433ef31 100644 --- a/src/rtms/types.ts +++ b/src/rtms/types.ts @@ -3,6 +3,11 @@ export type ZoomRtmsEventName = | 'meeting.rtms_stopped' | 'meeting.rtms_interrupted'; +export enum ZoomRtmsStopReason { + MeetingEnded = 6, + StreamRevoked = 8, +} + export interface ZoomRtmsPayload { meeting_uuid: string; meeting_id?: string | number; From 0a5e77be55077f30486d5576607862c4dfbac3b2 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 14:03:18 +0200 Subject: [PATCH 39/53] feat: add Chrome-first RTMS fallback --- .github/workflows/docker-build.yml | 66 +- Dockerfile.chrome-cdp | 5 +- README.md | 26 +- docker-compose.yml | 4 + package-lock.json | 1570 +++++++------------------- package.json | 10 +- scripts/smoke-chrome-cdp.mjs | 184 +++ scripts/start-chrome-cdp.sh | 75 +- src/app/common.ts | 17 + src/app/index.ts | 2 + src/bots/GoogleMeetBot.ts | 59 +- src/bots/MicrosoftTeamsBot.ts | 59 +- src/bots/ZoomBot.ts | 289 +++-- src/bots/zoomJoinState.test.ts | 30 + src/bots/zoomJoinState.ts | 58 + src/config.ts | 84 ++ src/error.ts | 39 + src/index.ts | 39 +- src/instrumentation.ts | 4 + src/lib/browserSession.test.ts | 166 +++ src/lib/browserSession.ts | 212 ++++ src/lib/chromium.test.ts | 50 + src/lib/chromium.ts | 555 +++++---- src/monitoring/sentry.test.ts | 345 ++++++ src/monitoring/sentry.ts | 495 ++++++++ src/rtms/ZoomRtmsApi.test.ts | 99 +- src/rtms/ZoomRtmsApi.ts | 59 +- src/rtms/ZoomRtmsCredentials.test.ts | 117 ++ src/rtms/ZoomRtmsCredentials.ts | 128 +++ src/rtms/ZoomRtmsEventStore.test.ts | 110 +- src/rtms/ZoomRtmsEventStore.ts | 232 +++- src/rtms/ZoomRtmsTransport.test.ts | 12 + src/rtms/ZoomRtmsTransport.ts | 67 +- src/rtms/types.ts | 20 + src/rtms/webhook.test.ts | 20 +- src/tasks/RecordingTask.ts | 23 + src/util/logger.ts | 64 +- 37 files changed, 3625 insertions(+), 1769 deletions(-) create mode 100644 scripts/smoke-chrome-cdp.mjs create mode 100644 src/bots/zoomJoinState.test.ts create mode 100644 src/bots/zoomJoinState.ts create mode 100644 src/instrumentation.ts create mode 100644 src/lib/browserSession.test.ts create mode 100644 src/lib/browserSession.ts create mode 100644 src/lib/chromium.test.ts create mode 100644 src/monitoring/sentry.test.ts create mode 100644 src/monitoring/sentry.ts create mode 100644 src/rtms/ZoomRtmsCredentials.test.ts create mode 100644 src/rtms/ZoomRtmsCredentials.ts diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 75a01585..e8b84be9 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -25,6 +25,21 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build application + run: npm run build + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -58,15 +73,6 @@ jobs: exit 1 fi - # Create and push git tag if it doesn't exist (only on main push) - - name: Create and push tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag ${{ env.TAG }} - git push origin ${{ env.TAG }} - # Always generate Docker tags for build, but only include version tag on main - name: Extract metadata id: meta @@ -90,6 +96,39 @@ jobs: type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} + - name: Build Chrome CDP smoke image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.chrome-cdp + platforms: linux/amd64 + load: true + tags: meeting-bot-chrome-cdp:smoke + cache-from: type=gha,scope=chrome-cdp + + - name: Smoke-test Chrome CDP sidecar + run: | + set -euo pipefail + container=meeting-bot-chrome-cdp-smoke + cleanup() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + docker logs "$container" || true + fi + docker rm -f "$container" >/dev/null 2>&1 || true + exit "$status" + } + trap cleanup EXIT + + docker run --detach \ + --name "$container" \ + --network host \ + --shm-size=1g \ + --env CHROME_NO_SANDBOX=true \ + meeting-bot-chrome-cdp:smoke + CHROME_CDP_CONTAINER="$container" node scripts/smoke-chrome-cdp.mjs + # Build and push on all branches - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -116,3 +155,12 @@ jobs: labels: ${{ steps.chrome-cdp-meta.outputs.labels }} cache-from: type=gha,scope=chrome-cdp cache-to: type=gha,mode=max,scope=chrome-cdp + + # Create and push the release tag only after both images and the smoke test succeed. + - name: Create and push tag + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag ${{ env.TAG }} + git push origin ${{ env.TAG }} diff --git a/Dockerfile.chrome-cdp b/Dockerfile.chrome-cdp index 4da61910..4f212b02 100644 --- a/Dockerfile.chrome-cdp +++ b/Dockerfile.chrome-cdp @@ -9,6 +9,9 @@ RUN apt-get update && \ dumb-init \ ffmpeg \ fonts-liberation \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + fonts-noto-core \ gnupg \ libasound2 \ libatk-bridge2.0-0 \ @@ -54,7 +57,7 @@ RUN chmod +x /usr/local/bin/start-chrome-cdp USER chrome ENV XDG_RUNTIME_DIR=/run/user/1001 -EXPOSE 9222 9223 +EXPOSE 9223 ENTRYPOINT ["dumb-init", "--"] CMD ["start-chrome-cdp"] diff --git a/README.md b/README.md index 2c999e47..441c5b6f 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Content-Type: application/json For Google Meet, you can optionally connect to an already-running Chrome via `GOOGLE_CHROME_CDP_URL`, or run the browser with a dedicated signed-in Google profile by setting `GOOGLE_CHROME_USER_DATA_DIR` or `GOOGLE_CHROME_STORAGE_STATE_PATH`. The CDP option is useful when the Docker browser is treated differently from a normal Chrome. If you use CDP, launch that Chrome with `--auto-accept-this-tab-capture` so recording can select the current Meet tab without a manual browser prompt. The Chrome window and virtual display should normally use `1280,800`; the bot then sets the page viewport to `1280x720`, leaving room for Chrome's top UI without clipping the Meet controls. -For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` at `http://127.0.0.1:9222`. The included `Dockerfile.chrome-cdp` builds a minimal Google Chrome + Xvfb CDP backend for that setup. +For Kubernetes, run Chrome as a sidecar container in the same pod and point `GOOGLE_CHROME_CDP_URL` and `ZOOM_CHROME_CDP_URL` at `http://127.0.0.1:9223`. The included `Dockerfile.chrome-cdp` builds a Google Chrome + Xvfb CDP backend for that setup. For **local testing**, `docker compose up --build` starts the `chrome-cdp` sidecar alongside the bot and wires `GOOGLE_CHROME_CDP_URL` to it automatically (via the sidecar's nginx proxy on `9223`, which rewrites the `Host` header so cross-container CDP works). If host port `6379` is already taken by another local Redis, start with `REDIS_PORT_HOST=6380 docker compose up --build`. Join a Meet that allows guests, make sure a second participant is present (the bot leaves if it's alone), then `POST /google/join` (see above) and follow `docker compose logs -f meeting-bot`. The recording **upload will fail** without configured storage credentials — that's expected; the join and recording are what this exercises. Set `GOOGLE_CHROME_CDP_URL=` (empty) to fall back to the bot's in-container Chromium. This sidecar joins as an anonymous guest; meetings that require sign-in need a signed-in profile (`GOOGLE_CHROME_USER_DATA_DIR`/`GOOGLE_CHROME_STORAGE_STATE_PATH`), not yet wired into the local compose. @@ -125,7 +125,21 @@ Content-Type: application/json } ``` -Zoom uses the browser recorder by default. `ZOOM_CHROME_CDP_URL` remains available for that transport. +Zoom uses the browser recorder by default. With `ZOOM_RTMS_FALLBACK_ENABLED=true`, only an explicit Zoom automated-bot rejection before admission can fall back to RTMS. Host rejection, sign-in requirements, lobby timeouts, browser crashes and recording failures never trigger that fallback. + +Fallback credentials are selected by the job's `teamId` from `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. Store S2S account credentials, not short-lived access tokens: + +```json +{ + "team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id" + } +} +``` To record through Zoom Realtime Media Streams instead, set `ZOOM_RECORDING_TRANSPORT=rtms` and configure a user-managed Zoom General app with RTMS enabled. Set its public HTTPS webhook endpoint to `POST /zoom/rtms/webhook` and subscribe to `meeting.rtms_started`, `meeting.rtms_stopped`, and `meeting.rtms_interrupted`. @@ -484,6 +498,14 @@ Notes: | `TEAMS_PREWARM_ENABLED` | Enable the extra Microsoft Teams warmup browser pass for environments that still show first-run dialogs | `false` | | `TEAMS_AUDIO_STABILIZATION_MS` | Delay before starting Microsoft Teams ffmpeg recording after joining | `1000` | | `GOOGLE_CHROME_CDP_URL` | Optional CDP endpoint for Google Meet joins, e.g. `http://host.docker.internal:9222`, to use an external Chrome instead of Docker Chrome. | - | +| `ZOOM_CHROME_CDP_URL` | Optional CDP endpoint for isolated Zoom browser contexts. | - | +| `ZOOM_RECORDING_TRANSPORT` | Primary Zoom transport (`browser` or `rtms`). | `browser` | +| `ZOOM_RTMS_FALLBACK_ENABLED` | Allow RTMS only after an explicit pre-join Zoom automated-bot block. | `false` | +| `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Team-keyed S2S OAuth credentials for the RTMS fallback. | - | +| `SENTRY_DSN` | Optional Sentry DSN; monitoring is a complete no-op when omitted. | - | +| `SENTRY_ENVIRONMENT` | Sentry environment tag. | `NODE_ENV` | +| `SENTRY_RELEASE` | Sentry release tag. | - | +| `CHROME_NO_SANDBOX` | Keep `true` for the hardened v1.3.6 sidecar; Chrome's setuid sandbox cannot create its namespace with dropped capabilities. | `true` | | `GOOGLE_CHROME_USER_DATA_DIR` | Optional persistent Chrome profile directory for Google Meet joins. Use a dedicated signed-in Google account profile. | - | | `GOOGLE_CHROME_STORAGE_STATE_PATH` | Optional Playwright storage state JSON for Google Meet joins when not using a persistent profile. | - | | `GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS` | Number of times to re-submit an anonymous Google Meet guest request if Meet redirects while waiting for host admission. | `10` | diff --git a/docker-compose.yml b/docker-compose.yml index 1f2d3954..7f6e5195 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,10 @@ services: context: . dockerfile: Dockerfile.chrome-cdp shm_size: "1gb" + environment: + # Compose uses a separate container network; Kubernetes keeps the default + # loopback-only binding because app and sidecar share one pod network. + CHROME_CDP_PROXY_ADDRESS: "0.0.0.0" expose: - "9223" healthcheck: diff --git a/package-lock.json b/package-lock.json index 24d12211..b1f8add5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.5", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.5", + "version": "1.3.6", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", @@ -14,6 +14,7 @@ "@azure/identity": "^4.13.0", "@azure/storage-blob": "^12.29.1", "@google-cloud/storage": "^7.16.0", + "@sentry/node": "^10.66.0", "axios": "^1.7.7", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -21,12 +22,7 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.46", "playwright": "^1.45.3", - "playwright-extra": "^4.3.6", - "playwright-extra-plugin-stealth": "^0.0.1", "prom-client": "^15.1.3", - "puppeteer": "^22.15.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", "redis": "^5.8.1", "serialize-error": "^12.0.0", "ts-node": "^10.9.2", @@ -51,6 +47,49 @@ "@zoom/rtms": "1.1.0" } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.18.0.tgz", + "integrity": "sha512-aN3Oq8r1J3gPJtCwErP664gM0+HhM1I1lujPr9TMTCcEl/joQQbpGpeMdts9B1+W2wHMsvioDMv5F4PvMWE6gw==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.6.2.tgz", + "integrity": "sha512-5vBrtIEL+UVbO0YWWoyYG4QMgR+ZfnIL3xlteIkAmU7YaAPhc28k3md/NM14tnkfXKjKOn9yUzEA9AYUmNpvJg==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.13.0.tgz", + "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1279,40 +1318,6 @@ "node": ">=20.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", - "dependencies": { - "@babel/highlight": "^7.24.7", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1545,9 +1550,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", @@ -1597,14 +1603,118 @@ } }, "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, "engines": { "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/test": { "version": "1.45.3", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", @@ -1620,27 +1730,6 @@ "node": ">=18" } }, - "node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", - "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@redis/bloom": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", @@ -1701,6 +1790,120 @@ "@redis/client": "^5.8.1" } }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.66.0.tgz", + "integrity": "sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.66.0.tgz", + "integrity": "sha512-5Ow7iQiRjaSaEOmqEIkYV368hFFzShIZCXPoj+wX3JtOmKFrsNu6lr8/VJlQs7cyjFS6tzE50PrLrD8iAPS/8w==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0", + "@sentry/node-core": "10.66.0", + "@sentry/opentelemetry": "10.66.0", + "@sentry/server-utils": "10.66.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.66.0.tgz", + "integrity": "sha512-SUnXHROqSdSetKgZC1goDEKCuMz3OmQ1h4rxzWeexLyqan+pensZXfLouP6jzZXlA8e/HP7uQg78LnfqYDcZlQ==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0", + "@sentry/opentelemetry": "10.66.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.66.0.tgz", + "integrity": "sha512-K5Y9IettN9yIOnpqCs40HRLGqaGUoaQ50+ZsLqX2kPCk3TzJVpKZ9icKwaJ4Nxm72QgGVT5Ovfr/9FXbgd3b/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.66.0.tgz", + "integrity": "sha512-h9EM9Wz9Mc6w2Vn7Fyh6ozjy4JC2UbUvWf9Ra1HYA4KMeYqjFA5/o0oB4pg4w/TF+rb+9OF/HNqxjuczxpudpA==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.18.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", + "@apm-js-collab/tracing-hooks": "^0.13.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", @@ -2458,11 +2661,6 @@ "node": ">= 10" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2508,13 +2706,11 @@ "@types/node": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" }, "node_modules/@types/express": { "version": "4.17.21", @@ -2578,11 +2774,6 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, "node_modules/@types/node": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", @@ -2687,15 +2878,6 @@ "@types/node": "*" } }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -3026,21 +3208,11 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3062,15 +3234,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-flatten": { "version": "1.1.1", @@ -3096,15 +3261,13 @@ "node": ">=8" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" } }, "node_modules/async": { @@ -3139,56 +3302,11 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/b4a": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", - "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/bare-events": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz", - "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==", - "optional": true - }, - "node_modules/bare-fs": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.1.tgz", - "integrity": "sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==", - "optional": true, - "dependencies": { - "bare-events": "^2.0.0", - "bare-path": "^2.0.0", - "bare-stream": "^2.0.0" - } - }, - "node_modules/bare-os": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.0.tgz", - "integrity": "sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==", - "optional": true - }, - "node_modules/bare-path": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", - "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", - "optional": true, - "dependencies": { - "bare-os": "^2.1.0" - } - }, - "node_modules/bare-stream": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.1.3.tgz", - "integrity": "sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==", - "optional": true, - "dependencies": { - "streamx": "^2.18.0" - } + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/base64-js": { "version": "1.5.1", @@ -3209,14 +3327,6 @@ } ] }, - "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bignumber.js": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.1.tgz", @@ -3312,6 +3422,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3347,19 +3458,12 @@ "url": "https://feross.org/support" } ], + "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3424,23 +3528,11 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "engines": { "node": ">=6" } }, - "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3475,46 +3567,11 @@ "node": ">=18" } }, - "node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", - "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone-deep": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", - "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", - "dependencies": { - "for-own": "^0.1.3", - "is-plain-object": "^2.0.1", - "kind-of": "^3.0.2", - "lazy-cache": "^1.0.3", - "shallow-clone": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" }, "node_modules/cluster-key-slot": { "version": "1.1.2", @@ -3583,7 +3640,8 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -3617,31 +3675,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -3662,20 +3695,13 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", - "engines": { - "node": ">= 14" - } - }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3719,14 +3745,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/default-browser": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.3.0.tgz", @@ -3783,19 +3801,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3832,11 +3837,6 @@ "node": ">=8" } }, - "node_modules/devtools-protocol": { - "version": "0.0.1312386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz", - "integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==" - }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -3922,11 +3922,6 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -3949,22 +3944,6 @@ "once": "^1.4.0" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3982,6 +3961,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4009,47 +3994,11 @@ "node": ">= 0.4" } }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "engines": { - "node": ">=6" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -4281,23 +4230,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -4331,6 +4267,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -4431,25 +4368,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4457,11 +4375,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4521,14 +4434,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4662,25 +4567,6 @@ } } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", - "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/form-data": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", @@ -4734,7 +4620,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.2", @@ -4800,14 +4687,6 @@ "node": ">=14" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4845,34 +4724,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-uri": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", - "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4", - "fs-extra": "^11.2.0" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4885,6 +4736,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -5016,6 +4868,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } @@ -5174,6 +5027,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -5185,6 +5039,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", + "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5200,6 +5068,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -5217,18 +5086,6 @@ "license": "ISC", "optional": true }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5237,11 +5094,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5254,11 +5106,6 @@ "node": ">=8" } }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -5274,14 +5121,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5291,14 +5130,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5348,17 +5179,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5393,23 +5213,11 @@ "dev": true, "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5417,11 +5225,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -5438,11 +5241,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5542,31 +5340,12 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, - "node_modules/lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5581,11 +5360,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5668,12 +5442,13 @@ "node": ">= 12.0.0" } }, - "node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "engines": { - "node": ">=12" + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/make-error": { @@ -5698,19 +5473,6 @@ "node": ">= 0.6" } }, - "node_modules/merge-deep": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", - "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", - "dependencies": { - "arr-union": "^3.1.0", - "clone-deep": "^0.2.4", - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -5726,6 +5488,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -5795,6 +5566,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5835,31 +5607,6 @@ "node": ">= 18" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" - }, - "node_modules/mixin-object": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", - "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", - "dependencies": { - "for-in": "^0.1.3", - "is-extendable": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-object/node_modules/for-in": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", - "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -5867,6 +5614,12 @@ "license": "MIT", "optional": true }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -5889,9 +5642,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/napi-build-utils": { "version": "2.0.0", @@ -5922,14 +5676,6 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/node-abi": { "version": "3.94.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", @@ -6116,40 +5862,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pac-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", - "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.5", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -6157,23 +5874,6 @@ "node": ">=6" } }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6196,6 +5896,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6225,16 +5926,6 @@ "node": ">=8" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" - }, - "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -6275,34 +5966,6 @@ "node": ">=18" } }, - "node_modules/playwright-extra": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", - "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "playwright": "*", - "playwright-core": "*" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": true - }, - "playwright-core": { - "optional": true - } - } - }, - "node_modules/playwright-extra-plugin-stealth": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/playwright-extra-plugin-stealth/-/playwright-extra-plugin-stealth-0.0.1.tgz", - "integrity": "sha512-eI0Ujf4MXbcupzlVEXaaOnb+Exjt1sFi7t/3KxIA5pVww+WRAXRWdhqTz0glX62jJq2YM8fLu+GyvULpjTpZrw==" - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -6378,14 +6041,6 @@ "node": ">= 0.8.0" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prom-client": { "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", @@ -6411,24 +6066,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6444,6 +6081,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -6459,183 +6097,6 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", - "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", - "hasInstallScript": true, - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1312386", - "puppeteer-core": "22.15.0" - }, - "bin": { - "puppeteer": "lib/esm/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-extra": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", - "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "deepmerge": "^4.2.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "@types/puppeteer": "*", - "puppeteer": "*", - "puppeteer-core": "*" - }, - "peerDependenciesMeta": { - "@types/puppeteer": { - "optional": true - }, - "puppeteer": { - "optional": true - }, - "puppeteer-core": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", - "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", - "dependencies": { - "@types/debug": "^4.1.0", - "debug": "^4.1.1", - "merge-deep": "^3.0.1" - }, - "engines": { - "node": ">=9.11.2" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-stealth": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", - "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", - "dependencies": { - "debug": "^4.1.1", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-preferences": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", - "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^10.0.0", - "puppeteer-extra-plugin": "^3.2.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/puppeteer-extra-plugin-user-preferences": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", - "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", - "dependencies": { - "debug": "^4.1.1", - "deepmerge": "^4.2.2", - "puppeteer-extra-plugin": "^3.2.3", - "puppeteer-extra-plugin-user-data-dir": "^2.4.1" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -6671,11 +6132,6 @@ ], "license": "MIT" }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6766,18 +6222,24 @@ "node": ">= 18" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, "engines": { - "node": ">=0.10.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "engines": { "node": ">=4" } @@ -6821,6 +6283,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -6900,6 +6363,12 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -6947,11 +6416,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "node_modules/serialize-error": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz", @@ -7014,39 +6478,6 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "node_modules/shallow-clone": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", - "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", - "dependencies": { - "is-extendable": "^0.1.1", - "kind-of": "^2.0.1", - "lazy-cache": "^0.2.3", - "mixin-object": "^2.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", - "dependencies": { - "is-buffer": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/lazy-cache": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", - "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7171,55 +6602,15 @@ "node": ">=8" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", - "dependencies": { - "agent-base": "^7.1.1", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -7262,19 +6653,6 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, - "node_modules/streamx": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.18.0.tgz", - "integrity": "sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==", - "dependencies": { - "fast-fifo": "^1.3.2", - "queue-tick": "^1.0.1", - "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, "node_modules/string_decoder": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7284,23 +6662,11 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -7343,6 +6709,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -7367,29 +6734,6 @@ "node": ">=18" } }, - "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -7467,14 +6811,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/text-decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.1.1.tgz", - "integrity": "sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -7488,11 +6824,6 @@ "dev": true, "license": "MIT" }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7672,15 +7003,6 @@ "node": ">=14.17" } }, - "node_modules/unbzip2-stream": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", - "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", - "dependencies": { - "buffer": "^5.2.1", - "through": "^2.3.8" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -7718,11 +7040,6 @@ "punycode": "^2.1.0" } }, - "node_modules/urlpattern-polyfill": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", - "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7841,78 +7158,11 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -7928,14 +7178,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -7946,40 +7188,6 @@ "node": ">=18" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "engines": { - "node": ">=12" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -7999,14 +7207,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } } } } diff --git a/package.json b/package.json index fde5cef9..a53310e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.5", + "version": "1.3.6", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", @@ -9,7 +9,7 @@ "dev": "docker compose up --build", "stop": "docker compose down", "build": "tsc", - "test": "node --test -r ts-node/register/transpile-only src/rtms/*.test.ts", + "test": "node --test -r ts-node/register/transpile-only src/rtms/*.test.ts src/lib/*.test.ts src/bots/*.test.ts src/monitoring/*.test.ts", "lint": "eslint \"*/**/*.{js,ts}\" --quiet --fix" }, "engines": { @@ -34,6 +34,7 @@ "@azure/identity": "^4.13.0", "@azure/storage-blob": "^12.29.1", "@google-cloud/storage": "^7.16.0", + "@sentry/node": "^10.66.0", "axios": "^1.7.7", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -41,12 +42,7 @@ "moment": "^2.30.1", "moment-timezone": "^0.5.46", "playwright": "^1.45.3", - "playwright-extra": "^4.3.6", - "playwright-extra-plugin-stealth": "^0.0.1", "prom-client": "^15.1.3", - "puppeteer": "^22.15.0", - "puppeteer-extra": "^3.3.6", - "puppeteer-extra-plugin-stealth": "^2.11.2", "redis": "^5.8.1", "serialize-error": "^12.0.0", "ts-node": "^10.9.2", diff --git a/scripts/smoke-chrome-cdp.mjs b/scripts/smoke-chrome-cdp.mjs new file mode 100644 index 00000000..236d0590 --- /dev/null +++ b/scripts/smoke-chrome-cdp.mjs @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chromium } from 'playwright'; + +const cdpUrl = process.env.CHROME_CDP_URL ?? 'http://127.0.0.1:9223'; +const containerName = process.env.CHROME_CDP_CONTAINER; +const codec = 'video/webm;codecs=vp8,opus'; + +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function getCdpVersion(timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const response = await fetch(new URL('/json/version', cdpUrl), { + signal: AbortSignal.timeout(2_000), + }); + assert.equal(response.ok, true, `CDP returned HTTP ${response.status}`); + return await response.json(); + } catch (error) { + lastError = error; + await delay(500); + } + } + + throw new Error(`CDP did not become ready: ${lastError instanceof Error ? lastError.message : lastError}`); +} + +function assertXvfbSocket() { + if (!containerName) return; + + execFileSync('docker', [ + 'exec', + containerName, + 'test', + '-S', + process.env.CHROME_XVFB_SOCKET ?? '/tmp/.X11-unix/X99', + ], { stdio: 'inherit' }); +} + +async function recordCurrentTab(page) { + await page.goto(new URL('/json/version', cdpUrl).toString(), { waitUntil: 'domcontentloaded' }); + await page.setContent(` + + + + + + + + +
Chrome CDP media smoke test
+ + + + `); + + await page.click('#start'); + return page.evaluate(() => window.__chromeSmokeResult); +} + +async function main() { + const initialVersion = await getCdpVersion(); + assert.match(initialVersion.Browser ?? '', /Chrome\//); + assert.match(initialVersion.webSocketDebuggerUrl ?? '', /^ws:\/\//); + assertXvfbSocket(); + + const browser = await chromium.connectOverCDP(cdpUrl); + const context = await browser.newContext({ + locale: 'en-US', + viewport: { width: 1280, height: 720 }, + }); + const page = await context.newPage(); + + try { + const result = await recordCurrentTab(page); + assert.equal(result.error, undefined, result.error); + assert.equal(result.secureContext, true); + assert.equal(result.codecSupported, true, `${codec} is not supported`); + assert.equal(result.displaySurface, 'browser', 'Chrome did not capture the current tab'); + assert.equal(result.videoTrackCount, 1); + assert.equal(result.audioTrackCount, 1); + assert.equal(result.liveDuringRecording, true); + assert.ok(result.chunkCount > 0, 'MediaRecorder produced no chunks'); + assert.ok(result.blobSize > 0, 'MediaRecorder produced an empty recording'); + } finally { + await page.close().catch(() => undefined); + await context.close().catch(() => undefined); + } + + const finalVersion = await getCdpVersion(5_000); + assert.equal(finalVersion.Browser, initialVersion.Browser, 'Chrome did not survive meeting-context cleanup'); + console.log(`Chrome CDP smoke test passed (${finalVersion.Browser}, ${codec})`); +} + +main() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index b111e6f1..1c490b7d 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -5,10 +5,13 @@ export DISPLAY="${DISPLAY:-:99}" export CHROME_USER_DATA_DIR="${CHROME_USER_DATA_DIR:-/tmp/chrome-profile}" export CHROME_REMOTE_DEBUGGING_ADDRESS="${CHROME_REMOTE_DEBUGGING_ADDRESS:-127.0.0.1}" export CHROME_REMOTE_DEBUGGING_PORT="${CHROME_REMOTE_DEBUGGING_PORT:-9222}" -export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-0.0.0.0}" +export CHROME_CDP_PROXY_ADDRESS="${CHROME_CDP_PROXY_ADDRESS:-127.0.0.1}" export CHROME_CDP_PROXY_PORT="${CHROME_CDP_PROXY_PORT:-9223}" export CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1280,800}" export CHROME_URL="${CHROME_URL:-about:blank}" +export LANG="${LANG:-C.UTF-8}" +export LC_ALL="${LC_ALL:-C.UTF-8}" + CHROME_WINDOW_SIZE_FOR_CHROME="${CHROME_WINDOW_SIZE/x/,}" XVFB_SCREEN_SIZE="${CHROME_WINDOW_SIZE/,/x}" @@ -43,12 +46,6 @@ http { proxy_set_header Host 127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; - # Chrome reports its DevTools socket using the (rewritten) Host header, so - # /json* responses contain ws://127.0.0.1:${CHROME_REMOTE_DEBUGGING_PORT}/... - # which a remote client (e.g. the bot in another container) can't reach. - # Rewrite it back to the address the client used so connectOverCDP's second - # hop comes through this proxy. Disable upstream compression so sub_filter - # can see the body. proxy_set_header Accept-Encoding ""; sub_filter_types application/json; sub_filter_once off; @@ -68,31 +65,49 @@ cleanup() { kill "$chrome_pid" >/dev/null 2>&1 || true fi kill "$xvfb_pid" >/dev/null 2>&1 || true + wait "$nginx_pid" >/dev/null 2>&1 || true + if [ -n "${chrome_pid:-}" ]; then + wait "$chrome_pid" >/dev/null 2>&1 || true + fi + wait "$xvfb_pid" >/dev/null 2>&1 || true } trap cleanup EXIT INT TERM -google-chrome-stable \ - --remote-debugging-address="$CHROME_REMOTE_DEBUGGING_ADDRESS" \ - --remote-debugging-port="$CHROME_REMOTE_DEBUGGING_PORT" \ - --remote-allow-origins='*' \ - --user-data-dir="$CHROME_USER_DATA_DIR" \ - --window-size="$CHROME_WINDOW_SIZE_FOR_CHROME" \ - --window-position=0,0 \ - --force-device-scale-factor=1 \ - --auto-accept-this-tab-capture \ - --autoplay-policy=no-user-gesture-required \ - --use-gl=angle \ - --use-angle=swiftshader \ - --enable-unsafe-swiftshader \ - --ignore-gpu-blocklist \ - --no-first-run \ - --no-default-browser-check \ - --disable-dev-shm-usage \ - --disable-background-timer-throttling \ - --disable-backgrounding-occluded-windows \ - --disable-renderer-backgrounding \ - --no-sandbox \ - "$CHROME_URL" & +chrome_args=( + "--remote-debugging-address=$CHROME_REMOTE_DEBUGGING_ADDRESS" + "--remote-debugging-port=$CHROME_REMOTE_DEBUGGING_PORT" + "--user-data-dir=$CHROME_USER_DATA_DIR" + "--window-size=$CHROME_WINDOW_SIZE_FOR_CHROME" + '--window-position=0,0' + '--force-device-scale-factor=1' + '--lang=en-US' + '--auto-accept-this-tab-capture' + '--autoplay-policy=no-user-gesture-required' + '--no-first-run' + '--no-default-browser-check' + '--disable-background-timer-throttling' + '--disable-backgrounding-occluded-windows' + '--disable-renderer-backgrounding' +) + +if [ "${CHROME_SOFTWARE_GL:-true}" = 'true' ]; then + chrome_args+=( + '--use-gl=angle' + '--use-angle=swiftshader' + '--enable-unsafe-swiftshader' + ) +fi + +# Keep the v1.3.6 container-compatible default. The setuid sandbox cannot create +# its PID namespace under the hardened Kubernetes/Docker security context. +if [ "${CHROME_NO_SANDBOX:-true}" = 'true' ]; then + chrome_args+=( + '--no-sandbox' + '--disable-setuid-sandbox' + ) +fi + +google-chrome-stable "${chrome_args[@]}" "$CHROME_URL" & chrome_pid="$!" -wait -n "$nginx_pid" "$chrome_pid" +wait -n "$xvfb_pid" "$nginx_pid" "$chrome_pid" diff --git a/src/app/common.ts b/src/app/common.ts index b0f8b073..782507a1 100644 --- a/src/app/common.ts +++ b/src/app/common.ts @@ -3,6 +3,13 @@ import { KnownError, WaitingAtLobbyRetryError } from '../error'; import { getErrorType } from '../util/logger'; import config from '../config'; import { createMeetingFailedPayload, notifyMeetingFailed } from '../services/notificationService'; +import { + createSentryCorrelationId, + inferFallbackResult, + inferMeetingFailurePhase, + inferMeetingFailureTransport, + reportPermanentMeetingFailure, +} from '../monitoring/sentry'; export interface MeetingJoinParams { url: string; @@ -110,6 +117,16 @@ export const notifyMeetingJoinFailure = async ( error: unknown, logger: Logger ) => { + reportPermanentMeetingFailure(error, { + provider: params.provider, + transport: inferMeetingFailureTransport(error), + phase: inferMeetingFailurePhase(error), + fallbackResult: inferFallbackResult(error), + teamId: params.teamId, + eventId: params.eventId, + botId: params.botId, + correlationId: createSentryCorrelationId(params), + }); const payload = createMeetingFailedPayload(params, error); await notifyMeetingFailed(payload, logger); }; diff --git a/src/app/index.ts b/src/app/index.ts index 994381c6..7bbc3bbf 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -8,6 +8,7 @@ import zoomRouter from './zoom'; import { globalJobStore } from '../lib/globalJobStore'; import { RedisConsumerService } from '../connect/RedisConsumerService'; import { captureRawBody } from '../rtms/webhook'; +import { captureOperationalError } from '../monitoring/sentry'; const app = express(); @@ -84,6 +85,7 @@ export const getIsBusy = () => isbusy; if (config.isRedisEnabled) { redisConsumerService.start().catch((error) => { console.error('Failed to start Redis consumer service:', error); + captureOperationalError(error, { phase: 'redis_consumer_startup' }); }); } else { console.info('Redis consumer service not started - Redis is disabled'); diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index 71d108fb..bdc5b836 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -11,7 +11,11 @@ import { browserLogCaptureCallback } from '../util/logger'; import { getWaitingPromise } from '../lib/promise'; import { retryActionWithWait } from '../util/resilience'; import { uploadDebugImage } from '../services/bugService'; -import createBrowserContext, { isExternalBrowserContext } from '../lib/chromium'; +import createBrowserContext, { + closeBrowserSession, + normalizeBrowserSessionError, + raceBrowserSessionFailure, +} from '../lib/chromium'; import { GOOGLE_LOBBY_MODE_HOST_TEXT, GOOGLE_REQUEST_DENIED, GOOGLE_REQUEST_TIMEOUT } from '../constants'; import { getRecordingMimeTypesForExtension } from '../lib/recording'; import { getGoogleMeetDisplayName } from '../util/googleMeetDisplayName'; @@ -50,6 +54,7 @@ export class GoogleMeetBot extends MeetBotBase { await patchBotStatus({ botId, eventId, provider: 'google', status: _state, token: bearerToken }, this._logger); } catch(error) { + error = normalizeBrowserSessionError(this.page, error); if (!_state.includes('finished') && !_state.includes('failed')) _state.push('failed'); @@ -68,28 +73,18 @@ export class GoogleMeetBot extends MeetBotBase { // Guarantee chrome subprocess tree is reaped regardless of exit path. // No-op if a deeper code path already closed the browser. try { - const context = this.page?.context(); - const browser = context?.browser(); - if (isExternalBrowserContext(context)) { - await this.page?.close(); - this._logger.info('External browser page closed in join finally'); - } else if (browser?.isConnected()) { - await browser.close(); - this._logger.info('Browser closed in join finally'); - } else if (context) { - await context.close(); - this._logger.info('Persistent browser context closed in join finally'); - } + await closeBrowserSession(this.page); + this._logger.info('Browser session closed in join finally'); } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); } } } - private async joinMeeting({ url, name, teamId, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { + private async joinMeeting({ url, name, teamId, timezone, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { this._logger.info('Launching browser...'); - this.page = await createBrowserContext(url, this._correlationId, 'google'); + this.page = await createBrowserContext(url, this._correlationId, 'google', timezone); this._logger.info('Navigating to Google Meet URL...'); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); @@ -530,11 +525,7 @@ export class GoogleMeetBot extends MeetBotBase { } } catch(lobbyError) { this._logger.info('Closing the browser on error...', lobbyError); - if (isExternalBrowserContext(this.page.context())) { - await this.page.close(); - } else { - await this.page.context().browser()?.close(); - } + await closeBrowserSession(this.page); throw lobbyError; } @@ -1012,7 +1003,7 @@ export class GoogleMeetBot extends MeetBotBase { console.warn('Meet participant detection failed, retrying. Failure count:', detectionFailures); // Log for debugging if (detectionFailures >= maxDetectionFailures) { - console.log('Persistent detection failures:', { bodyText: `${document.body.innerText?.toString()}` }); + console.log('Persistent participant detection failures.'); loneTestDetectionActive = false; } retryWithBackoff(); @@ -1267,23 +1258,15 @@ export class GoogleMeetBot extends MeetBotBase { const processingTime = 0.2 * 60 * 1000; const waitingPromise: WaitPromise = getWaitingPromise(processingTime + duration); - waitingPromise.promise.then(async () => { - const context = this.page.context(); - // For an external CDP browser (the chrome-cdp sidecar), browser.close() only - // disconnects Playwright — it leaves the Meet tab open, so the bot stays in - // the call. Close the page (tab) instead, which leaves the meeting and keeps - // the shared sidecar Chrome alive for the next job. - if (isExternalBrowserContext(context)) { - this._logger.info('Closing the page (external CDP browser stays up)...'); - await this.page.close(); - } else { - this._logger.info('Closing the browser...'); - await context.browser()?.close(); - } - - this._logger.info('Recording stopped and meeting left; finalizing upload next...', { eventId, botId, userId, teamId }); - }); + try { + await raceBrowserSessionFailure(this.page, waitingPromise.promise); + } catch (error) { + waitingPromise.resolveEarly(); + throw error; + } - await waitingPromise.promise; + this._logger.info('Closing the browser session...'); + await closeBrowserSession(this.page); + this._logger.info('Recording stopped and meeting left; finalizing upload next...', { eventId, botId, userId, teamId }); } } diff --git a/src/bots/MicrosoftTeamsBot.ts b/src/bots/MicrosoftTeamsBot.ts index 2c121572..f10b249d 100644 --- a/src/bots/MicrosoftTeamsBot.ts +++ b/src/bots/MicrosoftTeamsBot.ts @@ -10,7 +10,11 @@ import { IUploader } from '../middleware/disk-uploader'; import { Logger } from 'winston'; import { retryActionWithWait } from '../util/resilience'; import { uploadDebugImage } from '../services/bugService'; -import createBrowserContext from '../lib/chromium'; +import createBrowserContext, { + closeBrowserSession, + normalizeBrowserSessionError, + raceBrowserSessionFailure, +} from '../lib/chromium'; import { browserLogCaptureCallback } from '../util/logger'; import { MICROSOFT_REQUEST_DENIED } from '../constants'; import { FFmpegRecorder } from '../lib/ffmpegRecorder'; @@ -57,6 +61,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { await patchBotStatus({ botId, eventId, provider: 'microsoft', status: _state, token: bearerToken }, this._logger); } catch(error) { + error = normalizeBrowserSessionError(this.page, error); // Log the actual error that occurred this._logger.error('Error in Microsoft Teams bot join process', { error: error instanceof Error ? error.message : String(error), @@ -81,21 +86,15 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Guarantee chrome subprocess tree is reaped regardless of exit path. // No-op if a deeper code path already closed the browser. try { - const browser = this.page?.context().browser(); - if (browser?.isConnected()) { - await browser.close(); - this._logger.info('Browser closed in join finally'); - } else if (this.page?.context()) { - await this.page.context().close(); - this._logger.info('Persistent browser context closed in join finally'); - } + await closeBrowserSession(this.page); + this._logger.info('Browser session closed in join finally'); } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); } } } - private async joinMeeting({ url, name, teamId, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { + private async joinMeeting({ url, name, teamId, timezone, userId, eventId, botId, pushState, uploader }: JoinParams & { pushState(state: BotStatus): void }): Promise { const joinButtonSelectors = [ 'button[aria-label="Join meeting from this browser"]', 'button[aria-label="Continue on this browser"]', @@ -131,7 +130,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { this._logger.info('Pre-warming: Opening browser to trigger first-run dialogs...'); let warmupPage: Page | undefined; try { - warmupPage = await createBrowserContext(url, this._correlationId, 'microsoft'); + warmupPage = await createBrowserContext(url, this._correlationId, 'microsoft', timezone); this._logger.info('Pre-warming: Navigating to Teams meeting...'); await warmupPage.goto(url, { waitUntil: 'domcontentloaded' }); await clickFirstVisibleSelector(warmupPage, joinButtonSelectors, 8000, 'Pre-warming'); @@ -143,11 +142,8 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Guarantee the warmup chrome tree is reaped even if the block above threw // mid-flight. join()'s outer finally only covers this.page, not warmupPage. try { - const browser = warmupPage?.context().browser(); - if (browser?.isConnected()) { - this._logger.info('Pre-warming: Closing warmup browser...'); - await browser.close(); - } + this._logger.info('Pre-warming: Closing warmup browser session...'); + await closeBrowserSession(warmupPage); } catch (cleanupErr) { this._logger.warn('Pre-warming: warmup browser cleanup failed (non-fatal)', { error: cleanupErr }); } @@ -159,7 +155,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Second run: Actual meeting join this._logger.info('Launching browser for actual meeting...'); - this.page = await createBrowserContext(url, this._correlationId, 'microsoft'); + this.page = await createBrowserContext(url, this._correlationId, 'microsoft', timezone); this._logger.info('Navigating to Microsoft Teams Meeting URL...'); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); @@ -307,7 +303,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess: false, bodyText }); this._logger.error('Closing the browser on error...', error); - await this.page.context().browser()?.close(); + await closeBrowserSession(this.page); // Don't retry lobby errors - if user doesn't admit bot, retrying won't help throw new WaitingAtLobbyRetryError('Microsoft Teams Meeting bot could not enter the meeting...', bodyText ?? '', false, 0); @@ -634,11 +630,11 @@ export class MicrosoftTeamsBot extends MeetBotBase { } const alonePhrases = [ - "you're the only one here", - "you’re the only one here", + 'you\'re the only one here', + 'you’re the only one here', 'you are the only one here', - "you're the only one in this meeting", - "you’re the only one in this meeting", + 'you\'re the only one in this meeting', + 'you’re the only one in this meeting', 'you are the only one in this meeting', 'only one in this meeting', 'only you are here', @@ -738,7 +734,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { return; } - const { count, samples } = getParticipantCount(); + const { count } = getParticipantCount(); let inferredCount = count; if (typeof inferredCount !== 'number') { if (meetingState === 'empty') { @@ -751,7 +747,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { if (typeof inferredCount !== 'number') { const now = Date.now(); if (now - lastParticipantDetectionLogAt > 30000) { - console.log('Teams participant count not detected yet', { samples }); + console.log('Teams participant count not detected yet.'); lastParticipantDetectionLogAt = now; } return; @@ -776,10 +772,15 @@ export class MicrosoftTeamsBot extends MeetBotBase { } ); - // Wait for either timeout, meeting end, or FFmpeg failure - while (!meetingEnded && !ffmpegFailed && (Date.now() - startedAt) < duration) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } + // Wait for either timeout, meeting end, FFmpeg failure, or browser-session failure. + // Race the whole monitoring period once so long meetings do not accumulate + // pending rejection handlers on BrowserSession.failure. + const monitorRecordingPeriod = async () => { + while (!meetingEnded && !ffmpegFailed && (Date.now() - startedAt) < duration) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + }; + await raceBrowserSessionFailure(this.page, monitorRecordingPeriod()); this._logger.info('Recording period ended', { meetingEnded, @@ -833,7 +834,7 @@ export class MicrosoftTeamsBot extends MeetBotBase { // Close browser this._logger.info('Closing the browser...'); - await this.page.context().browser()?.close(); + await closeBrowserSession(this.page); // Log final status. The real remote upload + true completion is logged in // join() after handleUpload: 'Recording and upload completed successfully'. diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 40d2c743..e73130f9 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -2,18 +2,28 @@ import { Frame, Page } from 'playwright'; import { JoinParams, AbstractMeetBot } from './AbstractMeetBot'; import { BotStatus, WaitPromise } from '../types'; import config from '../config'; -import { RecordingUploadFailedError, WaitingAtLobbyRetryError } from '../error'; +import { + RecordingUploadFailedError, + WaitingAtLobbyRetryError, + ZoomBrowserJoinBlockedError, + ZoomMeetingJoinError, +} from '../error'; import { v4 } from 'uuid'; import { patchBotStatus } from '../services/botService'; import { RecordingTask } from '../tasks/RecordingTask'; import { ContextBridgeTask } from '../tasks/ContextBridgeTask'; import { getWaitingPromise } from '../lib/promise'; -import createBrowserContext from '../lib/chromium'; +import createBrowserContext, { closeBrowserSession, getBrowserSession } from '../lib/chromium'; import { uploadDebugImage } from '../services/bugService'; import { Logger } from 'winston'; import { handleWaitingAtLobbyError } from './MeetBotBase'; -import { ZOOM_REQUEST_DENIED } from '../constants'; import { ZoomRtmsTransport } from '../rtms/ZoomRtmsTransport'; +import { + classifyZoomJoinState, + shouldUseZoomRtmsFallback, + ZoomJoinState, +} from './zoomJoinState'; +import { reportRecoveredZoomFallback } from '../monitoring/sentry'; class BotBase extends AbstractMeetBot { protected page: Page; @@ -32,6 +42,8 @@ class BotBase extends AbstractMeetBot { } export class ZoomBot extends BotBase { + private joinState: ZoomJoinState = 'prejoin'; + constructor(logger: Logger, correlationId: string) { super(logger, correlationId); } @@ -40,6 +52,20 @@ export class ZoomBot extends BotBase { // TODO Lift the JoinParams to the constructor argument async join({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }: JoinParams): Promise { const _state: BotStatus[] = ['processing']; + let recoveredBrowserError: ZoomBrowserJoinBlockedError | undefined; + let recordingTransport: 'browser' | 'rtms' = config.zoomRecordingTransport; + let fallbackResult = 'not_attempted'; + + const annotateFailure = (error: unknown, phase: string) => { + if (error && typeof error === 'object') { + Object.assign(error, { + transport: recordingTransport, + fallbackResult, + phase, + }); + } + return error; + }; const handleUpload = async () => { this._logger.info('Begin recording upload to server', { userId, teamId }); @@ -52,18 +78,68 @@ export class ZoomBot extends BotBase { const pushState = (st: BotStatus) => _state.push(st); const joinParams = { url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }; if (config.zoomRecordingTransport === 'rtms') { - await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); + try { + await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); + } catch (rtmsError) { + throw annotateFailure(rtmsError, 'recording'); + } } else { - await this.joinMeeting({ ...joinParams, pushState }); + try { + await this.joinMeeting({ ...joinParams, pushState }); + } catch (browserError) { + if (!shouldUseZoomRtmsFallback( + browserError, + config.zoomRtmsFallbackEnabled, + this.joinState + )) { + throw browserError; + } + + this._logger.warn('Zoom browser join was blocked before admission; trying RTMS fallback', { + teamId, + eventId, + botId, + }); + await closeBrowserSession(this.page); + + recordingTransport = 'rtms'; + fallbackResult = 'failed'; + try { + await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); + } catch (fallbackError) { + throw annotateFailure(fallbackError, 'fallback'); + } + + fallbackResult = 'recovered'; + recoveredBrowserError = browserError; + } } - await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); // Finish the upload from the temp video - const uploadResult = await handleUpload(); + let uploadResult: boolean; + try { + uploadResult = await handleUpload(); + } catch (uploadError) { + throw annotateFailure(uploadError, 'upload'); + } if (_state.includes('finished') && !uploadResult) { _state.splice(_state.indexOf('finished'), 1, 'failed'); - throw new RecordingUploadFailedError('Zoom recording completed but upload failed'); + throw annotateFailure( + new RecordingUploadFailedError('Zoom recording completed but upload failed'), + 'upload' + ); + } + + await patchBotStatus({ botId, eventId, provider: 'zoom', status: _state, token: bearerToken }, this._logger); + if (recoveredBrowserError) { + reportRecoveredZoomFallback({ + phase: 'fallback', + teamId, + eventId, + botId, + correlationId: this._correlationId, + }, recoveredBrowserError); } } catch(error) { if (!_state.includes('finished') && !_state.includes('failed')) @@ -77,17 +153,8 @@ export class ZoomBot extends BotBase { throw error; } finally { - // Guarantee chrome subprocess tree is reaped regardless of exit path. - // No-op if a deeper code path already closed the browser. try { - const browser = this.page?.context().browser(); - if (browser?.isConnected()) { - await browser.close(); - this._logger.info('Browser closed in join finally'); - } else if (this.page?.context()) { - await this.page.context().close(); - this._logger.info('Persistent browser context closed in join finally'); - } + await closeBrowserSession(this.page); } catch (cleanupErr) { this._logger.warn('Browser cleanup in join finally failed (non-fatal)', { error: cleanupErr }); } @@ -95,41 +162,78 @@ export class ZoomBot extends BotBase { } private async joinMeeting({ pushState, ...params }: JoinParams & { pushState(state: BotStatus): void }): Promise { - const { url, name } = params; + this.joinState = 'prejoin'; this._logger.info('Launching browser for Zoom...', { userId: params.userId }); + this.page = await createBrowserContext(params.url, this._correlationId, 'zoom', params.timezone); + const browserSession = getBrowserSession(this.page); + const joinWork = this.joinMeetingInBrowser({ ...params, pushState }); + + if (browserSession) { + await Promise.race([ + joinWork, + browserSession.failure.then(error => Promise.reject(error)), + ]); + return; + } - this.page = await createBrowserContext(url, this._correlationId, 'zoom'); + await joinWork; + } + + private async joinMeetingInBrowser({ pushState, ...params }: JoinParams & { pushState(state: BotStatus): void }): Promise { + const { url, name } = params; await this.page.route('**/*.exe', async (route) => { this._logger.info(`Detected .exe download: ${route.request().url()?.split('download')[0]}`); await route.abort(); }); + const readVisiblePageText = async (): Promise => { + const frameTexts = await Promise.all( + this.page.frames().map(frame => frame.locator('body').innerText({ timeout: 2_000 }).catch(() => '')) + ); + return frameTexts.join('\n'); + }; + + const applyJoinState = (bodyText: string): ZoomJoinState => { + const state = classifyZoomJoinState(bodyText); + if (state !== 'unknown') this.joinState = state; + if (state === 'automated_bot_blocked') { + throw new ZoomBrowserJoinBlockedError(); + } + if (state === 'host_rejected' || state === 'sign_in_required' || state === 'meeting_ended') { + throw new ZoomMeetingJoinError(state); + } + return state; + }; + + const inspectJoinState = async (): Promise => + applyJoinState(await readVisiblePageText()); + let usingDirectWebClient = false; const visitWebClientByUrl = async (): Promise => { usingDirectWebClient = true; try { const wcUrl = new URL(url); wcUrl.pathname = wcUrl.pathname.replace('/j/', '/wc/join/'); - this._logger.info('Navigating to Zoom Web Client URL...', { wcUrl: wcUrl.toString(), botId: params.botId, userId: params.userId }); + this._logger.info('Navigating to the Zoom Web Client fallback...', { + botId: params.botId, + userId: params.userId, + }); await this.page.goto(wcUrl.toString(), { waitUntil: 'domcontentloaded' }); + await inspectJoinState(); return true; } catch(err) { + if (err instanceof ZoomBrowserJoinBlockedError || err instanceof ZoomMeetingJoinError) { + throw err; + } usingDirectWebClient = false; this._logger.info('Failed to access ZOOM web client by URL', { botId: params.botId, userId: params.userId }); return false; } }; - if (config.zoomChromeCdpUrl) { - this._logger.info('Zoom CDP is enabled; navigating directly to the web client...'); - if (!await visitWebClientByUrl()) { - throw new Error('Unable to join meeting after trying to access the web client by /wc/join/'); - } - } else { - this._logger.info('Navigating to Zoom Meeting URL...'); - await this.page.goto(url, { waitUntil: 'domcontentloaded' }); - } + this._logger.info('Navigating to the original Zoom meeting URL...'); + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); // Accept cookies try { @@ -138,7 +242,7 @@ export class ZoomBot extends BotBase { await acceptCookies.waitFor({ timeout: 2500 }); this._logger.info('Clicking the "Accept Cookies" button...', await acceptCookies.count()); - await acceptCookies.click({ force: true }); + await acceptCookies.click(); } catch (error) { this._logger.info('Unable to accept cookies...', error); @@ -161,7 +265,7 @@ export class ZoomBot extends BotBase { await joinFromBrowser.waitFor({ timeout: 4000 }); if (await joinFromBrowser.isVisible({ timeout: 500 }).catch(() => false)) { - await joinFromBrowser.click({ force: true }); + await joinFromBrowser.click(); return true; } else { @@ -169,6 +273,7 @@ export class ZoomBot extends BotBase { return await findAndEnableJoinFromBrowserButton(retry + 1); } } catch(error) { + await inspectJoinState(); this._logger.info('Error on try find the web client', error); if (retry >= attempts) { return false; @@ -238,6 +343,7 @@ export class ZoomBot extends BotBase { } if (!foundAndClickedJoinFromBrowser || !navSuccess) { + await inspectJoinState(); await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'enable-join-from-browser', params.userId, this._logger, params.botId); this._logger.info('Failed to enable Join from your browser button...', params.userId); this._logger.info('Zoom Bot will now attempt to access the Web Client by URL...', params.userId); @@ -285,6 +391,7 @@ export class ZoomBot extends BotBase { return true; } catch(err) { + await inspectJoinState(); this._logger.info('Cannot detect the App container for Zoom Web Client', startWith, err); await uploadDebugImage(await this.page.screenshot({ type: 'png', fullPage: true }), 'detect-app-container', params.userId, this._logger, params.botId); return await detectAppContainer(startWith === 'app' ? 'iframe' : 'app'); @@ -294,6 +401,7 @@ export class ZoomBot extends BotBase { const foundAppContainer = await detectAppContainer(usingDirectWebClient ? 'app' : 'iframe'); if (!iframe || !foundAppContainer) { + await inspectJoinState(); throw new Error(`Failed to get the Zoom PWA iframe on user ${params.userId}`); } @@ -307,84 +415,43 @@ export class ZoomBot extends BotBase { const joinButton = iframe.locator('button', { hasText: 'Join' }).first(); await joinButton.waitFor({ timeout: 15000 }); await joinButton.click(); - - // Wait in waiting room - try { - const wanderingTime = config.joinWaitTime * 60 * 1000; // Give some time to be let in - - let waitTimeout: NodeJS.Timeout; - let waitInterval: NodeJS.Timeout; - const waitAtLobbyPromise = new Promise((resolveMe) => { - waitTimeout = setTimeout(() => { - clearInterval(waitInterval); - resolveMe(false); - }, wanderingTime); - - waitInterval = setInterval(async () => { - try { - const footerInfo = await iframe.locator('#wc-footer'); - await footerInfo.waitFor({ state: 'attached' }); - const footerText = await footerInfo?.innerText(); - - const tokens1 = footerText.split('\n'); - const tokens2 = footerText.split(' '); - const tokens = tokens1.length > tokens2.length ? tokens1 : tokens2; - - const filtered: string[] = []; - for (const tok of tokens) { - if (!tok) continue; - if (!Number.isNaN(Number(tok.trim()))) - filtered.push(tok); - else if (tok.trim().toLowerCase() === 'participants') { - filtered.push(tok.trim().toLowerCase()); - break; - } - } - const joinedText = filtered.join(''); - - if (joinedText === 'participants') - return; - - const isValid = joinedText.match(/\d+(.*)participants/i); - if (!isValid) { - return; - } - - const num = joinedText.match(/\d+/); - this._logger.info('Final Number of participants while waiting...', num); - if (num && Number(num[0]) === 0) - this._logger.info('Waiting on host...'); - else { - clearInterval(waitInterval); - clearTimeout(waitTimeout); - resolveMe(true); - } - } catch(e) { - // Do nothing - } - }, 2000); - }); - - const joined = await waitAtLobbyPromise; - if (!joined) { - const bodyText = await this.page.evaluate(() => document.body.innerText); - - const userDenied = (bodyText || '')?.includes(ZOOM_REQUEST_DENIED); - - this._logger.error('Cant finish wait at the lobby check', { userDenied, waitingAtLobbySuccess: joined, bodyText }); - - // Don't retry lobby errors - if user doesn't admit bot, retrying won't help - throw new WaitingAtLobbyRetryError('Zoom bot could not enter the meeting...', bodyText ?? '', false, 0); + this.joinState = 'waiting_room'; + + const lobbyDeadline = Date.now() + config.joinWaitTime * 60 * 1000; + let joined = false; + while (Date.now() < lobbyDeadline) { + const state = await inspectJoinState(); + if (state === 'waiting_room') this.joinState = state; + + const footerText = await iframe.locator('#wc-footer') + .innerText({ timeout: 1_500 }) + .catch(() => ''); + const participantCount = footerText.match(/(\d+)\s*participants?/i); + if (participantCount && Number(participantCount[1]) > 0) { + joined = true; + break; } - this._logger.info('Bot is entering the meeting after wait room...'); - } catch (error) { - this._logger.info('Closing the browser on error...', error); - await this.page.context().browser()?.close(); + await new Promise(resolve => setTimeout(resolve, 2_000)); + } - throw error; + if (!joined) { + await inspectJoinState(); + this._logger.warn('Zoom participant was not admitted before the lobby timeout', { + botId: params.botId, + userId: params.userId, + }); + throw new WaitingAtLobbyRetryError( + 'Zoom recording participant was not admitted before the lobby timeout', + '', + false, + 0 + ); } + this.joinState = 'joined'; + this._logger.info('Bot is entering the meeting after wait room...'); + // Wait for device notifications and close the notifications let notifyInternval: NodeJS.Timeout; let notifyTimeout: NodeJS.Timeout; @@ -525,12 +592,14 @@ export class ZoomBot extends BotBase { await recordingTask.runAsync(null); this._logger.info('Waiting for recording duration:', config.maxRecordingDuration, 'minutes...'); - waitingPromise.promise.then(async () => { - this._logger.info('Closing the browser...'); - await this.page.context().browser()?.close(); + await waitingPromise.promise; - this._logger.info('Recording stopped; finalizing upload next...', { botId, eventId, userId, teamId }); + this._logger.info('Recording stopped; closing the meeting browser context...', { + botId, + eventId, + userId, + teamId, }); - await waitingPromise.promise; + await closeBrowserSession(this.page); } } diff --git a/src/bots/zoomJoinState.test.ts b/src/bots/zoomJoinState.test.ts new file mode 100644 index 00000000..88ccafda --- /dev/null +++ b/src/bots/zoomJoinState.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ZoomBrowserJoinBlockedError } from '../error'; +import { classifyZoomJoinState, shouldUseZoomRtmsFallback } from './zoomJoinState'; + +test('classifies only an explicit Zoom automated-bot rejection as bot blocked', () => { + assert.equal( + classifyZoomJoinState('Automated bots aren\'t allowed to join this meeting. Sign in to join.'), + 'automated_bot_blocked' + ); + assert.equal(classifyZoomJoinState('Sign in to join this meeting'), 'sign_in_required'); +}); + +test('allows RTMS fallback only for an explicit pre-join automated-bot block', () => { + const blocked = new ZoomBrowserJoinBlockedError(); + + assert.equal(shouldUseZoomRtmsFallback(blocked, true, 'prejoin'), true); + assert.equal(shouldUseZoomRtmsFallback(blocked, false, 'prejoin'), false); + assert.equal(shouldUseZoomRtmsFallback(blocked, true, 'joined'), false); + assert.equal(shouldUseZoomRtmsFallback(new Error('automated bot blocked'), true, 'prejoin'), false); +}); + +test('classifies terminal Zoom join states', () => { + assert.equal(classifyZoomJoinState('You have been removed'), 'host_rejected'); + assert.equal(classifyZoomJoinState('This meeting has been ended by host'), 'meeting_ended'); + assert.equal( + classifyZoomJoinState('Please wait, the meeting host will let you in soon.'), + 'waiting_room' + ); +}); diff --git a/src/bots/zoomJoinState.ts b/src/bots/zoomJoinState.ts new file mode 100644 index 00000000..9c87c427 --- /dev/null +++ b/src/bots/zoomJoinState.ts @@ -0,0 +1,58 @@ +export type ZoomJoinState = + | 'prejoin' + | 'waiting_room' + | 'joined' + | 'host_rejected' + | 'sign_in_required' + | 'meeting_ended' + | 'automated_bot_blocked' + | 'unknown'; + +const normalize = (value?: string | null): string => + (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase(); + +export const classifyZoomJoinState = (bodyText?: string | null): ZoomJoinState => { + const text = normalize(bodyText); + if (!text) return 'unknown'; + + if ( + /automated bots? (?:are not|aren't) allowed to join/.test(text) + || /this meeting (?:does not|doesn't) allow automated bots?/.test(text) + ) { + return 'automated_bot_blocked'; + } + + if (text.includes('you have been removed')) return 'host_rejected'; + if ( + text.includes('this meeting has been ended by host') + || text.includes('this meeting has ended') + || text.includes('meeting has been ended') + ) { + return 'meeting_ended'; + } + if ( + text.includes('sign in to join') + || text.includes('you need to sign in to join this meeting') + ) { + return 'sign_in_required'; + } + if ( + text.includes('please wait, the meeting host will let you in soon') + || text.includes('the host will let you in soon') + ) { + return 'waiting_room'; + } + + return 'unknown'; +}; + +export const shouldUseZoomRtmsFallback = ( + error: unknown, + fallbackEnabled: boolean, + joinState: ZoomJoinState +): error is ZoomBrowserJoinBlockedError => + fallbackEnabled + && joinState !== 'joined' + && error instanceof ZoomBrowserJoinBlockedError + && error.reason === 'automated_bot_blocked'; +import { ZoomBrowserJoinBlockedError } from '../error'; diff --git a/src/config.ts b/src/config.ts index 09e30cbe..368bbd10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,82 @@ export const NODE_ENV: Environment = ENVIRONMENTS.includes( console.log('NODE_ENV', process.env.NODE_ENV); +export interface ZoomRtmsCustomerCredentials { + enabled: boolean; + accountId: string; + clientId: string; + clientSecret: string; + participantUserId: string; +} + +export interface ZoomRtmsCustomerCredentialsParseResult { + credentials: Record; + entryErrors: Record; + error?: string; +} + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' + && value !== null + && !Array.isArray(value); + +export const parseZoomRtmsCustomerCredentials = ( + rawValue?: string +): ZoomRtmsCustomerCredentialsParseResult => { + const credentials: Record = Object.create(null); + const entryErrors: Record = Object.create(null); + if (!rawValue?.trim()) return { credentials, entryErrors }; + + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + return { + credentials, + entryErrors, + error: 'ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON must contain valid JSON', + }; + } + + if (!isPlainObject(parsed)) { + return { + credentials, + entryErrors, + error: 'ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON must be an object keyed by teamId', + }; + } + + for (const [teamId, value] of Object.entries(parsed)) { + if (!teamId.trim() || !isPlainObject(value)) { + entryErrors[teamId] = 'customer credentials must be an object'; + continue; + } + + const invalidFields: string[] = []; + if (typeof value.enabled !== 'boolean') invalidFields.push('enabled'); + for (const field of ['accountId', 'clientId', 'clientSecret', 'participantUserId'] as const) { + if (typeof value[field] !== 'string' || !value[field].trim()) { + invalidFields.push(field); + } + } + + if (invalidFields.length > 0) { + entryErrors[teamId] = `invalid or missing fields: ${invalidFields.join(', ')}`; + continue; + } + + credentials[teamId] = { + enabled: value.enabled as boolean, + accountId: (value.accountId as string).trim(), + clientId: (value.clientId as string).trim(), + clientSecret: (value.clientSecret as string).trim(), + participantUserId: (value.participantUserId as string).trim(), + }; + } + + return { credentials, entryErrors }; +}; + const requiredSettings = [ 'GCP_DEFAULT_REGION', 'GCP_MISC_BUCKET', @@ -67,6 +143,10 @@ if (!Number.isFinite(zoomRtmsEventTtlSeconds) || zoomRtmsEventTtlSeconds <= 0) { throw new Error('ZOOM_RTMS_EVENT_TTL_SECONDS must be a positive number'); } +const zoomRtmsCustomerCredentials = parseZoomRtmsCustomerCredentials( + process.env.ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON +); + export default { port: process.env.PORT || 3000, db: { @@ -84,6 +164,7 @@ export default { googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, zoomChromeCdpUrl: process.env.ZOOM_CHROME_CDP_URL, zoomRecordingTransport: zoomRecordingTransport as 'browser' | 'rtms', + zoomRtmsFallbackEnabled: process.env.ZOOM_RTMS_FALLBACK_ENABLED === 'true', zoomRtms: { clientId: process.env.ZOOM_RTMS_CLIENT_ID, clientSecret: process.env.ZOOM_RTMS_CLIENT_SECRET, @@ -94,6 +175,9 @@ export default { oauthClientSecret: process.env.ZOOM_RTMS_OAUTH_CLIENT_SECRET, participantUserId: process.env.ZOOM_RTMS_PARTICIPANT_USER_ID, eventTtlSeconds: zoomRtmsEventTtlSeconds, + customerCredentials: zoomRtmsCustomerCredentials.credentials, + customerCredentialErrors: zoomRtmsCustomerCredentials.entryErrors, + customerCredentialsError: zoomRtmsCustomerCredentials.error, }, googleAnonymousJoinRequestAttempts: process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS ? Number(process.env.GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS) : diff --git a/src/error.ts b/src/error.ts index 5eec3e8c..a7ce5513 100644 --- a/src/error.ts +++ b/src/error.ts @@ -39,6 +39,45 @@ export class RecordingUploadFailedError extends KnownError { } } +export class ZoomBrowserJoinBlockedError extends KnownError { + public readonly reason = 'automated_bot_blocked' as const; + public readonly stage = 'prejoin' as const; + + constructor(message = 'Zoom blocked the browser as an automated meeting bot') { + super(message, false, 0); + this.name = 'ZoomBrowserJoinBlockedError'; + } +} + +export type ZoomMeetingJoinFailureReason = + | 'host_rejected' + | 'sign_in_required' + | 'meeting_ended'; + +export class ZoomMeetingJoinError extends KnownError { + public readonly stage = 'prejoin' as const; + + constructor(public readonly reason: ZoomMeetingJoinFailureReason) { + const messages: Record = { + host_rejected: 'The Zoom host rejected or removed the recording participant', + sign_in_required: 'Zoom requires the recording participant to sign in', + meeting_ended: 'The Zoom meeting ended before the recording participant joined', + }; + super(messages[reason], false, 0); + this.name = 'ZoomMeetingJoinError'; + } +} + +export class ZoomRtmsCredentialsMissingError extends KnownError { + public readonly teamId: string; + + constructor(teamId: string) { + super(`Zoom RTMS fallback is not configured for team ${teamId}`, false, 0); + this.name = 'ZoomRtmsCredentialsMissingError'; + this.teamId = teamId; + } +} + export class UnsupportedMeetingError extends KnownError { public googleMeetPageStatus: 'SIGN_IN_PAGE' | 'GOOGLE_MEET_PAGE' | 'UNSUPPORTED_PAGE' | null; diff --git a/src/index.ts b/src/index.ts index a79cd638..ef222ae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import './instrumentation'; // Ensure global Web Crypto API is available (needed by Azure SDK, polyfill for older Node versions) import './shims/crypto-polyfill'; import http from 'http'; @@ -8,6 +9,7 @@ import config from './config'; import { isPodMarkedForDeletion } from './util/k8sLifecycle'; import { loggerFactory } from './util/logger'; import { zoomRtmsEventStore } from './rtms/ZoomRtmsEventStore'; +import { captureOperationalError, flushSentry } from './monitoring/sentry'; const port = 3000; @@ -36,6 +38,7 @@ setTimeout(() => { }) .catch((err) => { console.error('Startup pod-deletion check threw (continuing normal startup):', err); + captureOperationalError(err, { phase: 'startup_pod_deletion_check' }); }); }, 10000); @@ -65,6 +68,8 @@ const initiateGracefulShutdown = async () => { gracefulShutdownApp(); } catch (error) { console.error('Error during graceful shutdown:', error); + captureOperationalError(error, { phase: 'graceful_shutdown' }); + await flushSentry(); // Force exit if graceful shutdown fails process.exit(1); } @@ -72,10 +77,14 @@ const initiateGracefulShutdown = async () => { process.on('uncaughtException', (err) => { console.error('Uncaught Exception:', err); + captureOperationalError(err, { phase: 'uncaught_exception' }); + void flushSentry(); }); -process.on('unhandledRejection', (reason, promise) => { +process.on('unhandledRejection', (reason) => { console.error('Unhandled Rejection:', reason); + captureOperationalError(reason, { phase: 'unhandled_rejection' }); + void flushSentry(); }); process.on('SIGTERM', () => { @@ -98,16 +107,24 @@ export const gracefulShutdownApp = () => { server.close(async () => { console.log('HTTP server closed. Exiting application'); - // Only shutdown Redis services if Redis is enabled - if (config.isRedisEnabled) { - await redisConsumerService.shutdown(); - await messageBroker.quitClientGracefully(); - } else { - console.log('Redis services not running - skipping Redis shutdown'); + let exitCode = 0; + try { + // Only shutdown Redis services if Redis is enabled + if (config.isRedisEnabled) { + await redisConsumerService.shutdown(); + await messageBroker.quitClientGracefully(); + } else { + console.log('Redis services not running - skipping Redis shutdown'); + } + await zoomRtmsEventStore.close(); + } catch (error) { + exitCode = 1; + console.error('Error while closing application resources:', error); + captureOperationalError(error, { phase: 'resource_shutdown' }); + } finally { + await flushSentry(); + console.log('Exiting.....'); + process.exit(exitCode); } - await zoomRtmsEventStore.close(); - - console.log('Exiting.....'); - process.exit(0); }); }; diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 00000000..f86e367a --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,4 @@ +import 'dotenv/config'; +import { initializeSentry } from './monitoring/sentry'; + +initializeSentry(); diff --git a/src/lib/browserSession.test.ts b/src/lib/browserSession.test.ts new file mode 100644 index 00000000..80944d6b --- /dev/null +++ b/src/lib/browserSession.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { Browser, BrowserContext, Page } from 'playwright'; +import { + closeBrowserSession, + getValidatedProviderOrigin, + normalizeBrowserSessionError, + normalizeBrowserTimezone, + raceBrowserSessionFailure, + registerBrowserSession, +} from './browserSession'; + +class FakeBrowser extends EventEmitter { + closeCalls = 0; + connected = true; + + isConnected(): boolean { + return this.connected; + } + + async close(): Promise { + this.closeCalls += 1; + this.connected = false; + this.emit('disconnected'); + } +} + +class FakeContext extends EventEmitter { + closeCalls = 0; + + constructor(private readonly fakeBrowser: FakeBrowser) { + super(); + } + + browser(): Browser { + return this.fakeBrowser as unknown as Browser; + } + + async close(): Promise { + this.closeCalls += 1; + this.emit('close'); + } +} + +class FakePage extends EventEmitter { + closeCalls = 0; + closed = false; + + constructor(private readonly fakeContext: FakeContext) { + super(); + } + + context(): BrowserContext { + return this.fakeContext as unknown as BrowserContext; + } + + isClosed(): boolean { + return this.closed; + } + + async close(): Promise { + this.closeCalls += 1; + this.closed = true; + this.emit('close'); + } +} + +function createFakeBrowserPage() { + const browser = new FakeBrowser(); + const context = new FakeContext(browser); + const page = new FakePage(context); + return { browser, context, page: page as unknown as Page, pageState: page }; +} + +test('validates provider-owned HTTPS origins', () => { + assert.equal(getValidatedProviderOrigin('https://us05web.zoom.us/j/123?pwd=secret', 'zoom'), 'https://us05web.zoom.us'); + assert.equal(getValidatedProviderOrigin('https://meet.google.com/abc-defg-hij', 'google'), 'https://meet.google.com'); + assert.equal(getValidatedProviderOrigin('https://teams.microsoft.com/l/meetup-join/123', 'microsoft'), 'https://teams.microsoft.com'); + assert.throws(() => getValidatedProviderOrigin('https://evilzoom.us/j/123', 'zoom')); + assert.throws(() => getValidatedProviderOrigin('http://meet.google.com/abc-defg-hij', 'google')); +}); + +test('uses UTC for missing or invalid timezones', () => { + assert.equal(normalizeBrowserTimezone('Europe/Vienna'), 'Europe/Vienna'); + assert.equal(normalizeBrowserTimezone('Not/A_Timezone'), 'UTC'); + assert.equal(normalizeBrowserTimezone(), 'UTC'); +}); + +test('closing an external CDP session never closes the shared browser', async () => { + const { browser, context, page, pageState } = createFakeBrowserPage(); + registerBrowserSession(page, 'external-cdp', true, '[test]'); + + await closeBrowserSession(page); + await closeBrowserSession(page); + + assert.equal(pageState.closeCalls, 1); + assert.equal(context.closeCalls, 1); + assert.equal(browser.closeCalls, 0); +}); + +test('closing a page in the shared CDP context leaves that context running', async () => { + const { browser, context, page, pageState } = createFakeBrowserPage(); + registerBrowserSession(page, 'external-cdp', false, '[test]'); + + await closeBrowserSession(page); + + assert.equal(pageState.closeCalls, 1); + assert.equal(context.closeCalls, 0); + assert.equal(browser.closeCalls, 0); +}); + +test('closing a session with an owned browser closes the browser once', async () => { + const { browser, page } = createFakeBrowserPage(); + registerBrowserSession(page, 'owned-browser', true, '[test]'); + + await closeBrowserSession(page); + await closeBrowserSession(page); + + assert.equal(browser.closeCalls, 1); +}); + +test('signals unexpected page crashes with a typed failure', async () => { + const { page, pageState } = createFakeBrowserPage(); + const session = registerBrowserSession(page, 'external-cdp', false, '[test]'); + + pageState.emit('crash'); + const failure = await session.failure; + + assert.equal(failure.name, 'BrowserSessionFailureError'); + assert.equal(failure.kind, 'page-crashed'); + assert.equal(normalizeBrowserSessionError(page, new Error('Target closed')), failure); +}); + +test('interrupts active meeting work immediately with the typed browser failure', async () => { + const { page, pageState } = createFakeBrowserPage(); + registerBrowserSession(page, 'external-cdp', false, '[test]'); + let finishWork!: () => void; + const work = new Promise(resolve => { + finishWork = resolve; + }); + + const activeMeeting = raceBrowserSessionFailure(page, work); + pageState.emit('crash'); + + await assert.rejects( + activeMeeting, + (error: unknown) => (error as { kind?: string }).kind === 'page-crashed' + ); + finishWork(); +}); + +test('normalizes disconnected and closed Playwright failures', () => { + const disconnected = createFakeBrowserPage(); + disconnected.browser.connected = false; + const disconnectedError = normalizeBrowserSessionError( + disconnected.page, + new Error('Target page, context or browser has been closed') + ); + assert.equal((disconnectedError as { kind?: string }).kind, 'browser-disconnected'); + + const closed = createFakeBrowserPage(); + closed.pageState.closed = true; + const closedError = normalizeBrowserSessionError(closed.page, new Error('Page has been closed')); + assert.equal((closedError as { kind?: string }).kind, 'page-closed'); +}); diff --git a/src/lib/browserSession.ts b/src/lib/browserSession.ts new file mode 100644 index 00000000..38b460c9 --- /dev/null +++ b/src/lib/browserSession.ts @@ -0,0 +1,212 @@ +import { Browser, BrowserContext, Page } from 'playwright'; + +export type BrowserProvider = 'microsoft' | 'google' | 'zoom'; +export type BrowserOwnership = 'external-cdp' | 'owned-browser' | 'owned-persistent-context'; +export type BrowserSessionFailureKind = 'browser-disconnected' | 'context-closed' | 'page-crashed' | 'page-closed'; + +export class BrowserSessionFailureError extends Error { + constructor(public readonly kind: BrowserSessionFailureKind) { + super(`Browser session failed: ${kind}`); + this.name = 'BrowserSessionFailureError'; + } +} + +const sessions = new WeakMap(); +const externalBrowserContexts = new WeakSet(); + +const PROVIDER_DOMAINS: Record = { + google: ['meet.google.com'], + microsoft: ['teams.microsoft.com', 'teams.live.com', 'teams.cloud.microsoft', 'teams.microsoft.us'], + zoom: ['zoom.us', 'zoom.com', 'zoomgov.com'], +}; + +function isDomainOrSubdomain(hostname: string, domain: string): boolean { + return hostname === domain || hostname.endsWith(`.${domain}`); +} + +export function getValidatedProviderOrigin(url: string, provider: BrowserProvider): string { + let parsed: URL; + + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid ${provider} meeting URL`); + } + + if (parsed.protocol !== 'https:' || !PROVIDER_DOMAINS[provider].some(domain => isDomainOrSubdomain(parsed.hostname, domain))) { + throw new Error(`Unsupported ${provider} meeting URL`); + } + + return parsed.origin; +} + +export function normalizeBrowserTimezone(timezone?: string): string { + if (!timezone?.trim()) return 'UTC'; + + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(); + return timezone; + } catch { + return 'UTC'; + } +} + +export class BrowserSession { + public readonly failure: Promise; + + private readonly browser: Browser | null; + private readonly context: BrowserContext; + private resolveFailure!: (error: BrowserSessionFailureError) => void; + private closePromise?: Promise; + private failureSignalled = false; + private failureError?: BrowserSessionFailureError; + private closing = false; + private readonly onBrowserDisconnected = () => this.signalFailure('browser-disconnected'); + private readonly onContextClosed = () => this.signalFailure('context-closed'); + private readonly onPageCrashed = () => this.signalFailure('page-crashed'); + private readonly onPageClosed = () => this.signalFailure('page-closed'); + + constructor( + public readonly page: Page, + public readonly ownership: BrowserOwnership, + private readonly ownsContext: boolean, + private readonly correlationLog: string, + ) { + this.context = page.context(); + this.browser = this.context.browser(); + this.failure = new Promise(resolve => { + this.resolveFailure = resolve; + }); + + if (ownership === 'external-cdp') { + externalBrowserContexts.add(this.context); + } + + this.attachFailureHandlers(); + sessions.set(page, this); + } + + private signalFailure(kind: BrowserSessionFailureKind): void { + if (this.closing || this.failureSignalled) return; + + this.failureSignalled = true; + const error = new BrowserSessionFailureError(kind); + this.failureError = error; + console.error(`${this.correlationLog} ${error.message}`); + this.resolveFailure(error); + } + + private attachFailureHandlers(): void { + this.browser?.on('disconnected', this.onBrowserDisconnected); + this.context.on('close', this.onContextClosed); + this.page.on('crash', this.onPageCrashed); + this.page.on('close', this.onPageClosed); + } + + private detachFailureHandlers(): void { + this.browser?.off('disconnected', this.onBrowserDisconnected); + this.context.off('close', this.onContextClosed); + this.page.off('crash', this.onPageCrashed); + this.page.off('close', this.onPageClosed); + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + + this.closing = true; + this.closePromise = this.closeOwnedResources(); + return this.closePromise; + } + + getFailureError(): BrowserSessionFailureError | undefined { + return this.failureError; + } + + private async closeOwnedResources(): Promise { + try { + if (this.ownership === 'external-cdp') { + if (!this.page.isClosed()) await this.page.close(); + if (this.ownsContext) await this.context.close().catch(() => undefined); + return; + } + + if (this.ownership === 'owned-persistent-context') { + await this.context.close(); + return; + } + + if (this.browser?.isConnected()) { + await this.browser.close(); + } else if (!this.page.isClosed()) { + await this.context.close(); + } + } finally { + this.detachFailureHandlers(); + sessions.delete(this.page); + } + } +} + +export function registerBrowserSession( + page: Page, + ownership: BrowserOwnership, + ownsContext: boolean, + correlationLog: string, +): BrowserSession { + return new BrowserSession(page, ownership, ownsContext, correlationLog); +} + +export function getBrowserSession(page?: Page | null): BrowserSession | undefined { + return page ? sessions.get(page) : undefined; +} + +export async function closeBrowserSession(page?: Page | null): Promise { + if (!page) return; + + const session = getBrowserSession(page); + if (session) { + await session.close(); + return; + } + + if (!page.isClosed()) await page.close(); +} + +export async function raceBrowserSessionFailure(page: Page, work: Promise): Promise { + const session = getBrowserSession(page); + if (!session) return work; + + const outcome = await Promise.race([ + work.then(value => ({ type: 'completed' as const, value })), + session.failure.then(error => ({ type: 'failed' as const, error })), + ]); + if (outcome.type === 'failed') throw outcome.error; + return outcome.value; +} + +export function normalizeBrowserSessionError(page: Page | undefined, error: unknown): unknown { + if (error instanceof BrowserSessionFailureError || !page) return error; + + const sessionFailure = getBrowserSession(page)?.getFailureError(); + if (sessionFailure) return sessionFailure; + + const message = error instanceof Error ? error.message.toLowerCase() : ''; + const browser = page.context().browser(); + if (browser && !browser.isConnected()) { + return new BrowserSessionFailureError('browser-disconnected'); + } + if (page.isClosed() || message.includes('page has been closed')) { + return new BrowserSessionFailureError('page-closed'); + } + if (message.includes('context') && message.includes('closed')) { + return new BrowserSessionFailureError('context-closed'); + } + if (message.includes('target page, context or browser has been closed')) { + return new BrowserSessionFailureError('page-closed'); + } + return error; +} + +export function isExternalBrowserContext(context?: BrowserContext | null): boolean { + return Boolean(context && externalBrowserContexts.has(context)); +} diff --git a/src/lib/chromium.test.ts b/src/lib/chromium.test.ts new file mode 100644 index 00000000..a0c1f764 --- /dev/null +++ b/src/lib/chromium.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Browser, BrowserContext } from 'playwright'; +import { cleanupFailedBrowserSetup } from './chromium'; + +test('closes an owned browser when setup fails before creating a page', async () => { + let closeCalls = 0; + const browser = { + isConnected: () => true, + close: async () => { + closeCalls += 1; + }, + } as unknown as Browser; + + await cleanupFailedBrowserSetup({ + browser, + closeBrowser: true, + closeContext: true, + correlationId: 'test', + }); + + assert.equal(closeCalls, 1); +}); + +test('closes an isolated external context without closing shared Chrome', async () => { + let browserCloseCalls = 0; + let contextCloseCalls = 0; + const browser = { + isConnected: () => true, + close: async () => { + browserCloseCalls += 1; + }, + } as unknown as Browser; + const context = { + close: async () => { + contextCloseCalls += 1; + }, + } as unknown as BrowserContext; + + await cleanupFailedBrowserSetup({ + browser, + context, + closeBrowser: false, + closeContext: true, + correlationId: 'test', + }); + + assert.equal(contextCloseCalls, 1); + assert.equal(browserCloseCalls, 0); +}); diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index adb58bee..dcb22fdb 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -1,23 +1,41 @@ -import { Browser, BrowserContext, Page } from 'playwright'; -import { chromium } from 'playwright-extra'; -import StealthPlugin from 'puppeteer-extra-plugin-stealth'; +import { Browser, BrowserContext, BrowserContextOptions, Page, chromium } from 'playwright'; import config from '../config'; import { getCorrelationIdLog } from '../util/logger'; - -const stealthPlugin = StealthPlugin(); -stealthPlugin.enabledEvasions.delete('iframe.contentWindow'); -stealthPlugin.enabledEvasions.delete('media.codecs'); -chromium.use(stealthPlugin); - -export type BotType = 'microsoft' | 'google' | 'zoom'; - -const externalBrowserContexts = new WeakSet(); - -export function isExternalBrowserContext(context?: BrowserContext | null): boolean { - return Boolean(context && externalBrowserContexts.has(context)); +import { + BrowserProvider, + closeBrowserSession, + getBrowserSession, + getValidatedProviderOrigin, + isExternalBrowserContext, + normalizeBrowserSessionError, + normalizeBrowserTimezone, + raceBrowserSessionFailure, + registerBrowserSession, +} from './browserSession'; + +export type BotType = BrowserProvider; + +export { + closeBrowserSession, + getBrowserSession, + getValidatedProviderOrigin, + isExternalBrowserContext, + normalizeBrowserSessionError, + normalizeBrowserTimezone, + raceBrowserSessionFailure, +}; + +const VIEWPORT_SIZE = { width: 1280, height: 720 }; +const WINDOW_SIZE = { width: 1280, height: 800 }; +const CDP_CONNECT_TIMEOUT_MS = 60_000; +const CDP_RETRY_INTERVAL_MS = 1_000; +const externalBrowserConnections = new Map>(); + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } -async function resizeBrowserWindow(page: Page, size: { width: number; height: number }, correlationId: string) { +async function resizeBrowserWindow(page: Page, correlationId: string): Promise { const log = getCorrelationIdLog(correlationId); try { @@ -29,306 +47,389 @@ async function resizeBrowserWindow(page: Page, size: { width: number; height: nu windowState: 'normal', left: 0, top: 0, - width: size.width, - height: size.height, + width: WINDOW_SIZE.width, + height: WINDOW_SIZE.height, }, }); - } catch (err: any) { - console.warn(`${log} Unable to resize Chrome window through CDP`, err?.message ?? err); + } catch (error) { + console.warn(`${log} Unable to resize Chrome window through CDP`, error instanceof Error ? error.message : error); } } -function attachBrowserErrorHandlers(browser: Browser | null, context: BrowserContext, page: Page, correlationId: string) { +async function applyPageEnvironment(page: Page, timezoneId: string, correlationId: string): Promise { const log = getCorrelationIdLog(correlationId); - browser?.on('disconnected', () => { - console.log(`${log} Browser has disconnected!`); - }); - - context.on('close', () => { - console.log(`${log} Browser has closed!`); - }); - - page.on('crash', (page) => { - console.error(`${log} Page has crashed! ${page?.url()}`); - }); + await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' }); - page.on('close', (page) => { - console.log(`${log} Page has closed! ${page?.url()}`); - }); + try { + const client = await page.context().newCDPSession(page); + await client.send('Emulation.setLocaleOverride', { locale: 'en-US' }); + await client.send('Emulation.setTimezoneOverride', { timezoneId }); + } catch (error) { + console.warn(`${log} Unable to apply Chrome locale/timezone through CDP`, error instanceof Error ? error.message : error); + } } -async function launchBrowserWithTimeout(launchFn: () => Promise, timeoutMs: number, correlationId: string): Promise { +async function launchBrowserWithTimeout( + launchFn: () => Promise, + timeoutMs: number, + correlationId: string, +): Promise { let timeoutId: NodeJS.Timeout; let finished = false; return new Promise((resolve, reject) => { - // Set up timeout timeoutId = setTimeout(() => { - if (!finished) { - finished = true; - reject(new Error(`Browser launch timed out after ${timeoutMs}ms`)); - } + if (finished) return; + finished = true; + reject(new Error(`Browser launch timed out after ${timeoutMs}ms`)); }, timeoutMs); - // Start launch launchFn() - .then(result => { - if (!finished) { - finished = true; - clearTimeout(timeoutId); - console.log(`${getCorrelationIdLog(correlationId)} Browser launch function success!`); - resolve(result); + .then(async browser => { + if (finished) { + await browser.close().catch(() => undefined); + return; } + + finished = true; + clearTimeout(timeoutId); + console.log(`${getCorrelationIdLog(correlationId)} Browser launch function success!`); + resolve(browser); }) - .catch(err => { - console.error(`${getCorrelationIdLog(correlationId)} Error launching browser`, err); - if (!finished) { - finished = true; - clearTimeout(timeoutId); - reject(err); - } + .catch(error => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + reject(error); }); }); } -async function launchPersistentContextWithTimeout(launchFn: () => Promise, timeoutMs: number, correlationId: string): Promise { +async function launchPersistentContextWithTimeout( + launchFn: () => Promise, + timeoutMs: number, + correlationId: string, +): Promise { let timeoutId: NodeJS.Timeout; let finished = false; return new Promise((resolve, reject) => { timeoutId = setTimeout(() => { - if (!finished) { - finished = true; - reject(new Error(`Persistent browser launch timed out after ${timeoutMs}ms`)); - } + if (finished) return; + finished = true; + reject(new Error(`Persistent browser launch timed out after ${timeoutMs}ms`)); }, timeoutMs); launchFn() - .then(result => { - if (!finished) { - finished = true; - clearTimeout(timeoutId); - console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launch function success!`); - resolve(result); + .then(async context => { + if (finished) { + await context.close().catch(() => undefined); + return; } + + finished = true; + clearTimeout(timeoutId); + console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launch function success!`); + resolve(context); }) - .catch(err => { - console.error(`${getCorrelationIdLog(correlationId)} Error launching persistent browser`, err); - if (!finished) { - finished = true; - clearTimeout(timeoutId); - reject(err); - } + .catch(error => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + reject(error); }); }); } -async function createBrowserContext(url: string, correlationId: string, botType: BotType = 'google'): Promise { - const size = { width: 1280, height: 720 }; - const browserWindowSize = { width: size.width, height: size.height + 80 }; +async function connectToExternalChrome(cdpUrl: string, correlationId: string): Promise { + const existingConnection = externalBrowserConnections.get(cdpUrl); + if (existingConnection) { + const existingBrowser = await existingConnection.catch(() => undefined); + if (existingBrowser?.isConnected()) return existingBrowser; + externalBrowserConnections.delete(cdpUrl); + } + + const connection = connectToExternalChromeWithRetry(cdpUrl, correlationId); + externalBrowserConnections.set(cdpUrl, connection); + + try { + const browser = await connection; + browser.once('disconnected', () => { + if (externalBrowserConnections.get(cdpUrl) === connection) { + externalBrowserConnections.delete(cdpUrl); + } + }); + return browser; + } catch (error) { + if (externalBrowserConnections.get(cdpUrl) === connection) { + externalBrowserConnections.delete(cdpUrl); + } + throw error; + } +} + +async function connectToExternalChromeWithRetry(cdpUrl: string, correlationId: string): Promise { + const startedAt = Date.now(); + let lastError: unknown; + let attempt = 0; - // Google Meet is sensitive to browser fingerprinting before admission. Keep - // its launch close to normal Chrome and reserve recording-heavy flags for - // platforms that need them. - const firstRunSuppressionArgs: string[] = [ + while ((Date.now() - startedAt) < CDP_CONNECT_TIMEOUT_MS) { + attempt += 1; + const remainingMs = CDP_CONNECT_TIMEOUT_MS - (Date.now() - startedAt); + + try { + return await chromium.connectOverCDP(cdpUrl, { + timeout: Math.max(1, Math.min(5_000, remainingMs)), + }); + } catch (error) { + lastError = error; + console.warn(`${getCorrelationIdLog(correlationId)} External Chrome is not ready; retrying`, { attempt }); + const retryDelayMs = Math.min(CDP_RETRY_INTERVAL_MS, CDP_CONNECT_TIMEOUT_MS - (Date.now() - startedAt)); + if (retryDelayMs > 0) await delay(retryDelayMs); + } + } + + const detail = lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown error'); + throw new Error(`Unable to connect to external Chrome within ${CDP_CONNECT_TIMEOUT_MS}ms: ${detail}`); +} + +function getBrowserArgs(botType: BotType): string[] { + const args = [ '--no-first-run', '--no-default-browser-check', '--disable-first-run-ui', '--disable-default-browser-promo', '--disable-default-apps', - ]; - - const googleBrowserArgs: string[] = [ - ...firstRunSuppressionArgs, - '--no-sandbox', - '--disable-setuid-sandbox', - `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, + '--lang=en-US', + `--window-size=${WINDOW_SIZE.width},${WINDOW_SIZE.height}`, '--auto-accept-this-tab-capture', '--autoplay-policy=no-user-gesture-required', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', ]; - const recordingBrowserArgs: string[] = [ - ...firstRunSuppressionArgs, - '--enable-usermedia-screen-capturing', - '--allow-http-screen-capture', - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-web-security', - '--use-gl=angle', - '--use-angle=swiftshader', - `--window-size=${browserWindowSize.width},${browserWindowSize.height}`, - '--auto-accept-this-tab-capture', - '--enable-features=MediaRecorder', - '--enable-audio-service-out-of-process', - '--autoplay-policy=no-user-gesture-required', - ]; + if (process.env.CHROME_NO_SANDBOX !== 'false') { + args.push('--no-sandbox', '--disable-setuid-sandbox'); + } - // Fake device args - only for Microsoft Teams - // Teams needs fake devices to interact with pre-join screen toggles, - // but actual recording is done via ffmpeg (X11 + PulseAudio) - const fakeDeviceArgs: string[] = [ - '--use-fake-ui-for-media-stream', - '--use-fake-device-for-media-stream', - ]; + if (botType === 'microsoft') { + args.push('--use-fake-device-for-media-stream'); + } + + if (botType !== 'google' && process.env.CHROME_SOFTWARE_GL !== 'false') { + args.push('--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'); + } - // Google Meet and Zoom use browser-based recording (getDisplayMedia + MediaRecorder) - // and don't need fake devices: - // - Google Meet: clicks "Continue without microphone and camera" - // - Zoom: expects "Cannot detect your camera/microphone" notifications - const browserArgs = botType === 'google' - ? googleBrowserArgs - : botType === 'microsoft' - ? [...recordingBrowserArgs, ...fakeDeviceArgs] - : recordingBrowserArgs; - const ignoreDefaultArgs = botType === 'google' - ? ['--mute-audio', '--enable-automation'] - : ['--mute-audio']; - - // Teams-specific display args: kiosk mode prevents address bar from showing in ffmpeg recording - // Google Meet and Zoom don't need this since they use tab capture (getDisplayMedia) - const displayArgs = botType === 'microsoft' - ? ['--kiosk', '--start-maximized'] - : []; - - console.log(`${getCorrelationIdLog(correlationId)} Launching browser for ${botType} bot (fake devices: ${botType === 'microsoft'})`); - - const contextOptions = { - ...(botType !== 'google' ? { - permissions: ['camera', 'microphone'], - } : {}), - viewport: size, - ignoreHTTPSErrors: true, - // Record video only in development for debugging. Keep Google Meet's - // anonymous admission context close to a regular incognito tab. + return args; +} + +async function configurePage( + page: Page, + timezoneId: string, + correlationId: string, +): Promise { + await resizeBrowserWindow(page, correlationId); + await page.setViewportSize(VIEWPORT_SIZE); + await applyPageEnvironment(page, timezoneId, correlationId); +} + +export async function cleanupFailedBrowserSetup({ + browser, + context, + page, + closeBrowser, + closeContext, + correlationId, +}: { + browser?: Browser | null; + context?: BrowserContext; + page?: Page; + closeBrowser: boolean; + closeContext: boolean; + correlationId: string; +}): Promise { + try { + const session = getBrowserSession(page); + if (session) { + await session.close(); + } else if (closeBrowser && browser?.isConnected()) { + await browser.close(); + } else if (closeContext && context) { + await context.close(); + } else if (page && !page.isClosed()) { + await page.close(); + } + } catch (cleanupError) { + console.warn( + `${getCorrelationIdLog(correlationId)} Failed to clean up partial browser setup`, + cleanupError instanceof Error ? cleanupError.message : cleanupError + ); + } +} + +async function createBrowserContext( + url: string, + correlationId: string, + botType: BotType = 'google', + timezone?: string, +): Promise { + const log = getCorrelationIdLog(correlationId); + const trustedOrigin = getValidatedProviderOrigin(url, botType); + const timezoneId = normalizeBrowserTimezone(timezone); + const browserArgs = getBrowserArgs(botType); + const contextOptions: BrowserContextOptions = { + viewport: VIEWPORT_SIZE, + locale: 'en-US', + timezoneId, ...(process.env.NODE_ENV === 'development' && botType !== 'google' && { recordVideo: { dir: './debug-videos/', - size: size, + size: VIEWPORT_SIZE, }, }), }; - // External (already-running) Chrome via CDP. Supported for Google Meet and - // Zoom. Google reuses the browser's first context because it is tied to a - // logged-in profile/storageState; Zoom joins each meeting anonymously, so - // always spin up a fresh isolated context to avoid state bleeding between - // meetings (the /wc/join/ web client keeps IndexedDB/localStorage per origin). - const cdpUrl = botType === 'google' ? config.googleChromeCdpUrl - : botType === 'zoom' ? config.zoomChromeCdpUrl - : undefined; + if (timezone && timezoneId === 'UTC' && timezone !== 'UTC') { + console.warn(`${log} Invalid browser timezone; using UTC`); + } - if (cdpUrl) { - console.log(`${getCorrelationIdLog(correlationId)} Connecting ${botType} bot to external Chrome`, { - cdpUrl, - }); + console.log(`${log} Launching browser for ${botType} bot`); - const browser = await launchBrowserWithTimeout( - async () => await chromium.connectOverCDP(cdpUrl!), - 60000, - correlationId - ); + const cdpUrl = botType === 'google' + ? config.googleChromeCdpUrl + : botType === 'zoom' + ? config.zoomChromeCdpUrl + : undefined; - const context = botType === 'google' - ? (browser.contexts()[0] ?? await browser.newContext({ - ...contextOptions, - ...(config.googleChromeStorageStatePath ? { - storageState: config.googleChromeStorageStatePath, - } : {}), - })) - : await browser.newContext(contextOptions); - externalBrowserContexts.add(context); - - const page = await context.newPage(); - await resizeBrowserWindow(page, browserWindowSize, correlationId); - await page.setViewportSize(size); - attachBrowserErrorHandlers(browser, context, page, correlationId); - - // Parity with the non-CDP launch path: Zoom/Teams expect camera/mic grants - // for the pre-join audio check. Harmless if the web client doesn't use them. - if (botType !== 'google') { - await context.grantPermissions(['microphone', 'camera'], { origin: url }); - } + if (cdpUrl) { + console.log(`${log} Connecting ${botType} bot to external Chrome`); + const browser = await connectToExternalChrome(cdpUrl, correlationId); + let context: BrowserContext | undefined; + let page: Page | undefined; + let ownsContext = false; + + try { + const existingContext = botType === 'google' ? browser.contexts()[0] : undefined; + context = existingContext ?? await browser.newContext({ + ...contextOptions, + ...(botType === 'google' && config.googleChromeStorageStatePath + ? { storageState: config.googleChromeStorageStatePath } + : {}), + }); + ownsContext = !existingContext; - console.log(`${getCorrelationIdLog(correlationId)} External Chrome connected successfully!`); + if (botType === 'microsoft') { + await context.grantPermissions(['microphone', 'camera'], { origin: trustedOrigin }); + } - return page; + page = await context.newPage(); + registerBrowserSession(page, 'external-cdp', ownsContext, log); + await configurePage(page, timezoneId, correlationId); + + console.log(`${log} External Chrome connected successfully!`); + return page; + } catch (error) { + const normalizedError = normalizeBrowserSessionError(page, error); + await cleanupFailedBrowserSetup({ + browser, + context, + page, + closeBrowser: false, + closeContext: ownsContext, + correlationId, + }); + throw normalizedError; + } } if (botType === 'google' && config.googleChromeUserDataDir) { - console.log(`${getCorrelationIdLog(correlationId)} Launching Google bot with persistent Chrome profile`, { - userDataDir: config.googleChromeUserDataDir, - }); - + console.log(`${log} Launching Google bot with persistent Chrome profile`); const context = await launchPersistentContextWithTimeout( - async () => await chromium.launchPersistentContext(config.googleChromeUserDataDir!, { + () => chromium.launchPersistentContext(config.googleChromeUserDataDir!, { ...contextOptions, headless: false, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, - args: [ - ...browserArgs, - ...displayArgs, - ], - ignoreDefaultArgs, + args: browserArgs, + ignoreDefaultArgs: ['--mute-audio'], executablePath: config.chromeExecutablePath, }), - 60000, - correlationId + 60_000, + correlationId, ); - - const page = context.pages()[0] ?? await context.newPage(); - await resizeBrowserWindow(page, browserWindowSize, correlationId); - await page.setViewportSize(size); - attachBrowserErrorHandlers(context.browser(), context, page, correlationId); - - console.log(`${getCorrelationIdLog(correlationId)} Persistent browser launched successfully!`); - - return page; + let page: Page | undefined; + + try { + page = context.pages()[0] ?? await context.newPage(); + registerBrowserSession(page, 'owned-persistent-context', true, log); + await configurePage(page, timezoneId, correlationId); + + console.log(`${log} Persistent browser launched successfully!`); + return page; + } catch (error) { + const normalizedError = normalizeBrowserSessionError(page, error); + await cleanupFailedBrowserSetup({ + browser: context.browser(), + context, + page, + closeBrowser: false, + closeContext: true, + correlationId, + }); + throw normalizedError; + } } const browser = await launchBrowserWithTimeout( - async () => await chromium.launch({ + () => chromium.launch({ headless: false, - // Don't let Playwright install its own SIGTERM/SIGINT/SIGHUP handlers — they - // close the browser immediately when the node process receives a signal, which - // breaks our graceful shutdown (we want the in-flight recording to finish). - // The app explicitly calls browser.close() when the recording is done. handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, - args: [ - ...browserArgs, - ...displayArgs, - ], - ignoreDefaultArgs, + args: browserArgs, + ignoreDefaultArgs: ['--mute-audio'], executablePath: config.chromeExecutablePath, }), - 60000, - correlationId + 60_000, + correlationId, ); - const context = await browser.newContext({ - ...contextOptions, - ...(config.googleChromeStorageStatePath && botType === 'google' ? { - storageState: config.googleChromeStorageStatePath, - } : {}), - }); - - // Grant permissions so Teams will play audio (Teams requires this unlike Google Meet) - if (botType !== 'google') { - await context.grantPermissions(['microphone', 'camera'], { origin: url }); - } + let context: BrowserContext | undefined; + let page: Page | undefined; - const page = await context.newPage(); + try { + context = await browser.newContext({ + ...contextOptions, + ...(config.googleChromeStorageStatePath && botType === 'google' + ? { storageState: config.googleChromeStorageStatePath } + : {}), + }); - // Attach common error handlers - attachBrowserErrorHandlers(browser, context, page, correlationId); + if (botType === 'microsoft') { + await context.grantPermissions(['microphone', 'camera'], { origin: trustedOrigin }); + } - console.log(`${getCorrelationIdLog(correlationId)} Browser launched successfully!`); + page = await context.newPage(); + registerBrowserSession(page, 'owned-browser', true, log); + await configurePage(page, timezoneId, correlationId); - return page; + console.log(`${log} Browser launched successfully!`); + return page; + } catch (error) { + const normalizedError = normalizeBrowserSessionError(page, error); + await cleanupFailedBrowserSetup({ + browser, + context, + page, + closeBrowser: true, + closeContext: true, + correlationId, + }); + throw normalizedError; + } } export default createBrowserContext; diff --git a/src/monitoring/sentry.test.ts b/src/monitoring/sentry.test.ts new file mode 100644 index 00000000..dd79143b --- /dev/null +++ b/src/monitoring/sentry.test.ts @@ -0,0 +1,345 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { WaitingAtLobbyRetryError } from '../error'; +import { + SentryReporter, + classifyMeetingFailureKind, + inferFallbackResult, + inferMeetingFailurePhase, + inferMeetingFailureTransport, + redactSentryEvent, +} from './sentry'; + +interface FakeScope { + level?: string; + tags: Record; + fingerprint?: string[]; + setLevel(level: string): void; + setTag(key: string, value: string): void; + setFingerprint(fingerprint: string[]): void; +} + +interface FakeInitOptions { + dsn?: string; + environment?: string; + release?: string; + tracesSampleRate?: number; + sendDefaultPii?: boolean; + beforeSend?: (event: unknown) => unknown; + integrations?: ( + integrations: Array<{ name: string }> + ) => Array<{ name: string }>; +} + +const createFakeSdk = () => { + let activeScope: FakeScope | undefined; + const state = { + initCalls: [] as FakeInitOptions[], + exceptions: [] as Array<{ error: unknown; level?: string; tags: Record; fingerprint?: string[] }>, + messages: [] as Array<{ message: string; level?: string; tags: Record; fingerprint?: string[] }>, + flushTimeouts: [] as number[], + }; + + const sdk = { + init(options: FakeInitOptions) { + state.initCalls.push(options); + }, + withScope(callback: (scope: FakeScope) => void) { + const scope: FakeScope = { + tags: {}, + setLevel(level) { + this.level = level; + }, + setTag(key, value) { + this.tags[key] = value; + }, + setFingerprint(fingerprint) { + this.fingerprint = fingerprint; + }, + }; + activeScope = scope; + callback(scope); + activeScope = undefined; + }, + captureException(error: unknown) { + state.exceptions.push({ + error, + level: activeScope?.level, + tags: { ...activeScope?.tags }, + fingerprint: activeScope?.fingerprint, + }); + return 'event-id'; + }, + captureMessage(message: string) { + state.messages.push({ + message, + level: activeScope?.level, + tags: { ...activeScope?.tags }, + fingerprint: activeScope?.fingerprint, + }); + return 'event-id'; + }, + async flush(timeout: number) { + state.flushTimeouts.push(timeout); + return true; + }, + }; + + return { sdk, state }; +}; + +test('is a complete no-op when SENTRY_DSN is absent', async () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, {}); + + assert.equal(reporter.initialize(), false); + assert.equal(reporter.captureOperationalError(new Error('failure'), { phase: 'startup' }), false); + assert.equal(await reporter.flush(), true); + assert.equal(state.initCalls.length, 0); + assert.equal(state.exceptions.length, 0); + assert.equal(state.flushTimeouts.length, 0); +}); + +test('initializes Sentry with safe non-tracing defaults from the environment', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + SENTRY_ENVIRONMENT: 'production', + SENTRY_RELEASE: 'meeting-bot@1.3.6', + }); + + assert.equal(reporter.initialize(), true); + assert.equal(state.initCalls.length, 1); + assert.equal(state.initCalls[0].dsn, 'https://public@example.invalid/1'); + assert.equal(state.initCalls[0].environment, 'production'); + assert.equal(state.initCalls[0].release, 'meeting-bot@1.3.6'); + assert.equal(state.initCalls[0].tracesSampleRate, 0); + assert.equal(state.initCalls[0].sendDefaultPii, false); + assert.equal(typeof state.initCalls[0].beforeSend, 'function'); + const integrations = state.initCalls[0].integrations?.([ + { name: 'Http' }, + { name: 'Console' }, + { name: 'OnUncaughtException' }, + { name: 'OnUnhandledRejection' }, + ]); + assert.deepEqual(integrations?.map(({ name }) => name), ['Http']); +}); + +test('redacts URL queries, credentials, request and page bodies before sending', () => { + const event = { + message: 'Failed https://zoom.us/j/123?pwd=zoom-password&token=url-token and /wc/join/123?foo=private-query token=inline-token', + user: { email: 'person@example.com' }, + request: { + url: 'https://zoom.us/j/123?pwd=zoom-password', + query_string: 'pwd=zoom-password', + data: 'private page', + headers: { + authorization: 'Bearer private-bearer', + 'x-api-key': 'private-api-key', + }, + }, + extra: { + documentBodyText: 'private page text', + clientSecret: 'private-client-secret', + nested: { password: 'private-password' }, + }, + }; + + const redacted = redactSentryEvent(event); + const serialized = JSON.stringify(redacted); + + assert.equal(redacted.request.url, 'https://zoom.us/j/123'); + assert.equal('user' in redacted, false); + assert.equal(redacted.request.query_string, '[REDACTED]'); + assert.equal(redacted.request.data, '[REDACTED]'); + assert.equal(redacted.request.headers.authorization, '[REDACTED]'); + assert.equal(redacted.request.headers['x-api-key'], '[REDACTED]'); + for (const secret of [ + 'zoom-password', + 'url-token', + 'inline-token', + 'private-query', + 'private page', + 'private-bearer', + 'private-api-key', + 'private-client-secret', + 'private-password', + ]) { + assert.equal(serialized.includes(secret), false, `found leaked value: ${secret}`); + } +}); + +test('redacts non-HTTP meeting URLs and inline OAuth credentials', () => { + const event = { + message: 'wss://rtms.zoom.us/connect?signature=private zoommtg://zoom.us/join?pwd=private clientSecret=private oauth_token=private', + }; + const serialized = JSON.stringify(redactSentryEvent(event)); + + assert.equal(serialized.includes('signature=private'), false); + assert.equal(serialized.includes('pwd=private'), false); + assert.equal(serialized.includes('clientSecret=private'), false); + assert.equal(serialized.includes('oauth_token=private'), false); +}); + +test('captures one tagged permanent meeting failure for the same final error', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + SENTRY_ENVIRONMENT: 'production', + SENTRY_RELEASE: 'meeting-bot@1.3.6', + }); + reporter.initialize(); + + const error = new Error('recording failed'); + const context = { + provider: 'microsoft' as const, + transport: 'browser' as const, + phase: 'recording', + fallbackResult: 'not_attempted', + teamId: 'team-id', + eventId: 'event-id', + botId: 'bot-id', + correlationId: 'correlation-id', + }; + + assert.equal(reporter.reportPermanentMeetingFailure(error, context), true); + assert.equal(reporter.reportPermanentMeetingFailure(error, context), false); + assert.equal(state.exceptions.length, 1); + assert.equal(state.exceptions[0].level, 'error'); + assert.deepEqual(state.exceptions[0].fingerprint, ['microsoft', 'recording_failure']); + assert.deepEqual(state.exceptions[0].tags, { + provider: 'microsoft', + transport: 'browser', + error_type: 'Error', + failure_kind: 'recording_failure', + stage: 'recording', + fallback_result: 'not_attempted', + environment: 'production', + release: 'meeting-bot@1.3.6', + team_id: 'team-id', + event_id: 'event-id', + bot_id: 'bot-id', + correlation_id: 'correlation-id', + }); +}); + +test('reports host rejection and lobby timeout as classified warnings', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + const context = { + provider: 'google' as const, + transport: 'browser' as const, + phase: 'waiting_room', + fallbackResult: 'not_attempted', + teamId: 'team-id', + eventId: 'event-id', + correlationId: 'correlation-id', + }; + const rejected = new WaitingAtLobbyRetryError( + 'Google Meet bot could not enter the meeting', + 'Someone in the call denied your request to join' + ); + const timedOut = new WaitingAtLobbyRetryError( + 'Google Meet bot could not enter the meeting', + 'No one responded to your request to join the call' + ); + + assert.equal(classifyMeetingFailureKind(rejected), 'host_rejected'); + assert.equal(classifyMeetingFailureKind(timedOut), 'lobby_timeout'); + reporter.reportPermanentMeetingFailure(rejected, context); + reporter.reportPermanentMeetingFailure(timedOut, { + ...context, + correlationId: 'second-correlation-id', + }); + + assert.deepEqual(state.exceptions.map(({ level, tags }) => ({ + level, + failureKind: tags.failure_kind, + stage: tags.stage, + })), [ + { level: 'warning', failureKind: 'host_rejected', stage: 'waiting_room' }, + { level: 'warning', failureKind: 'lobby_timeout', stage: 'waiting_room' }, + ]); +}); + +test('keeps technical permanent failures at error level', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + const technicalError = new Error('CDP browser disconnected'); + reporter.reportPermanentMeetingFailure(technicalError, { + provider: 'zoom', + transport: 'browser', + phase: 'recording', + fallbackResult: 'not_attempted', + teamId: 'team-id', + botId: 'bot-id', + correlationId: 'correlation-id', + }); + + assert.equal(state.exceptions[0].level, 'error'); + assert.equal(state.exceptions[0].tags.failure_kind, 'browser_failure'); + assert.equal(state.exceptions[0].tags.team_id, 'team-id'); + assert.equal(state.exceptions[0].tags.bot_id, 'bot-id'); + assert.equal(state.exceptions[0].tags.event_id, 'none'); +}); + +test('classifies an RTMS fallback failure from a wrapped error message', () => { + const error = new Error('Zoom RTMS stream failed after recovery retries'); + assert.equal(inferMeetingFailureTransport(error), 'rtms'); + assert.equal(inferMeetingFailurePhase(error), 'fallback'); + assert.equal(inferFallbackResult(error), 'failed'); +}); + +test('preserves explicit RTMS transport context for upload failures', () => { + const error = Object.assign(new Error('Recording upload failed'), { + transport: 'rtms', + phase: 'upload', + fallbackResult: 'recovered', + }); + + assert.equal(inferMeetingFailureTransport(error), 'rtms'); + assert.equal(inferMeetingFailurePhase(error), 'upload'); + assert.equal(inferFallbackResult(error), 'recovered'); +}); + +test('reports a recovered Zoom RTMS fallback as a warning', () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + reporter.reportRecoveredZoomFallback({ + phase: 'fallback', + teamId: 'team-id', + eventId: 'event-id', + correlationId: 'correlation-id', + }); + + assert.equal(state.messages.length, 1); + assert.equal(state.messages[0].level, 'warning'); + assert.equal(state.messages[0].tags.provider, 'zoom'); + assert.equal(state.messages[0].tags.transport, 'rtms'); + assert.equal(state.messages[0].tags.failure_kind, 'automated_bot_blocked'); + assert.equal(state.messages[0].tags.stage, 'fallback'); + assert.equal(state.messages[0].tags.fallback_result, 'recovered'); +}); + +test('caps Sentry flushing at two seconds', async () => { + const { sdk, state } = createFakeSdk(); + const reporter = new SentryReporter(sdk as never, { + SENTRY_DSN: 'https://public@example.invalid/1', + }); + reporter.initialize(); + + assert.equal(await reporter.flush(10_000), true); + assert.deepEqual(state.flushTimeouts, [2_000]); +}); diff --git a/src/monitoring/sentry.ts b/src/monitoring/sentry.ts new file mode 100644 index 00000000..bdcd7f55 --- /dev/null +++ b/src/monitoring/sentry.ts @@ -0,0 +1,495 @@ +import * as Sentry from '@sentry/node'; +import { v5 as uuidv5 } from 'uuid'; + +const REDACTED = '[REDACTED]'; +const MAX_FLUSH_TIMEOUT_MS = 2_000; +const GLOBAL_HANDLER_INTEGRATIONS = new Set([ + 'Console', + 'OnUncaughtException', + 'OnUnhandledRejection', +]); + +const SENSITIVE_KEYS = new Set([ + 'authorization', + 'accountid', + 'bearer', + 'body', + 'clientsecret', + 'clientid', + 'cookie', + 'cookies', + 'documentbody', + 'documentbodytext', + 'dsn', + 'html', + 'idtoken', + 'oauth', + 'pagebody', + 'pagecontent', + 'pagetext', + 'password', + 'passwd', + 'pwd', + 'query', + 'querystring', + 'refreshtoken', + 'requestbody', + 'responsebody', + 'secret', + 'servicekey', + 'signature', + 'setcookie', + 'token', + 'accesstoken', + 'apikey', + 'participantuserid', +]); + +type SentrySdk = Pick< + typeof Sentry, + 'captureException' | 'captureMessage' | 'flush' | 'init' | 'withScope' +>; + +export type MeetingProvider = 'google' | 'microsoft' | 'zoom'; +export type RecordingTransport = 'browser' | 'rtms'; +export type MeetingFailureKind = + | 'automated_bot_blocked' + | 'browser_failure' + | 'host_rejected' + | 'lobby_timeout' + | 'meeting_ended' + | 'recording_failure' + | 'rtms_failure' + | 'sign_in_required' + | 'technical_failure' + | 'unsupported_meeting' + | 'upload_failed'; + +export interface MeetingSentryContext { + provider: MeetingProvider; + transport: RecordingTransport; + phase: string; + fallbackResult: string; + teamId: string; + eventId?: string; + botId?: string; + correlationId: string; +} + +export interface MeetingIdentityContext { + teamId: string; + userId: string; + url: string; + eventId?: string; + botId?: string; +} + +export interface OperationalSentryContext { + phase: string; + provider?: MeetingProvider | 'system'; + transport?: RecordingTransport | 'none'; + teamId?: string; + eventId?: string; + botId?: string; + correlationId?: string; +} + +const normalizedKey = (key: string): string => + key.toLowerCase().replace(/[^a-z0-9]/g, ''); + +const isSensitiveKey = (key: string, parentKey?: string): boolean => { + const normalized = normalizedKey(key); + if (SENSITIVE_KEYS.has(normalized)) return true; + if ( + normalized.endsWith('secret') + || normalized.endsWith('token') + || normalized.endsWith('apikey') + || normalized.endsWith('authorization') + || normalized.endsWith('cookie') + || normalized.endsWith('password') + || normalized.endsWith('signature') + ) return true; + return normalized === 'data' && normalizedKey(parentKey ?? '') === 'request'; +}; + +export const redactSensitiveString = (value: string): string => value + .replace(/\b(?:https?|wss?|zoommtg):\/\/[^\s"'<>]+/gi, (candidate) => { + const queryIndex = candidate.search(/[?#]/); + return queryIndex === -1 ? candidate : candidate.slice(0, queryIndex); + }) + .replace( + /(^|[\s("'`])((?:\/\/|\/)[^\s"'<>?]+)\?[^\s"'<>]*/g, + '$1$2' + ) + .replace( + /\b((?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/[^\s"'<>?]*)?)\?[^\s"'<>]*/gi, + '$1' + ) + .replace(/\bBearer\s+[^\s,;]+/gi, `Bearer ${REDACTED}`) + .replace( + /((?:access[_-]?token|api[_-]?key|authorization|client[_-]?secret|id[_-]?token|oauth|password|passwd|pwd|refresh[_-]?token|secret|token)\s*["']?\s*[:=]\s*["']?)[^&\s,"'`;}]*/gi, + `$1${REDACTED}` + ); + +const redactValue = ( + value: unknown, + seen: WeakSet, + parentKey?: string +): unknown => { + if (typeof value === 'string') return redactSensitiveString(value); + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return '[Circular]'; + seen.add(value); + + if (Array.isArray(value)) { + return value.map((item) => redactValue(item, seen, parentKey)); + } + + const redacted: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + redacted[key] = isSensitiveKey(key, parentKey) + ? REDACTED + : redactValue(nestedValue, seen, key); + } + return redacted; +}; + +export const redactSentryEvent = (event: T): T => { + const redacted = redactValue(event, new WeakSet()) as T; + if (redacted && typeof redacted === 'object' && 'user' in redacted) { + delete (redacted as Record).user; + } + return redacted; +}; + +const errorType = (error: unknown): string => { + if (!(error instanceof Error)) return 'Unknown'; + return error.constructor.name || error.name || 'UnknownError'; +}; + +const errorDescriptor = (error: unknown): string => [ + errorType(error), + error instanceof Error ? error.message : '', +].join(' ').toLowerCase(); + +const readErrorTag = (error: unknown, key: string): string | undefined => { + if (!error || typeof error !== 'object') return undefined; + const value = (error as Record)[key]; + return typeof value === 'string' && value.trim() ? value : undefined; +}; + +const FAILURE_KINDS = new Set([ + 'automated_bot_blocked', + 'browser_failure', + 'host_rejected', + 'lobby_timeout', + 'meeting_ended', + 'recording_failure', + 'rtms_failure', + 'sign_in_required', + 'technical_failure', + 'unsupported_meeting', + 'upload_failed', +]); + +export const classifyMeetingFailureKind = (error: unknown): MeetingFailureKind => { + const explicitKind = readErrorTag(error, 'failureKind') + ?? readErrorTag(error, 'failure_kind'); + if (explicitKind && FAILURE_KINDS.has(explicitKind as MeetingFailureKind)) { + return explicitKind as MeetingFailureKind; + } + + const reason = readErrorTag(error, 'reason'); + if (reason && FAILURE_KINDS.has(reason as MeetingFailureKind)) { + return reason as MeetingFailureKind; + } + const descriptor = errorDescriptor(error); + if (reason === 'automated_bot_blocked' || descriptor.includes('automated bot')) { + return 'automated_bot_blocked'; + } + + if (descriptor.includes('waitingatlobby') || descriptor.includes('waiting at lobby')) { + const bodyText = readErrorTag(error, 'documentBodyText') ?? ''; + return /denied|removed|rejected/i.test(bodyText) + ? 'host_rejected' + : 'lobby_timeout'; + } + + if (/host.?rejected|denied access|request (?:was )?denied/.test(descriptor)) { + return 'host_rejected'; + } + if (descriptor.includes('lobby') && descriptor.includes('timeout')) return 'lobby_timeout'; + if (descriptor.includes('sign in') || descriptor.includes('signin')) return 'sign_in_required'; + if (descriptor.includes('upload')) return 'upload_failed'; + if (descriptor.includes('rtms')) return 'rtms_failure'; + if ( + descriptor.includes('browser') + || descriptor.includes('context-closed') + || descriptor.includes('page-crashed') + || descriptor.includes('page-closed') + || descriptor.includes('cdp') + ) return 'browser_failure'; + if (descriptor.includes('meeting ended') || descriptor.includes('meeting_ended')) { + return 'meeting_ended'; + } + if (descriptor.includes('unsupported meeting') || descriptor.includes('unsupportedmeeting')) { + return 'unsupported_meeting'; + } + if (descriptor.includes('recording')) return 'recording_failure'; + return 'technical_failure'; +}; + +const failureLevel = (failureKind: MeetingFailureKind): 'error' | 'warning' => + failureKind === 'host_rejected' || failureKind === 'lobby_timeout' + ? 'warning' + : 'error'; + +export const inferMeetingFailurePhase = (error: unknown): string => { + const explicitPhase = readErrorTag(error, 'phase') ?? readErrorTag(error, 'stage'); + if (explicitPhase) return explicitPhase; + + const type = errorDescriptor(error); + if (type.includes('upload')) return 'upload'; + if (type.includes('lobby') || type.includes('waitingroom')) return 'waiting_room'; + if (type.includes('signin') || type.includes('unsupported') || type.includes('join')) return 'prejoin'; + if (type.includes('browser') || type.includes('context') || type.includes('cdp')) return 'browser'; + if (type.includes('rtms')) return 'fallback'; + return 'recording'; +}; + +export const inferMeetingFailureTransport = (error: unknown): RecordingTransport => { + const transport = readErrorTag(error, 'transport'); + if (transport === 'rtms' || transport === 'browser') return transport; + return errorDescriptor(error).includes('rtms') ? 'rtms' : 'browser'; +}; + +export const inferFallbackResult = (error: unknown): string => { + const fallbackResult = readErrorTag(error, 'fallbackResult') + ?? readErrorTag(error, 'fallback_result'); + if (fallbackResult) return fallbackResult; + return inferMeetingFailureTransport(error) === 'rtms' ? 'failed' : 'not_attempted'; +}; + +export const createSentryCorrelationId = ({ + userId, + eventId, + botId, + url, +}: MeetingIdentityContext): string => { + const entityId = botId ?? eventId; + return uuidv5(`${userId}:${entityId}:${url}`, uuidv5.DNS); +}; + +const normalizedException = (error: unknown): Error => { + if (error instanceof Error) return error; + return new Error(redactSensitiveString(String(error ?? 'Unknown error'))); +}; + +export class SentryReporter { + private enabled = false; + private environment = 'unknown'; + private release = 'unknown'; + private readonly reportedObjectFailures = new WeakMap>(); + private readonly reportedPrimitiveFailures = new Set(); + + constructor( + private readonly sdk: SentrySdk = Sentry, + private readonly env: NodeJS.ProcessEnv = process.env + ) {} + + initialize(): boolean { + const dsn = this.env.SENTRY_DSN?.trim(); + if (!dsn) return false; + + this.environment = this.env.SENTRY_ENVIRONMENT?.trim() + || this.env.NODE_ENV?.trim() + || 'unknown'; + this.release = this.env.SENTRY_RELEASE?.trim() || 'unknown'; + + try { + this.sdk.init({ + dsn, + environment: this.environment, + release: this.release === 'unknown' ? undefined : this.release, + tracesSampleRate: 0, + sendDefaultPii: false, + integrations: (defaultIntegrations) => defaultIntegrations.filter( + (integration) => !GLOBAL_HANDLER_INTEGRATIONS.has(integration.name) + ), + beforeSend: (event) => redactSentryEvent(event), + }); + this.enabled = true; + return true; + } catch (error) { + console.error( + 'Sentry initialization failed; monitoring is disabled.', + redactSensitiveString(normalizedException(error).message) + ); + return false; + } + } + + isEnabled(): boolean { + return this.enabled; + } + + private tags( + context: OperationalSentryContext & { + errorType: string; + failureKind: MeetingFailureKind; + fallbackResult?: string; + } + ): Record { + return { + provider: context.provider ?? 'system', + transport: context.transport ?? 'none', + error_type: context.errorType, + failure_kind: context.failureKind, + stage: context.phase, + fallback_result: context.fallbackResult ?? 'not_applicable', + environment: this.environment, + release: this.release, + team_id: context.teamId ?? 'none', + event_id: context.eventId ?? 'none', + bot_id: context.botId ?? 'none', + correlation_id: context.correlationId ?? 'none', + }; + } + + private capture( + error: unknown, + level: 'error' | 'warning', + tags: Record, + message?: string + ): void { + if (!this.enabled) return; + try { + this.sdk.withScope((scope) => { + scope.setLevel(level); + for (const [key, value] of Object.entries(tags)) scope.setTag(key, value); + scope.setFingerprint([tags.provider, tags.failure_kind]); + if (message) { + this.sdk.captureMessage(message, level); + } else { + this.sdk.captureException(normalizedException(error)); + } + }); + } catch (captureError) { + console.error( + 'Sentry capture failed.', + redactSensitiveString(normalizedException(captureError).message) + ); + } + } + + private failureKey(error: unknown, context: MeetingSentryContext): string { + return [ + context.correlationId, + context.provider, + context.transport, + context.phase, + context.fallbackResult, + errorType(error), + ].join(':'); + } + + private isDuplicatePermanentFailure(error: unknown, context: MeetingSentryContext): boolean { + const key = this.failureKey(error, context); + if (error !== null && (typeof error === 'object' || typeof error === 'function')) { + const previousKeys = this.reportedObjectFailures.get(error as object); + if (previousKeys?.has(key)) return true; + if (previousKeys) { + previousKeys.add(key); + } else { + this.reportedObjectFailures.set(error as object, new Set([key])); + } + return false; + } + + if (this.reportedPrimitiveFailures.has(key)) return true; + if (this.reportedPrimitiveFailures.size >= 1_000) { + this.reportedPrimitiveFailures.clear(); + } + this.reportedPrimitiveFailures.add(key); + return false; + } + + reportPermanentMeetingFailure(error: unknown, context: MeetingSentryContext): boolean { + if (!this.enabled || this.isDuplicatePermanentFailure(error, context)) return false; + const failureKind = classifyMeetingFailureKind(error); + this.capture(error, failureLevel(failureKind), this.tags({ + ...context, + errorType: errorType(error), + failureKind, + })); + return true; + } + + reportRecoveredZoomFallback( + context: Omit, + browserError?: unknown + ): boolean { + if (!this.enabled) return false; + this.capture(browserError, 'warning', this.tags({ + ...context, + provider: 'zoom', + transport: 'rtms', + errorType: errorType(browserError) === 'Unknown' + ? 'ZoomBrowserJoinBlockedError' + : errorType(browserError), + failureKind: 'automated_bot_blocked', + fallbackResult: 'recovered', + }), 'Zoom browser join was blocked; RTMS fallback recovered the recording.'); + return true; + } + + captureOperationalError(error: unknown, context: OperationalSentryContext): boolean { + if (!this.enabled) return false; + this.capture(error, 'error', this.tags({ + ...context, + errorType: errorType(error), + failureKind: classifyMeetingFailureKind(error), + })); + return true; + } + + async flush(timeoutMs = MAX_FLUSH_TIMEOUT_MS): Promise { + if (!this.enabled) return true; + const boundedTimeout = Math.min( + MAX_FLUSH_TIMEOUT_MS, + Math.max(0, Math.floor(timeoutMs)) + ); + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + this.sdk.flush(boundedTimeout), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), boundedTimeout); + }), + ]); + } catch { + return false; + } finally { + if (timeout) clearTimeout(timeout); + } + } +} + +const reporter = new SentryReporter(); + +export const initializeSentry = (): boolean => reporter.initialize(); +export const isSentryEnabled = (): boolean => reporter.isEnabled(); +export const reportPermanentMeetingFailure = ( + error: unknown, + context: MeetingSentryContext +): boolean => reporter.reportPermanentMeetingFailure(error, context); +export const reportRecoveredZoomFallback = ( + context: Omit, + browserError?: unknown +): boolean => reporter.reportRecoveredZoomFallback(context, browserError); +export const captureOperationalError = ( + error: unknown, + context: OperationalSentryContext +): boolean => reporter.captureOperationalError(error, context); +export const flushSentry = (timeoutMs = MAX_FLUSH_TIMEOUT_MS): Promise => + reporter.flush(timeoutMs); diff --git a/src/rtms/ZoomRtmsApi.test.ts b/src/rtms/ZoomRtmsApi.test.ts index 04da6499..35133749 100644 --- a/src/rtms/ZoomRtmsApi.test.ts +++ b/src/rtms/ZoomRtmsApi.test.ts @@ -1,23 +1,40 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import axios from 'axios'; -import config from '../config'; import { KnownError } from '../error'; import { ZoomRtmsApi } from './ZoomRtmsApi'; +import { ZoomRtmsApiCredentials } from './types'; const originalPatch = axios.patch; -const originalClientId = config.zoomRtms.clientId; -const originalAccessToken = config.zoomRtms.oauthAccessToken; +const originalPost = axios.post; + +const legacyCredentials = (): ZoomRtmsApiCredentials => ({ + source: 'legacy', + customerId: 'legacy', + rtmsClientId: 'rtms-client', + oauthAccessToken: 'access-token', +}); + +const customerCredentials = ( + customerId: string, + clientId = `${customerId}-client` +): ZoomRtmsApiCredentials => ({ + source: 'customer', + customerId, + rtmsClientId: 'rtms-client', + participantUserId: `${customerId}-operator`, + oauthAccountId: `${customerId}-account`, + oauthClientId: clientId, + oauthClientSecret: `${customerId}-secret`, +}); test.beforeEach(() => { - config.zoomRtms.clientId = 'rtms-client'; - config.zoomRtms.oauthAccessToken = 'access-token'; + ZoomRtmsApi.clearTokenCache(); }); test.afterEach(() => { axios.patch = originalPatch; - config.zoomRtms.clientId = originalClientId; - config.zoomRtms.oauthAccessToken = originalAccessToken; + axios.post = originalPost; }); const zoomError = (status: number, code?: number) => ({ @@ -40,7 +57,7 @@ test('retries transient start failures with bounded exponential backoff', async if (failure) throw failure; }) as typeof axios.patch; - const api = new ZoomRtmsApi(async (milliseconds) => { + const api = new ZoomRtmsApi(legacyCredentials(), async (milliseconds) => { waits.push(milliseconds); now += milliseconds; }, () => now); @@ -61,7 +78,7 @@ test('stops retrying when the caller timeout is reached', async () => { throw zoomError(500); }) as typeof axios.patch; - const api = new ZoomRtmsApi(async (milliseconds) => { + const api = new ZoomRtmsApi(legacyCredentials(), async (milliseconds) => { waits.push(milliseconds); now += milliseconds; }, () => now); @@ -79,7 +96,7 @@ test('does not retry fatal start failures or stop failures', async () => { throw zoomError(attempts === 1 ? 403 : 500); }) as typeof axios.patch; - const api = new ZoomRtmsApi(async () => { + const api = new ZoomRtmsApi(legacyCredentials(), async () => { waits += 1; }); @@ -88,3 +105,65 @@ test('does not retry fatal start failures or stop failures', async () => { assert.equal(attempts, 2); assert.equal(waits, 0); }); + +test('waits for external authorization after a customer-scoped 403', async () => { + axios.patch = (async () => { + throw zoomError(403, 2308); + }) as typeof axios.patch; + + const result = await new ZoomRtmsApi({ + ...customerCredentials('team-a'), + oauthAccessToken: 'customer-access-token', + }).start( + '123456789', + 10_000 + ); + assert.deepEqual(result, { + status: 'awaiting_external_authorization', + httpStatus: 403, + }); + + await assert.rejects( + new ZoomRtmsApi(legacyCredentials()).start('123456789', 10_000), + KnownError + ); +}); + +test('keeps unsupported or disabled RTMS 403 responses terminal', async () => { + axios.patch = (async () => { + throw zoomError(403, 13273); + }) as typeof axios.patch; + + await assert.rejects( + new ZoomRtmsApi({ + ...customerCredentials('team-a'), + oauthAccessToken: 'customer-access-token', + }).start('123456789', 10_000), + KnownError + ); +}); + +test('caches S2S OAuth tokens independently by credential identity', async () => { + const oauthClients: string[] = []; + const authorizationHeaders: string[] = []; + axios.post = (async (_url, _body, requestConfig) => { + const clientId = requestConfig?.auth?.username ?? ''; + oauthClients.push(clientId); + return { data: { access_token: `token-for-${clientId}`, expires_in: 3600 } }; + }) as typeof axios.post; + axios.patch = (async (_url, _body, requestConfig) => { + authorizationHeaders.push(String(requestConfig?.headers?.Authorization)); + }) as typeof axios.patch; + + const teamA = customerCredentials('team-a'); + await new ZoomRtmsApi(teamA).start('123456789'); + await new ZoomRtmsApi(teamA).start('123456789'); + await new ZoomRtmsApi(customerCredentials('team-b')).start('123456789'); + + assert.deepEqual(oauthClients, ['team-a-client', 'team-b-client']); + assert.deepEqual(authorizationHeaders, [ + 'Bearer token-for-team-a-client', + 'Bearer token-for-team-a-client', + 'Bearer token-for-team-b-client', + ]); +}); diff --git a/src/rtms/ZoomRtmsApi.ts b/src/rtms/ZoomRtmsApi.ts index 94053912..a33e32be 100644 --- a/src/rtms/ZoomRtmsApi.ts +++ b/src/rtms/ZoomRtmsApi.ts @@ -1,6 +1,7 @@ import axios, { AxiosError } from 'axios'; -import config from '../config'; +import crypto from 'crypto'; import { KnownError } from '../error'; +import { ZoomRtmsApiCredentials, ZoomRtmsStartResult } from './types'; type RtmsAction = 'start' | 'stop'; @@ -23,15 +24,20 @@ const asAxiosError = (error: unknown): AxiosError | undefined axios.isAxiosError(error) ? error : undefined; export class ZoomRtmsApi { - private cachedToken?: CachedToken; + private static readonly tokenCache = new Map(); constructor( + private readonly credentials: ZoomRtmsApiCredentials, private readonly wait: (milliseconds: number) => Promise = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), private readonly now: () => number = Date.now ) {} - async start(meetingId: string, retryTimeoutMs = 0): Promise { + static clearTokenCache(): void { + this.tokenCache.clear(); + } + + async start(meetingId: string, retryTimeoutMs = 0): Promise { const retryWindowMs = Number.isFinite(retryTimeoutMs) ? Math.max(0, retryTimeoutMs) : 0; @@ -45,8 +51,11 @@ export class ZoomRtmsApi { ? Math.max(1, Math.min(REQUEST_TIMEOUT_MS, remainingMs)) : REQUEST_TIMEOUT_MS; await this.sendStatusRequest(meetingId, 'start', requestTimeoutMs); - return; + return { status: 'requested' }; } catch (error: unknown) { + if (this.isAwaitingExternalAuthorization(error)) { + return { status: 'awaiting_external_authorization', httpStatus: 403 }; + } const remainingMs = deadline - this.now(); if (!this.isRetryableStartError(error) || remainingMs <= 0) { throw this.toKnownError(error, 'start'); @@ -74,7 +83,7 @@ export class ZoomRtmsApi { action: RtmsAction, timeout: number ): Promise { - const clientId = config.zoomRtms.clientId; + const clientId = this.credentials.rtmsClientId; if (!clientId) { throw new KnownError('ZOOM_RTMS_CLIENT_ID is required for RTMS', false, 0); } @@ -83,8 +92,8 @@ export class ZoomRtmsApi { const settings: { client_id: string; participant_user_id?: string } = { client_id: clientId, }; - if (config.zoomRtms.participantUserId) { - settings.participant_user_id = config.zoomRtms.participantUserId; + if (this.credentials.participantUserId) { + settings.participant_user_id = this.credentials.participantUserId; } await axios.patch( @@ -108,6 +117,14 @@ export class ZoomRtmsApi { || (status >= 500 && status < 600); } + private isAwaitingExternalAuthorization(error: unknown): boolean { + const axiosError = asAxiosError(error); + const code = Number(axiosError?.response?.data?.code); + return this.credentials.source === 'customer' + && axiosError?.response?.status === 403 + && (code === 2308 || code === 2309); + } + private toKnownError(error: unknown, action: RtmsAction): KnownError { if (error instanceof KnownError) return error; @@ -124,15 +141,17 @@ export class ZoomRtmsApi { } private async getAccessToken(): Promise { - if (config.zoomRtms.oauthAccessToken) { - return config.zoomRtms.oauthAccessToken; + if (this.credentials.oauthAccessToken) { + return this.credentials.oauthAccessToken; } - if (this.cachedToken && this.cachedToken.expiresAt > Date.now()) { - return this.cachedToken.value; + const cacheKey = this.tokenCacheKey(); + const cachedToken = ZoomRtmsApi.tokenCache.get(cacheKey); + if (cachedToken && cachedToken.expiresAt > this.now()) { + return cachedToken.value; } - const { oauthAccountId, oauthClientId, oauthClientSecret } = config.zoomRtms; + const { oauthAccountId, oauthClientId, oauthClientSecret } = this.credentials; if (!oauthAccountId || !oauthClientId || !oauthClientSecret) { throw new KnownError( 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the ZOOM_RTMS_OAUTH_ACCOUNT_ID/CLIENT_ID/CLIENT_SECRET settings', @@ -155,11 +174,12 @@ export class ZoomRtmsApi { } ); const expiresIn = Math.max(60, response.data.expires_in ?? 3600); - this.cachedToken = { + const cachedToken = { value: response.data.access_token, - expiresAt: Date.now() + (expiresIn - 30) * 1000, + expiresAt: this.now() + (expiresIn - 30) * 1000, }; - return this.cachedToken.value; + ZoomRtmsApi.tokenCache.set(cacheKey, cachedToken); + return cachedToken.value; } catch (error: unknown) { const axiosError = asAxiosError(error); const status = axiosError?.response?.status; @@ -173,4 +193,13 @@ export class ZoomRtmsApi { ); } } + + private tokenCacheKey(): string { + const identity = [ + this.credentials.oauthAccountId, + this.credentials.oauthClientId, + this.credentials.oauthClientSecret, + ].join('\0'); + return crypto.createHash('sha256').update(identity).digest('hex'); + } } diff --git a/src/rtms/ZoomRtmsCredentials.test.ts b/src/rtms/ZoomRtmsCredentials.test.ts new file mode 100644 index 00000000..751d1936 --- /dev/null +++ b/src/rtms/ZoomRtmsCredentials.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import config, { + parseZoomRtmsCustomerCredentials, + ZoomRtmsCustomerCredentials, +} from '../config'; +import { ZoomRtmsCredentialsMissingError } from '../error'; +import { + resolveZoomRtmsCredentials, + ZoomRtmsCredentialConfigurationError, +} from './ZoomRtmsCredentials'; + +const original = { + transport: config.zoomRecordingTransport, + rtmsClientId: config.zoomRtms.clientId, + oauthAccessToken: config.zoomRtms.oauthAccessToken, + oauthAccountId: config.zoomRtms.oauthAccountId, + oauthClientId: config.zoomRtms.oauthClientId, + oauthClientSecret: config.zoomRtms.oauthClientSecret, + participantUserId: config.zoomRtms.participantUserId, + customerCredentials: config.zoomRtms.customerCredentials, + customerCredentialErrors: config.zoomRtms.customerCredentialErrors, + customerCredentialsError: config.zoomRtms.customerCredentialsError, +}; + +const customer = (enabled = true): ZoomRtmsCustomerCredentials => ({ + enabled, + accountId: 'account-a', + clientId: 'oauth-client-a', + clientSecret: 'oauth-secret-a', + participantUserId: 'operator-a', +}); + +test.beforeEach(() => { + config.zoomRecordingTransport = 'browser'; + config.zoomRtms.clientId = 'rtms-client'; + config.zoomRtms.oauthAccessToken = undefined; + config.zoomRtms.oauthAccountId = undefined; + config.zoomRtms.oauthClientId = undefined; + config.zoomRtms.oauthClientSecret = undefined; + config.zoomRtms.participantUserId = undefined; + config.zoomRtms.customerCredentials = Object.create(null); + config.zoomRtms.customerCredentialErrors = Object.create(null); + config.zoomRtms.customerCredentialsError = undefined; +}); + +test.after(() => { + config.zoomRecordingTransport = original.transport; + config.zoomRtms.clientId = original.rtmsClientId; + config.zoomRtms.oauthAccessToken = original.oauthAccessToken; + config.zoomRtms.oauthAccountId = original.oauthAccountId; + config.zoomRtms.oauthClientId = original.oauthClientId; + config.zoomRtms.oauthClientSecret = original.oauthClientSecret; + config.zoomRtms.participantUserId = original.participantUserId; + config.zoomRtms.customerCredentials = original.customerCredentials; + config.zoomRtms.customerCredentialErrors = original.customerCredentialErrors; + config.zoomRtms.customerCredentialsError = original.customerCredentialsError; +}); + +test('parses customer credential JSON without exposing secret values in errors', () => { + const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ + 'team-a': customer(), + 'team-b': { enabled: true, clientSecret: 'must-not-appear' }, + })); + + assert.deepEqual({ ...parsed.credentials['team-a'] }, customer()); + assert.match(parsed.entryErrors['team-b'], /accountId/); + assert.equal(parsed.entryErrors['team-b'].includes('must-not-appear'), false); + + const malformed = parseZoomRtmsCustomerCredentials('{secret-value'); + assert.match(malformed.error ?? '', /valid JSON/); + assert.equal(malformed.error?.includes('secret-value'), false); +}); + +test('selects enabled team credentials for browser fallback', () => { + config.zoomRtms.customerCredentials['team-a'] = customer(); + + const selected = resolveZoomRtmsCredentials('team-a'); + assert.equal(selected.api.source, 'customer'); + assert.equal(selected.api.oauthAccountId, 'account-a'); + assert.equal(selected.api.oauthClientId, 'oauth-client-a'); + assert.equal(selected.eventScope.customerId, 'team-a'); + assert.equal(selected.eventScope.operatorId, 'operator-a'); +}); + +test('fails typed for missing, disabled, and invalid fallback credentials', () => { + assert.throws( + () => resolveZoomRtmsCredentials('missing-team'), + ZoomRtmsCredentialsMissingError + ); + + config.zoomRtms.customerCredentials['disabled-team'] = customer(false); + assert.throws( + () => resolveZoomRtmsCredentials('disabled-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'disabled' + ); + + config.zoomRtms.customerCredentialErrors['invalid-team'] = 'invalid fields'; + assert.throws( + () => resolveZoomRtmsCredentials('invalid-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_customer_configuration' + ); +}); + +test('uses legacy global OAuth only for explicit RTMS mode', () => { + config.zoomRecordingTransport = 'rtms'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.participantUserId = 'legacy-operator'; + + const selected = resolveZoomRtmsCredentials('team-without-mapping'); + assert.equal(selected.api.source, 'legacy'); + assert.equal(selected.api.oauthAccessToken, 'legacy-access-token'); + assert.equal(selected.eventScope.customerId, 'legacy'); + assert.equal(selected.eventScope.operatorId, 'legacy-operator'); +}); diff --git a/src/rtms/ZoomRtmsCredentials.ts b/src/rtms/ZoomRtmsCredentials.ts new file mode 100644 index 00000000..27645204 --- /dev/null +++ b/src/rtms/ZoomRtmsCredentials.ts @@ -0,0 +1,128 @@ +import config from '../config'; +import { KnownError, ZoomRtmsCredentialsMissingError } from '../error'; +import { ZoomRtmsApiCredentials, ZoomRtmsEventScope } from './types'; + +export type ZoomRtmsCredentialErrorReason = + | 'disabled' + | 'invalid_customer_configuration' + | 'invalid_global_configuration'; + +export class ZoomRtmsCredentialConfigurationError extends KnownError { + constructor( + public readonly reason: ZoomRtmsCredentialErrorReason, + public readonly teamId: string, + message: string + ) { + super(message, false, 0); + this.name = 'ZoomRtmsCredentialConfigurationError'; + } +} + +export interface ResolvedZoomRtmsCredentials { + api: ZoomRtmsApiCredentials; + eventScope: ZoomRtmsEventScope; +} + +const legacyCredentials = (): ResolvedZoomRtmsCredentials => { + const rtmsClientId = config.zoomRtms.clientId; + if (!rtmsClientId) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_global_configuration', + 'legacy', + 'ZOOM_RTMS_CLIENT_ID is required for RTMS' + ); + } + + const hasAccessToken = Boolean(config.zoomRtms.oauthAccessToken); + const hasServerCredentials = Boolean( + config.zoomRtms.oauthAccountId + && config.zoomRtms.oauthClientId + && config.zoomRtms.oauthClientSecret + ); + if (!hasAccessToken && !hasServerCredentials) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_global_configuration', + 'legacy', + 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the legacy Zoom RTMS OAuth account credentials' + ); + } + + return { + api: { + source: 'legacy', + customerId: 'legacy', + rtmsClientId, + participantUserId: config.zoomRtms.participantUserId, + oauthAccessToken: config.zoomRtms.oauthAccessToken, + oauthAccountId: config.zoomRtms.oauthAccountId, + oauthClientId: config.zoomRtms.oauthClientId, + oauthClientSecret: config.zoomRtms.oauthClientSecret, + }, + eventScope: { + customerId: 'legacy', + operatorId: config.zoomRtms.participantUserId, + }, + }; +}; + +export const resolveZoomRtmsCredentials = ( + teamId: string, + allowLegacy = config.zoomRecordingTransport === 'rtms' +): ResolvedZoomRtmsCredentials => { + const customer = config.zoomRtms.customerCredentials[teamId]; + if (customer?.enabled) { + const rtmsClientId = config.zoomRtms.clientId; + if (!rtmsClientId) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_global_configuration', + teamId, + 'ZOOM_RTMS_CLIENT_ID is required for customer RTMS recordings' + ); + } + + return { + api: { + source: 'customer', + customerId: teamId, + rtmsClientId, + participantUserId: customer.participantUserId, + oauthAccountId: customer.accountId, + oauthClientId: customer.clientId, + oauthClientSecret: customer.clientSecret, + }, + eventScope: { + customerId: teamId, + operatorId: customer.participantUserId, + }, + }; + } + + if (allowLegacy) return legacyCredentials(); + + if (config.zoomRtms.customerCredentialsError) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_customer_configuration', + teamId, + config.zoomRtms.customerCredentialsError + ); + } + + const entryError = config.zoomRtms.customerCredentialErrors[teamId]; + if (entryError) { + throw new ZoomRtmsCredentialConfigurationError( + 'invalid_customer_configuration', + teamId, + `Zoom RTMS credentials for team ${teamId} are invalid: ${entryError}` + ); + } + + if (customer && !customer.enabled) { + throw new ZoomRtmsCredentialConfigurationError( + 'disabled', + teamId, + `Zoom RTMS fallback is disabled for team ${teamId}` + ); + } + + throw new ZoomRtmsCredentialsMissingError(teamId); +}; diff --git a/src/rtms/ZoomRtmsEventStore.test.ts b/src/rtms/ZoomRtmsEventStore.test.ts index d10b56ae..9ad11cbf 100644 --- a/src/rtms/ZoomRtmsEventStore.test.ts +++ b/src/rtms/ZoomRtmsEventStore.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import config from '../config'; import { ZoomRtmsEventStore } from './ZoomRtmsEventStore'; -import { ZoomRtmsWebhookEvent } from './types'; +import { ZoomRtmsEventScope, ZoomRtmsWebhookEvent } from './types'; const originalRedisEnabled = config.isRedisEnabled; @@ -14,41 +14,125 @@ test.afterEach(() => { config.isRedisEnabled = originalRedisEnabled; }); -const startEvent = (eventTs: number): ZoomRtmsWebhookEvent => ({ +const scope = (customerId: string, operatorId: string): ZoomRtmsEventScope => ({ + customerId, + operatorId, +}); + +const startEvent = ( + eventTs: number, + operatorId = 'operator-id', + streamId = 'stream-id' +): ZoomRtmsWebhookEvent => ({ event: 'meeting.rtms_started', event_ts: eventTs, payload: { meeting_uuid: 'meeting-uuid', meeting_id: '12345678901', - operator_id: 'operator-id', - rtms_stream_id: 'stream-id', + operator_id: operatorId, + rtms_stream_id: streamId, server_urls: 'wss://rtms.zoom.us', }, }); test('routes initial and recovery start events to their respective queues', async () => { const store = new ZoomRtmsEventStore(); + const eventScope = scope('team-a', 'operator-id'); + await store.reserveMeeting('12345678901', 'owner-a', 60, eventScope); const initial = startEvent(1); assert.equal(await store.publish(initial), true); assert.equal(await store.publish(initial), false); - assert.deepEqual(await store.waitForMeetingStart('12345678901', 1), initial); + assert.deepEqual( + await store.waitForMeetingStart('12345678901', eventScope, 1), + initial + ); - await store.markStreamActive('stream-id'); + await store.markStreamActive('stream-id', eventScope); const recovery = startEvent(2); assert.equal(await store.publish(recovery), true); - assert.deepEqual(await store.waitForStreamEvent('stream-id', 1), recovery); + assert.deepEqual( + await store.waitForStreamEvent('stream-id', eventScope, 1), + recovery + ); }); test('releases meeting reservations only for their owner', async () => { const store = new ZoomRtmsEventStore(); + const eventScope = scope('team-a', 'operator-id'); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, eventScope), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, eventScope), false); + + await store.releaseMeeting('12345678901', 'owner-b', eventScope); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, eventScope), false); + + await store.releaseMeeting('12345678901', 'owner-a', eventScope); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, eventScope), true); +}); + +test('isolates initial events for parallel tenants by operator and customer', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'operator-a'); + const teamB = scope('team-b', 'operator-b'); + await store.reserveMeeting('12345678901', 'owner-a', 60, teamA); + await store.reserveMeeting('12345678901', 'owner-b', 60, teamB); + + const eventA = startEvent(10, 'operator-a', 'stream-a'); + const eventB = startEvent(11, 'operator-b', 'stream-b'); + await store.publish(eventA); + await store.publish(eventB); + + assert.deepEqual( + await store.waitForMeetingStart('12345678901', teamA, 1), + eventA + ); + assert.deepEqual( + await store.waitForMeetingStart('12345678901', teamB, 1), + eventB + ); +}); + +test('allows only one customer reservation for the same meeting and Zoom operator', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'shared-operator'); + const teamB = scope('team-b', 'shared-operator'); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, teamA), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, teamB), false); + + const event = startEvent(12, 'shared-operator', 'shared-stream'); + await store.publish(event); + assert.deepEqual(await store.waitForMeetingStart('12345678901', teamA, 1), event); +}); + +test('moves early stream stop events into the owning customer queue', async () => { + const store = new ZoomRtmsEventStore(); + const eventScope = scope('team-a', 'operator-id'); + const stopped: ZoomRtmsWebhookEvent = { + event: 'meeting.rtms_stopped', + event_ts: 12, + payload: { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'stream-id', + stop_reason: 6, + }, + }; - assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, 'operator-id'), true); - assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, 'operator-id'), false); + await store.publish(stopped); + await store.markStreamActive('stream-id', eventScope); + assert.deepEqual( + await store.waitForStreamEvent('stream-id', eventScope, 1), + stopped + ); +}); - await store.releaseMeeting('12345678901', 'owner-b', 'operator-id'); - assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, 'operator-id'), false); +test('prevents another customer from taking ownership of an active stream', async () => { + const store = new ZoomRtmsEventStore(); + await store.markStreamActive('stream-id', scope('team-a', 'operator-a')); - await store.releaseMeeting('12345678901', 'owner-a', 'operator-id'); - assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, 'operator-id'), true); + await assert.rejects( + store.markStreamActive('stream-id', scope('team-b', 'operator-b')), + /already owned by another customer/ + ); }); diff --git a/src/rtms/ZoomRtmsEventStore.ts b/src/rtms/ZoomRtmsEventStore.ts index 0cf1e2f2..c43b93c7 100644 --- a/src/rtms/ZoomRtmsEventStore.ts +++ b/src/rtms/ZoomRtmsEventStore.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import { createClient } from 'redis'; import config from '../config'; import { normalizeZoomMeetingId } from './utils'; -import { ZoomRtmsWebhookEvent } from './types'; +import { ZoomRtmsEventScope, ZoomRtmsWebhookEvent } from './types'; type RedisClient = ReturnType; type LocalWaiter = (event: ZoomRtmsWebhookEvent | null) => void; @@ -15,26 +15,45 @@ export class ZoomRtmsEventStore { private connectPromise?: Promise; private readonly localQueues = new Map(); private readonly localWaiters = new Map(); - private readonly localActiveStreams = new Set(); + private readonly localActiveStreams = new Map(); private readonly localDedupe = new Set(); private readonly localReservations = new Map(); + private readonly localRoutes = new Map>(); - private meetingQueue(meetingId: string): string { - return `${PREFIX}:meeting:${meetingId}:events`; + private digest(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); } - private streamQueue(streamId: string): string { - return `${PREFIX}:stream:${streamId}:events`; + private operatorId(scope: ZoomRtmsEventScope): string { + return scope.operatorId || '*'; + } + + private operatorRoutesKey(meetingId: string, operatorId: string): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${this.digest(operatorId)}:routes`; + } + + private meetingQueue( + meetingId: string, + operatorId: string, + customerDigest: string + ): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${this.digest(operatorId)}:customer:${customerDigest}:events`; + } + + private streamQueue(streamId: string, customerDigest: string): string { + return `${PREFIX}:stream:${streamId}:customer:${customerDigest}:events`; + } + + private pendingStreamQueue(streamId: string): string { + return `${PREFIX}:stream:${streamId}:pending-events`; } private activeStreamKey(streamId: string): string { return `${PREFIX}:stream:${streamId}:active`; } - private reservationKey(meetingId: string, operatorId?: string): string { - const operator = operatorId || 'token-user'; - const digest = crypto.createHash('sha256').update(operator).digest('hex'); - return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${digest}:owner`; + private reservationKey(meetingId: string, scope: ZoomRtmsEventScope): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${this.digest(this.operatorId(scope))}:owner`; } private dedupeKey(event: ZoomRtmsWebhookEvent): string { @@ -100,23 +119,28 @@ export class ZoomRtmsEventStore { await this.connect(); const client = this.getCommandClient(); const ttl = config.zoomRtms.eventTtlSeconds; - let queueKey: string; + let queueKeys: string[]; if (event.event === 'meeting.rtms_started') { const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); - const isActive = await client.exists(this.activeStreamKey(streamId)); - queueKey = isActive - ? this.streamQueue(streamId) - : this.meetingQueue(meetingId); + const activeCustomer = await client.get(this.activeStreamKey(streamId)); + queueKeys = activeCustomer + ? [this.streamQueue(streamId, activeCustomer)] + : await this.meetingQueuesForStart(meetingId, String(event.payload.operator_id ?? '')); } else { - queueKey = this.streamQueue(streamId); + const activeCustomer = await client.get(this.activeStreamKey(streamId)); + queueKeys = [activeCustomer + ? this.streamQueue(streamId, activeCustomer) + : this.pendingStreamQueue(streamId)]; } + if (queueKeys.length === 0) return true; + const published = await client.sendCommand([ 'EVAL', - 'if redis.call("SET", KEYS[1], "1", "EX", ARGV[2], "NX") then redis.call("RPUSH", KEYS[2], ARGV[1]); redis.call("EXPIRE", KEYS[2], ARGV[2]); return 1; end; return 0;', - '2', + 'if redis.call("SET", KEYS[1], "1", "EX", ARGV[2], "NX") then for i = 2, #KEYS do redis.call("RPUSH", KEYS[i], ARGV[1]); redis.call("EXPIRE", KEYS[i], ARGV[2]); end; return 1; end; return 0;', + String(queueKeys.length + 1), this.dedupeKey(event), - queueKey, + ...queueKeys, JSON.stringify(event), String(ttl), ]); @@ -125,21 +149,39 @@ export class ZoomRtmsEventStore { async waitForMeetingStart( meetingId: string, + scope: ZoomRtmsEventScope, timeoutSeconds: number ): Promise { - return this.waitFor(this.meetingQueue(normalizeZoomMeetingId(meetingId)), timeoutSeconds); + return this.waitFor( + this.meetingQueue(meetingId, this.operatorId(scope), this.digest(scope.customerId)), + timeoutSeconds + ); } async waitForStreamEvent( streamId: string, + scope: ZoomRtmsEventScope, timeoutSeconds: number ): Promise { - return this.waitFor(this.streamQueue(streamId), timeoutSeconds); + return this.waitFor( + this.streamQueue(streamId, this.digest(scope.customerId)), + timeoutSeconds + ); } - async markStreamActive(streamId: string): Promise { + async markStreamActive(streamId: string, scope: ZoomRtmsEventScope): Promise { + const customerDigest = this.digest(scope.customerId); if (!config.isRedisEnabled) { - this.localActiveStreams.add(streamId); + const activeCustomer = this.localActiveStreams.get(streamId); + if (activeCustomer && activeCustomer !== customerDigest) { + throw new Error('Zoom RTMS stream is already owned by another customer'); + } + this.localActiveStreams.set(streamId, customerDigest); + const pendingQueue = this.pendingStreamQueue(streamId); + const targetQueue = this.streamQueue(streamId, customerDigest); + const pendingEvents = this.localQueues.get(pendingQueue) ?? []; + this.localQueues.delete(pendingQueue); + pendingEvents.forEach((event) => this.enqueueLocal(targetQueue, event)); return; } @@ -148,53 +190,92 @@ export class ZoomRtmsEventStore { config.zoomRtms.eventTtlSeconds, config.maxRecordingDuration * 60 + 600 ); - await this.getCommandClient().set( + const claimed = await this.getCommandClient().sendCommand([ + 'EVAL', + 'local owner = redis.call("GET", KEYS[1]); if owner and owner ~= ARGV[1] then return -1; end; redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2]); local events = redis.call("LRANGE", KEYS[2], 0, -1); for _, event in ipairs(events) do redis.call("RPUSH", KEYS[3], event); end; if #events > 0 then redis.call("EXPIRE", KEYS[3], ARGV[3]); end; redis.call("DEL", KEYS[2]); return #events;', + '3', this.activeStreamKey(streamId), - '1', - { EX: activeTtl } - ); + this.pendingStreamQueue(streamId), + this.streamQueue(streamId, customerDigest), + customerDigest, + String(activeTtl), + String(config.zoomRtms.eventTtlSeconds), + ]); + if (Number(claimed) === -1) { + throw new Error('Zoom RTMS stream is already owned by another customer'); + } } - async markStreamInactive(streamId: string): Promise { + async markStreamInactive(streamId: string, scope?: ZoomRtmsEventScope): Promise { + const expectedCustomer = scope ? this.digest(scope.customerId) : undefined; if (!config.isRedisEnabled) { - this.localActiveStreams.delete(streamId); + if (!expectedCustomer || this.localActiveStreams.get(streamId) === expectedCustomer) { + this.localActiveStreams.delete(streamId); + } return; } await this.connect(); - await this.getCommandClient().del(this.activeStreamKey(streamId)); + if (!expectedCustomer) { + await this.getCommandClient().del(this.activeStreamKey(streamId)); + return; + } + await this.getCommandClient().sendCommand([ + 'EVAL', + 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]); end; return 0;', + '1', + this.activeStreamKey(streamId), + expectedCustomer, + ]); } async reserveMeeting( meetingId: string, ownerId: string, ttlSeconds: number, - operatorId?: string + scope: ZoomRtmsEventScope ): Promise { - const key = this.reservationKey(meetingId, operatorId); + const key = this.reservationKey(meetingId, scope); + const routesKey = this.operatorRoutesKey(meetingId, this.operatorId(scope)); + const customerDigest = this.digest(scope.customerId); if (!config.isRedisEnabled) { if (this.localReservations.has(key)) return false; this.localReservations.set(key, ownerId); + const routes = this.localRoutes.get(routesKey) ?? new Set(); + routes.add(customerDigest); + this.localRoutes.set(routesKey, routes); return true; } await this.connect(); - const result = await this.getCommandClient().set(key, ownerId, { - EX: Math.max(1, Math.ceil(ttlSeconds)), - NX: true, - }); - return result === 'OK'; + const ttl = String(Math.max(1, Math.ceil(ttlSeconds))); + const result = await this.getCommandClient().sendCommand([ + 'EVAL', + 'if redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[3], "NX") then redis.call("SADD", KEYS[2], ARGV[2]); redis.call("EXPIRE", KEYS[2], ARGV[3]); return 1; end; return 0;', + '2', + key, + routesKey, + ownerId, + customerDigest, + ttl, + ]); + return Number(result) === 1; } async releaseMeeting( meetingId: string, ownerId: string, - operatorId?: string + scope: ZoomRtmsEventScope ): Promise { - const key = this.reservationKey(meetingId, operatorId); + const key = this.reservationKey(meetingId, scope); + const routesKey = this.operatorRoutesKey(meetingId, this.operatorId(scope)); + const customerDigest = this.digest(scope.customerId); if (!config.isRedisEnabled) { if (this.localReservations.get(key) === ownerId) { this.localReservations.delete(key); + const routes = this.localRoutes.get(routesKey); + routes?.delete(customerDigest); + if (routes?.size === 0) this.localRoutes.delete(routesKey); } return; } @@ -202,10 +283,12 @@ export class ZoomRtmsEventStore { await this.connect(); await this.getCommandClient().sendCommand([ 'EVAL', - 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]); end; return 0;', - '1', + 'if redis.call("GET", KEYS[1]) == ARGV[1] then redis.call("DEL", KEYS[1]); redis.call("SREM", KEYS[2], ARGV[2]); if redis.call("SCARD", KEYS[2]) == 0 then redis.call("DEL", KEYS[2]); end; return 1; end; return 0;', + '2', key, + routesKey, ownerId, + customerDigest, ]); } @@ -234,7 +317,59 @@ export class ZoomRtmsEventStore { return result ? JSON.parse(result.element) as ZoomRtmsWebhookEvent : null; } + private async meetingQueuesForStart( + meetingId: string, + operatorId: string + ): Promise { + const operatorIds = operatorId ? [operatorId, '*'] : ['*']; + const customerRoutes = await Promise.all(operatorIds.map(async (routeOperatorId) => ({ + operatorId: routeOperatorId, + customerDigests: await this.getCommandClient().sMembers( + this.operatorRoutesKey(meetingId, routeOperatorId) + ), + }))); + return customerRoutes.flatMap(({ operatorId: routeOperatorId, customerDigests }) => + customerDigests.map((customerDigest) => + this.meetingQueue(meetingId, routeOperatorId, customerDigest) + ) + ); + } + + private localMeetingQueuesForStart( + meetingId: string, + operatorId: string + ): string[] { + const operatorIds = operatorId ? [operatorId, '*'] : ['*']; + return operatorIds.flatMap((routeOperatorId) => + Array.from( + this.localRoutes.get(this.operatorRoutesKey(meetingId, routeOperatorId)) ?? [] + ).map((customerDigest) => + this.meetingQueue(meetingId, routeOperatorId, customerDigest) + ) + ); + } + private publishLocally(event: ZoomRtmsWebhookEvent): boolean { + const streamId = event.payload.rtms_stream_id; + let queueKeys: string[]; + if (event.event === 'meeting.rtms_started') { + const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); + const activeCustomer = this.localActiveStreams.get(streamId); + queueKeys = activeCustomer + ? [this.streamQueue(streamId, activeCustomer)] + : this.localMeetingQueuesForStart( + meetingId, + String(event.payload.operator_id ?? '') + ); + } else { + const activeCustomer = this.localActiveStreams.get(streamId); + queueKeys = [activeCustomer + ? this.streamQueue(streamId, activeCustomer) + : this.pendingStreamQueue(streamId)]; + } + + if (queueKeys.length === 0) return true; + const dedupeKey = this.dedupeKey(event); if (this.localDedupe.has(dedupeKey)) return false; this.localDedupe.add(dedupeKey); @@ -244,17 +379,11 @@ export class ZoomRtmsEventStore { ); timer.unref(); - const streamId = event.payload.rtms_stream_id; - let queueKey: string; - if (event.event === 'meeting.rtms_started') { - const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); - queueKey = this.localActiveStreams.has(streamId) - ? this.streamQueue(streamId) - : this.meetingQueue(meetingId); - } else { - queueKey = this.streamQueue(streamId); - } + queueKeys.forEach((queueKey) => this.enqueueLocal(queueKey, event)); + return true; + } + private enqueueLocal(queueKey: string, event: ZoomRtmsWebhookEvent): void { const waiter = this.localWaiters.get(queueKey)?.shift(); if (waiter) { waiter(event); @@ -263,7 +392,6 @@ export class ZoomRtmsEventStore { queue.push(event); this.localQueues.set(queueKey, queue); } - return true; } private waitForLocal( diff --git a/src/rtms/ZoomRtmsTransport.test.ts b/src/rtms/ZoomRtmsTransport.test.ts index 3836491f..c631698e 100644 --- a/src/rtms/ZoomRtmsTransport.test.ts +++ b/src/rtms/ZoomRtmsTransport.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { isTerminalSdkLeaveReason, isTransientStopReason, + shouldRestartStoppedStream, } from './ZoomRtmsTransport'; test('treats non-retryable Zoom SDK stop reasons as terminal', () => { @@ -26,3 +27,14 @@ test('restarts the complete RTMS stream for every retryable Zoom stop reason', ( assert.equal(isTransientStopReason(reason), false, `reason ${reason}`); } }); + +test('restarts stopped streams for transient and unknown reasons only', () => { + for (const reason of [0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 24, 27, 960]) { + assert.equal(shouldRestartStoppedStream(reason), true, `reason ${reason}`); + } + + for (const reason of [1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 25, 26]) { + assert.equal(shouldRestartStoppedStream(reason), false, `reason ${reason}`); + } + assert.equal(shouldRestartStoppedStream(undefined), false); +}); diff --git a/src/rtms/ZoomRtmsTransport.ts b/src/rtms/ZoomRtmsTransport.ts index c5168715..74814319 100644 --- a/src/rtms/ZoomRtmsTransport.ts +++ b/src/rtms/ZoomRtmsTransport.ts @@ -6,13 +6,19 @@ import { KnownError } from '../error'; import { BotStatus } from '../types'; import { RtmsMediaRecorder } from './RtmsMediaRecorder'; import { ZoomRtmsApi } from './ZoomRtmsApi'; +import { resolveZoomRtmsCredentials } from './ZoomRtmsCredentials'; import { zoomRtmsEventStore } from './ZoomRtmsEventStore'; import { assertZoomRtmsSdkAvailable, ZoomRtmsSdkClient, ZoomRtmsSdkConnectionIssue, } from './ZoomRtmsSdkClient'; -import { ZoomRtmsPayload, ZoomRtmsStopReason, ZoomRtmsWebhookEvent } from './types'; +import { + ZoomRtmsEventScope, + ZoomRtmsPayload, + ZoomRtmsStopReason, + ZoomRtmsWebhookEvent, +} from './types'; import { extractZoomMeetingId } from './utils'; const START_EVENT_CLOCK_SKEW_MS = 5_000; @@ -32,6 +38,11 @@ export const isTerminalSdkLeaveReason = (reason: number): boolean => && reason <= LAST_KNOWN_STOP_REASON && !isTransientStopReason(reason); +export const shouldRestartStoppedStream = (reason?: number): reason is number => + typeof reason === 'number' + && reason !== ZoomRtmsStopReason.StreamRevoked + && !isTerminalSdkLeaveReason(reason); + const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -39,8 +50,6 @@ const sleep = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); export class ZoomRtmsTransport { - private readonly api = new ZoomRtmsApi(); - constructor(private readonly logger: Logger) {} async record( @@ -48,6 +57,9 @@ export class ZoomRtmsTransport { pushState: (state: BotStatus) => void ): Promise { const meetingId = extractZoomMeetingId(params.url); + const credentials = resolveZoomRtmsCredentials(params.teamId); + const eventScope = credentials.eventScope; + const api = new ZoomRtmsApi(credentials.api); let recorder: RtmsMediaRecorder | undefined; let client: ZoomRtmsSdkClient | undefined; let streamId: string | undefined; @@ -82,7 +94,7 @@ export class ZoomRtmsTransport { meetingId, reservationOwnerId, reservationTtlSeconds, - config.zoomRtms.participantUserId + eventScope ); if (!meetingReserved) { throw new KnownError( @@ -95,8 +107,14 @@ export class ZoomRtmsTransport { this.logger.info('Requesting Zoom RTMS stream', { meetingId }); const startRequestedAt = Date.now(); const initialJoinDeadline = startRequestedAt + config.joinWaitTime * 60 * 1000; - rtmsRequested = true; - await this.api.start(meetingId, Math.max(0, initialJoinDeadline - Date.now())); + const startResult = await api.start( + meetingId, + Math.max(0, initialJoinDeadline - Date.now()) + ); + rtmsRequested = startResult.status === 'requested'; + if (startResult.status === 'awaiting_external_authorization') { + this.logger.info('Waiting for external Zoom RTMS authorization', { meetingId }); + } const initialEventWaitMs = initialJoinDeadline - Date.now(); if (initialEventWaitMs <= 0) { throw new KnownError('Timed out requesting the Zoom RTMS stream', false, 0); @@ -105,6 +123,7 @@ export class ZoomRtmsTransport { const startEvent = await this.waitForFreshStart( meetingId, startRequestedAt, + eventScope, Math.ceil(initialEventWaitMs / 1000) ); if (!startEvent) { @@ -113,7 +132,8 @@ export class ZoomRtmsTransport { streamId = startEvent.payload.rtms_stream_id; lastStartPayload = startEvent.payload; - await zoomRtmsEventStore.markStreamActive(streamId); + rtmsRequested = true; + await zoomRtmsEventStore.markStreamActive(streamId, eventScope); client = new ZoomRtmsSdkClient(recorder, this.logger); await this.connectWithBackoff( client, @@ -147,18 +167,23 @@ export class ZoomRtmsTransport { maxAttempts: MAX_STREAM_RESTARTS, }); await activeClient.close(); - await zoomRtmsEventStore.markStreamInactive(stoppedStreamId); + await zoomRtmsEventStore.markStreamInactive(stoppedStreamId, eventScope); const restartRequestedAt = Date.now(); const restartJoinDeadline = Math.min( recordingDeadline, restartRequestedAt + config.joinWaitTime * 60 * 1000 ); - rtmsRequested = true; - await this.api.start( + const restartResult = await api.start( meetingId, Math.max(0, restartJoinDeadline - Date.now()) ); + rtmsRequested = restartResult.status === 'requested'; + if (restartResult.status === 'awaiting_external_authorization') { + this.logger.info('Waiting for external Zoom RTMS restart authorization', { + meetingId, + }); + } const restartEventWaitMs = restartJoinDeadline - Date.now(); if (restartEventWaitMs <= 0) { throw new Error('Timed out requesting the Zoom RTMS stream restart'); @@ -166,6 +191,7 @@ export class ZoomRtmsTransport { const restartedEvent = await this.waitForFreshStart( meetingId, restartRequestedAt, + eventScope, Math.ceil(restartEventWaitMs / 1000) ); if (!restartedEvent) { @@ -174,7 +200,8 @@ export class ZoomRtmsTransport { streamId = restartedEvent.payload.rtms_stream_id; lastStartPayload = restartedEvent.payload; - await zoomRtmsEventStore.markStreamActive(streamId); + rtmsRequested = true; + await zoomRtmsEventStore.markStreamActive(streamId, eventScope); await this.connectWithBackoff( activeClient, lastStartPayload, @@ -194,11 +221,15 @@ export class ZoomRtmsTransport { const event = await zoomRtmsEventStore.waitForStreamEvent( streamId, + eventScope, Math.min(STREAM_EVENT_POLL_SECONDS, remainingSeconds) ); if (event?.event === 'meeting.rtms_stopped') { rtmsRequested = false; + if (typeof event.payload.stop_reason !== 'number') { + throw new KnownError('Zoom RTMS stop event is missing its reason', false, 0); + } if (event.payload.stop_reason === ZoomRtmsStopReason.StreamRevoked) { throw new KnownError( 'Zoom RTMS consent was revoked; the recording was discarded', @@ -207,7 +238,7 @@ export class ZoomRtmsTransport { ); } - if (isTransientStopReason(event.payload.stop_reason)) { + if (shouldRestartStoppedStream(event.payload.stop_reason)) { await restartTerminatedStream(event.payload.stop_reason); continue; } @@ -313,7 +344,7 @@ export class ZoomRtmsTransport { meetingId, streamId, }); - await this.api.stop(meetingId); + await api.stop(meetingId); rtmsRequested = false; } @@ -337,12 +368,12 @@ export class ZoomRtmsTransport { } finally { await client?.close(); if (streamId) { - await zoomRtmsEventStore.markStreamInactive(streamId).catch((error) => { + await zoomRtmsEventStore.markStreamInactive(streamId, eventScope).catch((error) => { this.logger.warn('Unable to release Zoom RTMS stream ownership', { error, streamId }); }); } if (rtmsRequested && !terminalStopReceived) { - await this.api.stop(meetingId).catch((error) => { + await api.stop(meetingId).catch((error) => { this.logger.warn('Unable to stop Zoom RTMS during cleanup', { error, meetingId }); }); } @@ -353,7 +384,7 @@ export class ZoomRtmsTransport { await zoomRtmsEventStore.releaseMeeting( meetingId, reservationOwnerId, - config.zoomRtms.participantUserId + eventScope ).catch((error) => { this.logger.warn('Unable to release Zoom RTMS meeting reservation', { error, @@ -367,6 +398,7 @@ export class ZoomRtmsTransport { private async waitForFreshStart( meetingId: string, requestedAt: number, + eventScope: ZoomRtmsEventScope, timeoutSeconds: number ): Promise { const deadline = Date.now() + Math.max(1, timeoutSeconds) * 1000; @@ -374,6 +406,7 @@ export class ZoomRtmsTransport { while (Date.now() < deadline) { const event = await zoomRtmsEventStore.waitForMeetingStart( meetingId, + eventScope, Math.max(1, Math.ceil((deadline - Date.now()) / 1000)) ); if (!event) return null; @@ -392,7 +425,7 @@ export class ZoomRtmsTransport { continue; } - const expectedOperatorId = config.zoomRtms.participantUserId; + const expectedOperatorId = eventScope.operatorId; if ( expectedOperatorId && String(event.payload.operator_id ?? '') !== expectedOperatorId diff --git a/src/rtms/types.ts b/src/rtms/types.ts index c433ef31..30f12d64 100644 --- a/src/rtms/types.ts +++ b/src/rtms/types.ts @@ -29,3 +29,23 @@ export interface ZoomWebhookBody { event_ts?: number; payload?: Record; } + +export interface ZoomRtmsApiCredentials { + source: 'customer' | 'legacy'; + customerId: string; + rtmsClientId: string; + participantUserId?: string; + oauthAccessToken?: string; + oauthAccountId?: string; + oauthClientId?: string; + oauthClientSecret?: string; +} + +export interface ZoomRtmsEventScope { + customerId: string; + operatorId?: string; +} + +export type ZoomRtmsStartResult = + | { status: 'requested' } + | { status: 'awaiting_external_authorization'; httpStatus: 403 }; diff --git a/src/rtms/webhook.test.ts b/src/rtms/webhook.test.ts index 4eafca94..e070d8b7 100644 --- a/src/rtms/webhook.test.ts +++ b/src/rtms/webhook.test.ts @@ -58,8 +58,15 @@ test('validates Zoom challenges and signed RTMS events', async () => { const originalRedisEnabled = config.isRedisEnabled; config.zoomRtms.webhookSecret = 'webhook-secret'; config.isRedisEnabled = false; + const eventScope = { customerId: 'team-a', operatorId: 'operator-id' }; try { + await zoomRtmsEventStore.reserveMeeting( + '12345678901', + 'webhook-test-owner', + 60, + eventScope + ); const challenge = await send({ body: { event: 'endpoint.url_validation', @@ -95,7 +102,13 @@ test('validates Zoom challenges and signed RTMS events', async () => { }); assert.equal(signed.statusCode, 204); assert.equal( - (await zoomRtmsEventStore.waitForMeetingStart('12345678901', 1))?.payload.rtms_stream_id, + ( + await zoomRtmsEventStore.waitForMeetingStart( + '12345678901', + eventScope, + 1 + ) + )?.payload.rtms_stream_id, 'stream-id' ); @@ -106,6 +119,11 @@ test('validates Zoom challenges and signed RTMS events', async () => { }); assert.equal(rejected.statusCode, 401); } finally { + await zoomRtmsEventStore.releaseMeeting( + '12345678901', + 'webhook-test-owner', + eventScope + ); config.zoomRtms.webhookSecret = originalSecret; config.isRedisEnabled = originalRedisEnabled; } diff --git a/src/tasks/RecordingTask.ts b/src/tasks/RecordingTask.ts index e501e782..5619f137 100644 --- a/src/tasks/RecordingTask.ts +++ b/src/tasks/RecordingTask.ts @@ -184,6 +184,29 @@ export class RecordingTask extends Task { loneTest = setInterval(() => { try { + const terminalText = (dom.body?.innerText ?? '').toLowerCase(); + const participantWasRemoved = [ + 'you have been removed from the meeting', + 'you have been removed by the host', + 'the host has removed you from this meeting', + ].some((text) => terminalText.includes(text)); + const meetingHasEnded = [ + 'this meeting has been ended by host', + 'this meeting has ended', + 'meeting has been ended', + ].some((text) => terminalText.includes(text)); + + if (participantWasRemoved || meetingHasEnded) { + console.log('Detected terminal Zoom meeting state; finalizing recording.', { + userId, + reason: participantWasRemoved ? 'participant_removed' : 'meeting_ended', + }); + clearInterval(loneTest); + monitor = false; + void stopTheRecording(); + return; + } + // Detect and click blocking "OK" buttons const okButton = Array.from(dom.querySelectorAll('button')) .filter((el) => el?.innerText?.trim()?.match(/^OK/i)); diff --git a/src/util/logger.ts b/src/util/logger.ts index 4735eddb..8203e367 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -1,9 +1,48 @@ import { ConsoleMessage } from 'playwright'; import { createLogger, format, transports, Logger } from 'winston'; import { v5 as uuidv5, v4 } from 'uuid'; +import { redactSensitiveString } from '../monitoring/sentry'; const NAMESPACE = uuidv5.DNS; +const SENSITIVE_KEY = /(accountid|authorization|bearer|clientid|cookie|oauth|participantuserid|password|secret|token|bodytext|documentbodytext|rawbody)/i; +const URL_KEY = /url/i; + +export const sanitizeUrl = (value: string): string => { + try { + const parsed = new URL(value); + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return value.replace(/([?&](?:pwd|password|token|code|key|secret)=)[^&#\s]*/gi, '$1[REDACTED]'); + } +}; + +const sanitizeString = (value: string): string => + redactSensitiveString(value).replace( + /\b(?:https?|wss?|zoommtg):\/\/[^\s"'<>]+/gi, + (match) => sanitizeUrl(match) + ); + +export const redactSensitive = (value: unknown, key = '', seen = new WeakSet()): unknown => { + if (SENSITIVE_KEY.test(key)) return '[REDACTED]'; + if (typeof value === 'string') return URL_KEY.test(key) ? sanitizeUrl(value) : sanitizeString(value); + if (value === null || typeof value !== 'object') return value; + if (value instanceof Error) { + return { name: value.name, message: sanitizeString(value.message) }; + } + if (seen.has(value)) return '[Circular]'; + seen.add(value); + if (Array.isArray(value)) return value.map((entry) => redactSensitive(entry, key, seen)); + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactSensitive(entryValue, entryKey, seen), + ]) + ); +}; + export function loggerFactory(correlationId: string, botType?: string): Logger { return createLogger({ format: format.combine( @@ -16,9 +55,10 @@ export function loggerFactory(correlationId: string, botType?: string): Logger { return info; })(), format.printf(({ timestamp, level, message, correlationId, botType, ...meta }) => { - const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : ''; + const redactedMeta = redactSensitive(meta); + const metaStr = Object.keys(meta).length ? JSON.stringify(redactedMeta) : ''; const botTypeStr = botType ? ` [botType: ${botType}]` : ''; - return `[${timestamp}] [${level}] [correlationId: ${correlationId}]${botTypeStr} ${message} ${metaStr}`; + return `[${timestamp}] [${level}] [correlationId: ${correlationId}]${botTypeStr} ${sanitizeString(String(message))} ${metaStr}`; }), ), transports: [new transports.Console()], @@ -27,23 +67,23 @@ export function loggerFactory(correlationId: string, botType?: string): Logger { export const browserLogCaptureCallback = async (logger: Logger, msg: ConsoleMessage) => { try { - const values: unknown[] = []; - for (const arg of msg?.args()) { - values.push(await arg?.jsonValue()); - } + const metadata = { + browserConsoleType: msg.type(), + source: sanitizeUrl(msg.location().url || 'unknown'), + }; switch (msg?.type()) { case 'error': - logger.error(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.error('[Playwright browser console error]', metadata); break; case 'warning': - logger.warn(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.warn('[Playwright browser console warning]', metadata); break; case 'info': case 'log': - logger.info(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.info('[Playwright browser console message]', metadata); break; default: - logger.info(`[Playwright chrome logger] ${msg.text()}`, ...values); + logger.info('[Playwright browser console event]', metadata); break; } } catch(err) { @@ -73,7 +113,7 @@ export const createCorrelationId = ({ userId, eventId, botId, - url, + url: sanitizeUrl(url), teamId, method: 'v5' }); @@ -86,7 +126,7 @@ export const createCorrelationId = ({ userId, eventId, botId, - url, + url: sanitizeUrl(url), teamId, method: 'v4' }); From bc3acc690b83554e31519f7ff1cc5b5cfb53f463 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 14:05:42 +0200 Subject: [PATCH 40/53] ci: install ffmpeg for media tests --- .github/workflows/docker-build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index e8b84be9..a3b35ee2 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -34,6 +34,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Install media test dependencies + run: | + sudo apt-get update + sudo apt-get install --yes ffmpeg + - name: Run tests run: npm test From a2874adf13eb120e44e116bb2bb6247ea00b5f17 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 15:57:12 +0200 Subject: [PATCH 41/53] fix(zoom): handle prejoin media prompt --- package-lock.json | 4 +- package.json | 2 +- src/bots/ZoomBot.ts | 8 +++- src/bots/zoomJoinState.test.ts | 6 ++- src/bots/zoomJoinState.ts | 1 + src/bots/zoomPrejoinModal.test.ts | 62 +++++++++++++++++++++++++++++++ src/bots/zoomPrejoinModal.ts | 33 ++++++++++++++++ 7 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 src/bots/zoomPrejoinModal.test.ts create mode 100644 src/bots/zoomPrejoinModal.ts diff --git a/package-lock.json b/package-lock.json index b1f8add5..a28aec33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.6", + "version": "1.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.6", + "version": "1.3.7", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index a53310e2..2980f1d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.6", + "version": "1.3.7", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index e73130f9..068363e5 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -24,6 +24,7 @@ import { ZoomJoinState, } from './zoomJoinState'; import { reportRecoveredZoomFallback } from '../monitoring/sentry'; +import { dismissZoomOptionalMediaPrompt } from './zoomPrejoinModal'; class BotBase extends AbstractMeetBot { protected page: Page; @@ -408,10 +409,15 @@ export class ZoomBot extends BotBase { this._logger.info('Waiting for the input field to be visible...'); await iframe.waitForSelector('input[type="text"]', { timeout: 60000 }); + if (await dismissZoomOptionalMediaPrompt(iframe)) { + this._logger.info('Continuing Zoom pre-join without microphone and camera...'); + } + this._logger.info('Filling the input field with the name...'); - await iframe.fill('input[type="text"]', name ? name : 'ScreenApp Notetaker'); + await iframe.fill('input[type="text"]', name ? name : 'winkk AI Notetaker'); this._logger.info('Clicking the "Join" button...'); + await dismissZoomOptionalMediaPrompt(iframe, 500); const joinButton = iframe.locator('button', { hasText: 'Join' }).first(); await joinButton.waitFor({ timeout: 15000 }); await joinButton.click(); diff --git a/src/bots/zoomJoinState.test.ts b/src/bots/zoomJoinState.test.ts index 88ccafda..cc70e9fa 100644 --- a/src/bots/zoomJoinState.test.ts +++ b/src/bots/zoomJoinState.test.ts @@ -3,11 +3,15 @@ import test from 'node:test'; import { ZoomBrowserJoinBlockedError } from '../error'; import { classifyZoomJoinState, shouldUseZoomRtmsFallback } from './zoomJoinState'; -test('classifies only an explicit Zoom automated-bot rejection as bot blocked', () => { +test('classifies explicit Zoom browser-security blocks as bot blocked', () => { assert.equal( classifyZoomJoinState('Automated bots aren\'t allowed to join this meeting. Sign in to join.'), 'automated_bot_blocked' ); + assert.equal( + classifyZoomJoinState('Zoom needs to review the security of your connection before proceeding.'), + 'automated_bot_blocked' + ); assert.equal(classifyZoomJoinState('Sign in to join this meeting'), 'sign_in_required'); }); diff --git a/src/bots/zoomJoinState.ts b/src/bots/zoomJoinState.ts index 9c87c427..d6b26aef 100644 --- a/src/bots/zoomJoinState.ts +++ b/src/bots/zoomJoinState.ts @@ -18,6 +18,7 @@ export const classifyZoomJoinState = (bodyText?: string | null): ZoomJoinState = if ( /automated bots? (?:are not|aren't) allowed to join/.test(text) || /this meeting (?:does not|doesn't) allow automated bots?/.test(text) + || text.includes('zoom needs to review the security of your connection before proceeding') ) { return 'automated_bot_blocked'; } diff --git a/src/bots/zoomPrejoinModal.test.ts b/src/bots/zoomPrejoinModal.test.ts new file mode 100644 index 00000000..fa3be22a --- /dev/null +++ b/src/bots/zoomPrejoinModal.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Page } from 'playwright'; +import { + dismissZoomOptionalMediaPrompt, + isZoomOptionalMediaPromptText, +} from './zoomPrejoinModal'; + +test('recognizes only the optional Zoom media permission prompt', () => { + assert.equal(isZoomOptionalMediaPromptText( + 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera' + ), true); + assert.equal(isZoomOptionalMediaPromptText( + 'The host requires you to consent before joining. Continue' + ), false); +}); + +test('dismisses the optional Zoom media prompt through its accessible control', async () => { + let clicks = 0; + let hiddenWaits = 0; + const button = { + first: () => button, + isVisible: async () => true, + click: async () => { clicks += 1; }, + }; + const prompt = { + last: () => prompt, + isVisible: async () => true, + innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + getByRole: () => button, + waitFor: async () => { hiddenWaits += 1; }, + }; + const filtered = { last: () => prompt }; + const root = { + locator: () => ({ filter: () => filtered }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), true); + assert.equal(clicks, 1); + assert.equal(hiddenWaits, 1); +}); + +test('does not interact with an unknown Zoom modal', async () => { + let clicks = 0; + const button = { + first: () => button, + isVisible: async () => true, + click: async () => { clicks += 1; }, + }; + const prompt = { + last: () => prompt, + isVisible: async () => true, + innerText: async () => 'The host requires you to consent before joining. Continue', + getByRole: () => button, + }; + const root = { + locator: () => ({ filter: () => ({ last: () => prompt }) }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), false); + assert.equal(clicks, 0); +}); diff --git a/src/bots/zoomPrejoinModal.ts b/src/bots/zoomPrejoinModal.ts new file mode 100644 index 00000000..64fcb180 --- /dev/null +++ b/src/bots/zoomPrejoinModal.ts @@ -0,0 +1,33 @@ +import type { Frame, Page } from 'playwright'; + +type ZoomPrejoinRoot = Frame | Page; + +const OPTIONAL_MEDIA_PROMPT = /Do you want people to see you in the meeting\?/i; +const CONTINUE_WITHOUT_MEDIA = /^(?:Continue without microphone and camera|Ohne Mikrofon und Kamera fortfahren)$/i; + +export const isZoomOptionalMediaPromptText = (text?: string | null): boolean => + OPTIONAL_MEDIA_PROMPT.test(text ?? '') + && /microphone and camera/i.test(text ?? '') + && /Continue without microphone and camera/i.test(text ?? ''); + +export async function dismissZoomOptionalMediaPrompt( + root: ZoomPrejoinRoot, + timeout = 2_500, +): Promise { + const prompt = root + .locator('.ReactModal__Overlay.ReactModal__Overlay--after-open') + .filter({ hasText: OPTIONAL_MEDIA_PROMPT }) + .last(); + + if (!await prompt.isVisible({ timeout }).catch(() => false)) return false; + if (!isZoomOptionalMediaPromptText(await prompt.innerText().catch(() => ''))) return false; + + const continueWithoutMedia = prompt + .getByRole('button', { name: CONTINUE_WITHOUT_MEDIA }) + .first(); + if (!await continueWithoutMedia.isVisible({ timeout: 1_000 }).catch(() => false)) return false; + + await continueWithoutMedia.click(); + await prompt.waitFor({ state: 'hidden', timeout: 5_000 }); + return true; +} From 5655656a4fec981022642813262ed21bac37d768 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 18:09:14 +0200 Subject: [PATCH 42/53] fix(zoom): handle reusable prejoin overlay --- package-lock.json | 4 +-- package.json | 2 +- src/bots/zoomPrejoinModal.test.ts | 45 ++++++++++++++++++++++++++++++- src/bots/zoomPrejoinModal.ts | 6 +++-- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a28aec33..da3a7c05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.7", + "version": "1.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.7", + "version": "1.3.8", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 2980f1d4..1708705c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.7", + "version": "1.3.8", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/zoomPrejoinModal.test.ts b/src/bots/zoomPrejoinModal.test.ts index fa3be22a..992c3836 100644 --- a/src/bots/zoomPrejoinModal.test.ts +++ b/src/bots/zoomPrejoinModal.test.ts @@ -22,13 +22,13 @@ test('dismisses the optional Zoom media prompt through its accessible control', first: () => button, isVisible: async () => true, click: async () => { clicks += 1; }, + waitFor: async () => { hiddenWaits += 1; }, }; const prompt = { last: () => prompt, isVisible: async () => true, innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', getByRole: () => button, - waitFor: async () => { hiddenWaits += 1; }, }; const filtered = { last: () => prompt }; const root = { @@ -40,6 +40,49 @@ test('dismisses the optional Zoom media prompt through its accessible control', assert.equal(hiddenWaits, 1); }); +test('does not fail when Zoom keeps the reusable modal overlay visible', async () => { + let overlayHiddenWaits = 0; + const button = { + first: () => button, + isVisible: async () => true, + click: async () => undefined, + waitFor: async () => undefined, + }; + const prompt = { + last: () => prompt, + isVisible: async () => true, + innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + getByRole: () => button, + waitFor: async () => { overlayHiddenWaits += 1; throw new Error('overlay remained visible'); }, + }; + const root = { + locator: () => ({ filter: () => ({ last: () => prompt }) }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), true); + assert.equal(overlayHiddenWaits, 0); +}); + +test('returns false instead of throwing when Zoom does not dismiss the media prompt', async () => { + const button = { + first: () => button, + isVisible: async () => true, + click: async () => undefined, + waitFor: async () => { throw new Error('button remained visible'); }, + }; + const prompt = { + last: () => prompt, + isVisible: async () => true, + innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + getByRole: () => button, + }; + const root = { + locator: () => ({ filter: () => ({ last: () => prompt }) }), + } as unknown as Page; + + assert.equal(await dismissZoomOptionalMediaPrompt(root), false); +}); + test('does not interact with an unknown Zoom modal', async () => { let clicks = 0; const button = { diff --git a/src/bots/zoomPrejoinModal.ts b/src/bots/zoomPrejoinModal.ts index 64fcb180..f84ee36d 100644 --- a/src/bots/zoomPrejoinModal.ts +++ b/src/bots/zoomPrejoinModal.ts @@ -28,6 +28,8 @@ export async function dismissZoomOptionalMediaPrompt( if (!await continueWithoutMedia.isVisible({ timeout: 1_000 }).catch(() => false)) return false; await continueWithoutMedia.click(); - await prompt.waitFor({ state: 'hidden', timeout: 5_000 }); - return true; + return await continueWithoutMedia + .waitFor({ state: 'hidden', timeout: 5_000 }) + .then(() => true) + .catch(() => false); } From 914b56608de2e22b61ccba3e7287a1ebb2e60946 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 19:03:16 +0200 Subject: [PATCH 43/53] fix(zoom): handle sequential media prompts --- package-lock.json | 4 +- package.json | 2 +- src/bots/ZoomBot.ts | 10 +- src/bots/zoomPrejoinModal.test.ts | 226 +++++++++++++++++++++++++----- src/bots/zoomPrejoinModal.ts | 108 +++++++++++--- 5 files changed, 293 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index da3a7c05..446a0dbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.8", + "version": "1.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.8", + "version": "1.3.9", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 1708705c..2bd1732a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.8", + "version": "1.3.9", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 068363e5..526704f4 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -24,7 +24,10 @@ import { ZoomJoinState, } from './zoomJoinState'; import { reportRecoveredZoomFallback } from '../monitoring/sentry'; -import { dismissZoomOptionalMediaPrompt } from './zoomPrejoinModal'; +import { + clickZoomJoinWithOptionalMediaPromptRetry, + dismissZoomOptionalMediaPrompt, +} from './zoomPrejoinModal'; class BotBase extends AbstractMeetBot { protected page: Page; @@ -420,7 +423,10 @@ export class ZoomBot extends BotBase { await dismissZoomOptionalMediaPrompt(iframe, 500); const joinButton = iframe.locator('button', { hasText: 'Join' }).first(); await joinButton.waitFor({ timeout: 15000 }); - await joinButton.click(); + await clickZoomJoinWithOptionalMediaPromptRetry( + iframe, + () => joinButton.click({ timeout: 2_000 }) + ); this.joinState = 'waiting_room'; const lobbyDeadline = Date.now() + config.joinWaitTime * 60 * 1000; diff --git a/src/bots/zoomPrejoinModal.test.ts b/src/bots/zoomPrejoinModal.test.ts index 992c3836..2070e9e9 100644 --- a/src/bots/zoomPrejoinModal.test.ts +++ b/src/bots/zoomPrejoinModal.test.ts @@ -2,61 +2,164 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { Page } from 'playwright'; import { + clickZoomJoinWithOptionalMediaPromptRetry, dismissZoomOptionalMediaPrompt, isZoomOptionalMediaPromptText, } from './zoomPrejoinModal'; +type PromptKind = 'see' | 'hear'; + +const promptTexts: Record = { + see: 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + hear: 'Do you want people to hear you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', +}; + +const createPromptRoot = ( + initialPrompt?: PromptKind, + transition?: { from: PromptKind; to: PromptKind; delay: number }, +) => { + let activePrompt = initialPrompt; + let buttonVisible = activePrompt !== undefined; + let pendingTransition: Promise | undefined; + const clicks: PromptKind[] = []; + + const waitForState = async (predicate: () => boolean, timeout: number) => { + if (predicate()) return; + const transitionPromise = pendingTransition; + if (!transitionPromise) throw new Error('state is not available'); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('state timed out')), timeout); + transitionPromise.then(() => { + clearTimeout(timer); + resolve(); + }); + }); + if (!predicate()) throw new Error('state is not available'); + }; + + const locatorFor = (hasText: RegExp) => { + const matchesActivePrompt = () => activePrompt !== undefined + && hasText.test(promptTexts[activePrompt]); + const button = { + first: () => button, + click: async () => { + if (!activePrompt || !matchesActivePrompt() || !buttonVisible) { + throw new Error('button is not visible'); + } + + const clickedPrompt = activePrompt; + clicks.push(clickedPrompt); + buttonVisible = false; + if (transition && clickedPrompt === transition.from) { + pendingTransition = new Promise(resolve => { + setTimeout(() => { + activePrompt = transition.to; + buttonVisible = true; + pendingTransition = undefined; + resolve(); + }, transition.delay); + }); + } else { + activePrompt = undefined; + } + }, + waitFor: async ({ state, timeout = 0 }: { state: 'visible' | 'hidden'; timeout?: number }) => { + await waitForState( + () => state === 'visible' + ? matchesActivePrompt() && buttonVisible + : !matchesActivePrompt() || !buttonVisible, + timeout + ); + }, + }; + const locator = { + last: () => locator, + waitFor: async ({ state, timeout = 0 }: { state: 'visible' | 'hidden'; timeout?: number }) => { + await waitForState( + () => state === 'visible' ? matchesActivePrompt() : !matchesActivePrompt(), + timeout + ); + }, + innerText: async () => activePrompt && matchesActivePrompt() + ? promptTexts[activePrompt] + : '', + getByRole: () => button, + }; + return locator; + }; + + return { + root: { + locator: () => ({ filter: ({ hasText }: { hasText: RegExp }) => locatorFor(hasText) }), + } as unknown as Page, + clicks, + show: (prompt: PromptKind) => { + activePrompt = prompt; + buttonVisible = true; + }, + }; +}; + test('recognizes only the optional Zoom media permission prompt', () => { assert.equal(isZoomOptionalMediaPromptText( 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera' ), true); + assert.equal(isZoomOptionalMediaPromptText( + 'Do you want people to hear you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera' + ), true); assert.equal(isZoomOptionalMediaPromptText( 'The host requires you to consent before joining. Continue' ), false); }); test('dismisses the optional Zoom media prompt through its accessible control', async () => { - let clicks = 0; - let hiddenWaits = 0; - const button = { - first: () => button, - isVisible: async () => true, - click: async () => { clicks += 1; }, - waitFor: async () => { hiddenWaits += 1; }, - }; - const prompt = { - last: () => prompt, - isVisible: async () => true, - innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', - getByRole: () => button, - }; - const filtered = { last: () => prompt }; - const root = { - locator: () => ({ filter: () => filtered }), - } as unknown as Page; + const prompt = createPromptRoot('see'); - assert.equal(await dismissZoomOptionalMediaPrompt(root), true); - assert.equal(clicks, 1); - assert.equal(hiddenWaits, 1); + assert.equal(await dismissZoomOptionalMediaPrompt(prompt.root), true); + assert.deepEqual(prompt.clicks, ['see']); +}); + +test('dismisses sequential prompts after a delayed stale-overlay swap', async () => { + const prompt = createPromptRoot('see', { from: 'see', to: 'hear', delay: 350 }); + + assert.equal(await dismissZoomOptionalMediaPrompt(prompt.root), true); + assert.deepEqual(prompt.clicks, ['see', 'hear']); }); test('does not fail when Zoom keeps the reusable modal overlay visible', async () => { let overlayHiddenWaits = 0; + let buttonVisible = true; const button = { first: () => button, - isVisible: async () => true, - click: async () => undefined, - waitFor: async () => undefined, + click: async () => { buttonVisible = false; }, + waitFor: async ({ state }: { state: 'visible' | 'hidden' }) => { + if (state === 'visible' ? buttonVisible : !buttonVisible) return; + throw new Error('button state did not match'); + }, }; const prompt = { last: () => prompt, - isVisible: async () => true, - innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + innerText: async () => promptTexts.see, getByRole: () => button, - waitFor: async () => { overlayHiddenWaits += 1; throw new Error('overlay remained visible'); }, + waitFor: async ({ state }: { state: 'visible' | 'hidden' }) => { + if (state === 'visible') return; + overlayHiddenWaits += 1; + throw new Error('overlay remained visible'); + }, + }; + const missingPrompt = { + last: () => missingPrompt, + waitFor: async () => { throw new Error('prompt is not visible'); }, }; const root = { - locator: () => ({ filter: () => ({ last: () => prompt }) }), + locator: () => ({ + filter: ({ hasText }: { hasText: RegExp }) => ({ + last: () => hasText.test(promptTexts.hear) && !hasText.test(promptTexts.see) + ? missingPrompt + : prompt, + }), + }), } as unknown as Page; assert.equal(await dismissZoomOptionalMediaPrompt(root), true); @@ -66,14 +169,16 @@ test('does not fail when Zoom keeps the reusable modal overlay visible', async ( test('returns false instead of throwing when Zoom does not dismiss the media prompt', async () => { const button = { first: () => button, - isVisible: async () => true, click: async () => undefined, - waitFor: async () => { throw new Error('button remained visible'); }, + waitFor: async ({ state }: { state: 'visible' | 'hidden' }) => { + if (state === 'visible') return; + throw new Error('button remained visible'); + }, }; const prompt = { last: () => prompt, - isVisible: async () => true, - innerText: async () => 'Do you want people to see you in the meeting? You can still turn off your microphone and camera anytime in the meeting Continue without microphone and camera', + waitFor: async () => undefined, + innerText: async () => promptTexts.see, getByRole: () => button, }; const root = { @@ -87,12 +192,11 @@ test('does not interact with an unknown Zoom modal', async () => { let clicks = 0; const button = { first: () => button, - isVisible: async () => true, click: async () => { clicks += 1; }, }; const prompt = { last: () => prompt, - isVisible: async () => true, + waitFor: async () => undefined, innerText: async () => 'The host requires you to consent before joining. Continue', getByRole: () => button, }; @@ -103,3 +207,57 @@ test('does not interact with an unknown Zoom modal', async () => { assert.equal(await dismissZoomOptionalMediaPrompt(root), false); assert.equal(clicks, 0); }); + +test('retries Join after dismissing a late optional media prompt', async () => { + const prompt = createPromptRoot(); + const clickError = new Error('media overlay intercepted the click'); + let joinAttempts = 0; + + await clickZoomJoinWithOptionalMediaPromptRetry(prompt.root, async () => { + joinAttempts += 1; + if (joinAttempts === 1) { + prompt.show('hear'); + throw clickError; + } + }); + + assert.equal(joinAttempts, 2); + assert.deepEqual(prompt.clicks, ['hear']); +}); + +test('propagates the Join error when no optional media prompt was dismissed', async () => { + const prompt = createPromptRoot(); + const clickError = new Error('Join remained disabled'); + let joinAttempts = 0; + + await assert.rejects( + clickZoomJoinWithOptionalMediaPromptRetry(prompt.root, async () => { + joinAttempts += 1; + throw clickError; + }), + error => error === clickError + ); + assert.equal(joinAttempts, 1); +}); + +test('bounds Join retries when each failed click reveals another media prompt', async () => { + const prompt = createPromptRoot(); + const clickErrors = [ + new Error('first click failed'), + new Error('second click failed'), + new Error('third click failed'), + ]; + let joinAttempts = 0; + + await assert.rejects( + clickZoomJoinWithOptionalMediaPromptRetry(prompt.root, async () => { + const clickError = clickErrors[joinAttempts]; + joinAttempts += 1; + if (joinAttempts < clickErrors.length) prompt.show('hear'); + throw clickError; + }), + error => error === clickErrors[2] + ); + assert.equal(joinAttempts, 3); + assert.deepEqual(prompt.clicks, ['hear', 'hear']); +}); diff --git a/src/bots/zoomPrejoinModal.ts b/src/bots/zoomPrejoinModal.ts index f84ee36d..4d1ae155 100644 --- a/src/bots/zoomPrejoinModal.ts +++ b/src/bots/zoomPrejoinModal.ts @@ -2,34 +2,106 @@ import type { Frame, Page } from 'playwright'; type ZoomPrejoinRoot = Frame | Page; -const OPTIONAL_MEDIA_PROMPT = /Do you want people to see you in the meeting\?/i; +type ZoomOptionalMediaPromptKind = 'see' | 'hear'; + +const OPTIONAL_MEDIA_PROMPTS: Record = { + see: /Do you want people to see you in the meeting\?/i, + hear: /Do you want people to hear you in the meeting\?/i, +}; +const OPTIONAL_MEDIA_PROMPT = /Do you want people to (?:see|hear) you in the meeting\?/i; const CONTINUE_WITHOUT_MEDIA = /^(?:Continue without microphone and camera|Ohne Mikrofon und Kamera fortfahren)$/i; +const NEXT_OPTIONAL_MEDIA_PROMPT_TIMEOUT = 1_500; +const ZOOM_JOIN_CLICK_ATTEMPTS = 3; + +const getZoomOptionalMediaPromptKind = (text: string): ZoomOptionalMediaPromptKind | undefined => + (Object.keys(OPTIONAL_MEDIA_PROMPTS) as ZoomOptionalMediaPromptKind[]) + .find(kind => OPTIONAL_MEDIA_PROMPTS[kind].test(text)); -export const isZoomOptionalMediaPromptText = (text?: string | null): boolean => - OPTIONAL_MEDIA_PROMPT.test(text ?? '') - && /microphone and camera/i.test(text ?? '') - && /Continue without microphone and camera/i.test(text ?? ''); +export const isZoomOptionalMediaPromptText = (text?: string | null): boolean => { + const promptText = text ?? ''; + return getZoomOptionalMediaPromptKind(promptText) !== undefined + && /microphone and camera/i.test(promptText) + && /Continue without microphone and camera/i.test(promptText); +}; export async function dismissZoomOptionalMediaPrompt( root: ZoomPrejoinRoot, timeout = 2_500, ): Promise { - const prompt = root - .locator('.ReactModal__Overlay.ReactModal__Overlay--after-open') + const overlays = root.locator('.ReactModal__Overlay.ReactModal__Overlay--after-open'); + const prompt = overlays .filter({ hasText: OPTIONAL_MEDIA_PROMPT }) .last(); - if (!await prompt.isVisible({ timeout }).catch(() => false)) return false; - if (!isZoomOptionalMediaPromptText(await prompt.innerText().catch(() => ''))) return false; + if (!await prompt + .waitFor({ state: 'visible', timeout }) + .then(() => true) + .catch(() => false)) return false; - const continueWithoutMedia = prompt - .getByRole('button', { name: CONTINUE_WITHOUT_MEDIA }) - .first(); - if (!await continueWithoutMedia.isVisible({ timeout: 1_000 }).catch(() => false)) return false; + const dismissedKinds = new Set(); + let dismissed = false; + let currentPrompt = prompt; - await continueWithoutMedia.click(); - return await continueWithoutMedia - .waitFor({ state: 'hidden', timeout: 5_000 }) - .then(() => true) - .catch(() => false); + for (let attempt = 0; attempt < Object.keys(OPTIONAL_MEDIA_PROMPTS).length; attempt += 1) { + const promptText = await currentPrompt.innerText().catch(() => ''); + const promptKind = getZoomOptionalMediaPromptKind(promptText); + if (!promptKind || !isZoomOptionalMediaPromptText(promptText)) return dismissed; + + const continueWithoutMedia = overlays + .filter({ hasText: OPTIONAL_MEDIA_PROMPTS[promptKind] }) + .last() + .getByRole('button', { name: CONTINUE_WITHOUT_MEDIA }) + .first(); + if (!await continueWithoutMedia + .waitFor({ state: 'visible', timeout: 1_000 }) + .then(() => true) + .catch(() => false)) return dismissed; + if (dismissedKinds.has(promptKind)) return false; + + if (!await continueWithoutMedia + .click() + .then(() => true) + .catch(() => false)) return false; + const didDismiss = await continueWithoutMedia + .waitFor({ state: 'hidden', timeout: 5_000 }) + .then(() => true) + .catch(() => false); + if (!didDismiss) return false; + + dismissedKinds.add(promptKind); + dismissed = true; + if (attempt === Object.keys(OPTIONAL_MEDIA_PROMPTS).length - 1) return true; + + const nextPromptKind = (Object.keys(OPTIONAL_MEDIA_PROMPTS) as ZoomOptionalMediaPromptKind[]) + .find(kind => !dismissedKinds.has(kind)); + if (!nextPromptKind) return true; + + currentPrompt = overlays + .filter({ hasText: OPTIONAL_MEDIA_PROMPTS[nextPromptKind] }) + .last(); + const nextPromptIsVisible = await currentPrompt + .waitFor({ state: 'visible', timeout: NEXT_OPTIONAL_MEDIA_PROMPT_TIMEOUT }) + .then(() => true) + .catch(() => false); + if (!nextPromptIsVisible) return true; + } + + return dismissed; +} + +export async function clickZoomJoinWithOptionalMediaPromptRetry( + root: ZoomPrejoinRoot, + clickJoin: () => Promise, +): Promise { + for (let attempt = 0; attempt < ZOOM_JOIN_CLICK_ATTEMPTS; attempt += 1) { + try { + await clickJoin(); + return; + } catch (clickError) { + if (attempt === ZOOM_JOIN_CLICK_ATTEMPTS - 1) throw clickError; + if (!await dismissZoomOptionalMediaPrompt(root, 1_000).catch(() => false)) { + throw clickError; + } + } + } } From b233c4a32aabdbb9a8472b329cfbe0fe241e48b9 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 19:58:44 +0200 Subject: [PATCH 44/53] fix(chromium): prevent bot detection and bump version to 1.3.10 --- package-lock.json | 4 ++-- package.json | 2 +- src/lib/chromium.ts | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 446a0dbb..fbd76a03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.9", + "version": "1.3.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.9", + "version": "1.3.10", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.787.0", diff --git a/package.json b/package.json index 2bd1732a..7d72e0a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.9", + "version": "1.3.10", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index dcb22fdb..9704fbaf 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -206,6 +206,7 @@ function getBrowserArgs(botType: BotType): string[] { '--auto-accept-this-tab-capture', '--autoplay-policy=no-user-gesture-required', '--disable-background-timer-throttling', + '--disable-blink-features=AutomationControlled', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', ]; @@ -231,6 +232,9 @@ async function configurePage( correlationId: string, ): Promise { await resizeBrowserWindow(page, correlationId); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); await page.setViewportSize(VIEWPORT_SIZE); await applyPageEnvironment(page, timezoneId, correlationId); } From ccf283c64a8c3a4d21c76af59c363ea46960c859 Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 20:00:19 +0200 Subject: [PATCH 45/53] fix(chromium): disable AutomationControlled to prevent bot detection --- scripts/start-chrome-cdp.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/start-chrome-cdp.sh b/scripts/start-chrome-cdp.sh index 1c490b7d..5fa3e256 100755 --- a/scripts/start-chrome-cdp.sh +++ b/scripts/start-chrome-cdp.sh @@ -86,6 +86,7 @@ chrome_args=( '--no-first-run' '--no-default-browser-check' '--disable-background-timer-throttling' + '--disable-blink-features=AutomationControlled' '--disable-backgrounding-occluded-windows' '--disable-renderer-backgrounding' ) From c186ef514f2a71850355151c6aaa22a948f9cc1d Mon Sep 17 00:00:00 2001 From: jakob Date: Thu, 16 Jul 2026 20:04:42 +0200 Subject: [PATCH 46/53] fix(chromium): improve browser mimicry and add configurable storage state paths --- .env.example | 3 +++ src/config.ts | 1 + src/lib/chromium.ts | 43 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index f88728d0..2c78393f 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 # Zoom bot needs (see recordingBrowserArgs in src/lib/chromium.ts). Unlike Google, # Zoom needs no storageState: each meeting gets a fresh isolated browser context. # ZOOM_CHROME_CDP_URL=http://host.docker.internal:9222 +# Pre-authenticated Zoom session state (export via Playwright storageState). +# Lets the bot join with real cookies instead of a fresh anonymous context. +# ZOOM_CHROME_STORAGE_STATE_PATH=/usr/src/app/.chrome/zoom-storage-state.json # Zoom recording transport: browser (default) or rtms. # RTMS is a separate Zoom app-based transport and does not use Chrome/CDP. diff --git a/src/config.ts b/src/config.ts index 368bbd10..6ef2d92f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -163,6 +163,7 @@ export default { googleChromeUserDataDir: process.env.GOOGLE_CHROME_USER_DATA_DIR, googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, zoomChromeCdpUrl: process.env.ZOOM_CHROME_CDP_URL, + zoomChromeStorageStatePath: process.env.ZOOM_CHROME_STORAGE_STATE_PATH, zoomRecordingTransport: zoomRecordingTransport as 'browser' | 'rtms', zoomRtmsFallbackEnabled: process.env.ZOOM_RTMS_FALLBACK_ENABLED === 'true', zoomRtms: { diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index 9704fbaf..3bc1d0b8 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -194,6 +194,13 @@ async function connectToExternalChromeWithRetry(cdpUrl: string, correlationId: s throw new Error(`Unable to connect to external Chrome within ${CDP_CONNECT_TIMEOUT_MS}ms: ${detail}`); } + +function getStorageStatePath(botType: BotType): string | undefined { + if (botType === 'google') return config.googleChromeStorageStatePath; + if (botType === 'zoom') return config.zoomChromeStorageStatePath; + return undefined; +} + function getBrowserArgs(botType: BotType): string[] { const args = [ '--no-first-run', @@ -234,6 +241,30 @@ async function configurePage( await resizeBrowserWindow(page, correlationId); await page.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + // Automated Chrome returns 'denied' for Notification.permission, real + // Chrome returns 'default' — match real browser behaviour. + if (typeof Notification !== 'undefined' && Notification.permission === 'denied') { + Object.defineProperty(Notification, 'permission', { get: () => 'default' }); + } + const origQuery = navigator.permissions?.query?.bind(navigator.permissions); + if (origQuery) { + navigator.permissions.query = (parameters: PermissionDescriptor) => + parameters.name === 'notifications' + ? Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus) + : origQuery(parameters); + } + // Ensure navigator.languages matches Accept-Language header. + Object.defineProperty(navigator, 'languages', { + get: () => ['en-US', 'en'], + }); + // Ensure window.chrome.runtime exists (missing in some CDP setups). + const w = window as any; + if (typeof w.chrome === 'undefined') { + w.chrome = {}; + } + if (w.chrome && !w.chrome.runtime) { + w.chrome.runtime = {}; + } }); await page.setViewportSize(VIEWPORT_SIZE); await applyPageEnvironment(page, timezoneId, correlationId); @@ -318,8 +349,8 @@ async function createBrowserContext( const existingContext = botType === 'google' ? browser.contexts()[0] : undefined; context = existingContext ?? await browser.newContext({ ...contextOptions, - ...(botType === 'google' && config.googleChromeStorageStatePath - ? { storageState: config.googleChromeStorageStatePath } + ...(getStorageStatePath(botType) + ? { storageState: getStorageStatePath(botType) } : {}), }); ownsContext = !existingContext; @@ -358,7 +389,7 @@ async function createBrowserContext( handleSIGTERM: false, handleSIGHUP: false, args: browserArgs, - ignoreDefaultArgs: ['--mute-audio'], + ignoreDefaultArgs: ['--mute-audio', '--enable-automation'], executablePath: config.chromeExecutablePath, }), 60_000, @@ -394,7 +425,7 @@ async function createBrowserContext( handleSIGTERM: false, handleSIGHUP: false, args: browserArgs, - ignoreDefaultArgs: ['--mute-audio'], + ignoreDefaultArgs: ['--mute-audio', '--enable-automation'], executablePath: config.chromeExecutablePath, }), 60_000, @@ -407,8 +438,8 @@ async function createBrowserContext( try { context = await browser.newContext({ ...contextOptions, - ...(config.googleChromeStorageStatePath && botType === 'google' - ? { storageState: config.googleChromeStorageStatePath } + ...(getStorageStatePath(botType) + ? { storageState: getStorageStatePath(botType) } : {}), }); From fee54ae8be39e35b8d84cd0e80fe824bc6f0985e Mon Sep 17 00:00:00 2001 From: jakob Date: Sun, 19 Jul 2026 12:41:28 +0200 Subject: [PATCH 47/53] feat(zoom): add customer-scoped Chrome profiles --- .env.example | 10 +- package-lock.json | 3659 ++++++++++-------------------- package.json | 25 +- src/app/index.ts | 13 + src/bots/ZoomBot.ts | 8 +- src/config.ts | 77 +- src/error.ts | 10 + src/lib/chromium.test.ts | 94 +- src/lib/chromium.ts | 43 +- src/lib/diskUploaderPath.test.ts | 24 + src/middleware/disk-uploader.ts | 36 +- src/rtms/RtmsMediaRecorder.ts | 9 +- src/util/logger.ts | 4 +- 13 files changed, 1563 insertions(+), 2449 deletions(-) create mode 100644 src/lib/diskUploaderPath.test.ts diff --git a/.env.example b/.env.example index 2c78393f..c8e23846 100644 --- a/.env.example +++ b/.env.example @@ -28,12 +28,12 @@ GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 # Optional: connect Zoom bots to an already-running Chrome via CDP. # Launch that Chrome with --remote-debugging-port and the recording flags the -# Zoom bot needs (see recordingBrowserArgs in src/lib/chromium.ts). Unlike Google, -# Zoom needs no storageState: each meeting gets a fresh isolated browser context. +# Zoom bot needs (see recordingBrowserArgs in src/lib/chromium.ts). Every meeting +# gets a fresh isolated context, optionally seeded with its customer's state. # ZOOM_CHROME_CDP_URL=http://host.docker.internal:9222 -# Pre-authenticated Zoom session state (export via Playwright storageState). -# Lets the bot join with real cookies instead of a fresh anonymous context. -# ZOOM_CHROME_STORAGE_STATE_PATH=/usr/src/app/.chrome/zoom-storage-state.json +# Optional customer-specific Zoom session states (export via Playwright storageState). +# Values are read-only secret-mounted file paths selected by exact teamId. +# ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON={"team-id":"/var/run/secrets/meeting-bot/zoom-profiles/team-id.json"} # Zoom recording transport: browser (default) or rtms. # RTMS is a separate Zoom app-based transport and does not use Chrome/CDP. diff --git a/package-lock.json b/package-lock.json index fbd76a03..2fa5167a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,36 +1,36 @@ { "name": "meeting-bot", - "version": "1.3.10", + "version": "1.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.10", + "version": "1.3.11", "license": "MIT", "dependencies": { - "@aws-sdk/client-s3": "^3.787.0", - "@aws-sdk/lib-storage": "^3.876.0", - "@azure/identity": "^4.13.0", - "@azure/storage-blob": "^12.29.1", - "@google-cloud/storage": "^7.16.0", + "@aws-sdk/client-s3": "^3.1089.0", + "@aws-sdk/lib-storage": "^3.1089.0", + "@azure/identity": "^4.13.1", + "@azure/storage-blob": "^12.33.0", + "@google-cloud/storage": "^7.21.0", "@sentry/node": "^10.66.0", - "axios": "^1.7.7", + "axios": "^1.18.1", "dotenv": "^16.4.5", - "express": "^4.19.2", + "express": "^4.22.2", "fs-extra": "^11.2.0", "moment": "^2.30.1", "moment-timezone": "^0.5.46", - "playwright": "^1.45.3", + "playwright": "^1.61.1", "prom-client": "^15.1.3", "redis": "^5.8.1", "serialize-error": "^12.0.0", "ts-node": "^10.9.2", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "winston": "^3.17.0" }, "devDependencies": { - "@playwright/test": "^1.45.3", + "@playwright/test": "^1.61.1", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/node": "^22.1.0", @@ -90,2562 +90,1266 @@ "module-details-from-path": "^1.0.4" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz", + "integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "node_modules/@aws-sdk/client-s3": { + "version": "3.1089.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1089.0.tgz", + "integrity": "sha512-Sc+JOtNeP+v+XdP9Rb4Hh+uhiNO2hh/CZQbKYooNakhOIgK6/K0cjoDCEGeFmkG0vf2DopTmSctzKlcKwy1BPw==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/checksums": "^3.1000.18", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-node": "^3.972.70", + "@aws-sdk/middleware-sdk-s3": "^3.972.64", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "node_modules/@aws-sdk/core": { + "version": "3.975.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.4.tgz", + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-login": "^3.972.66", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.66.tgz", + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.70.tgz", + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-ini": "^3.973.4", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", "license": "Apache-2.0", "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.876.0.tgz", - "integrity": "sha512-rrdrB0IlHfRaY+qxo87iSPJJxjCZ2WIV0wKi0EWz02yBpq17c0o6Vzc8f1+ksR+IZGkGttQnD2j4UpItMdLSKg==", + "node_modules/@aws-sdk/lib-storage": { + "version": "3.1089.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1089.0.tgz", + "integrity": "sha512-Ly0bsr49in42ZRtnRbwaBo/0xv7kCWSu1/qT9V56s9JGsunJzzmTIFlvz7dwy6ISK/Dh3mFc5vrO91sj3G3YhQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/credential-provider-node": "3.876.0", - "@aws-sdk/middleware-bucket-endpoint": "3.873.0", - "@aws-sdk/middleware-expect-continue": "3.873.0", - "@aws-sdk/middleware-flexible-checksums": "3.876.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-location-constraint": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-sdk-s3": "3.876.0", - "@aws-sdk/middleware-ssec": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/signature-v4-multi-region": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.876.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/eventstream-serde-browser": "^4.0.5", - "@smithy/eventstream-serde-config-resolver": "^4.1.3", - "@smithy/eventstream-serde-node": "^4.0.5", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-blob-browser": "^4.0.5", - "@smithy/hash-node": "^4.0.5", - "@smithy/hash-stream-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/md5-js": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.1089.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" } }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.876.0.tgz", - "integrity": "sha512-Vf0PMF7HVpvllrfPODnBZmlz6kT/y2AvOt1RQG3+qD0VrHWzShc5nwgRZ+yyP3xkKVhZsQ3sJapfZTFnjqMOYA==", + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.64.tgz", + "integrity": "sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.876.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.876.0.tgz", - "integrity": "sha512-sVFBFkdoPOPyY13NaXO1E/R9O5J6ixzHnnRbqrbXYM2QQgLNPTKIiRtmVEuVoFV9YULg+/aKm7caix8m468y9w==", + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/xml-builder": "3.873.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-utf8": "^4.0.0", - "fast-xml-parser": "5.2.5", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/core/node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.876.0.tgz", - "integrity": "sha512-cof7lwp2AlrAfRs0pt4W2KMS2VMBvEmpcti1UOFfSJIqkn+cyJliMJ8LHg22GI+kUexjvxdAqSbf3M7OHvEW+w==", + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.876.0.tgz", - "integrity": "sha512-wzmef2NBp2+X1l8D4Q8hx1G8oI3+WdvLdPev9VnVpRYZxYGRWVPl++wvCBsCn/ZL0mdWopPkhHA3kFexQhMzvg==", + "node_modules/@aws-sdk/token-providers": { + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/property-provider": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.876.0.tgz", - "integrity": "sha512-JHbW6fqnJsVjGHCyko7B0NVPT1nEAPxkM3CGjUcVGsHgJBkxOLVCMQqTRyHcDdeHR2qeojlLoOHRz97xIHQjYw==", + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.876.0.tgz", - "integrity": "sha512-eHbNt1+Hi43e8ANnwf6toapLSxfMiyGq459y3Uh6i7NBOiWWKEsOVcgOfUC3RCoqeikxovt1tFM2cEElWUIOhg==", + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.876.0", - "@aws-sdk/credential-provider-http": "3.876.0", - "@aws-sdk/credential-provider-ini": "3.876.0", - "@aws-sdk/credential-provider-process": "3.876.0", - "@aws-sdk/credential-provider-sso": "3.876.0", - "@aws-sdk/credential-provider-web-identity": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.876.0.tgz", - "integrity": "sha512-SMX4OlHvspu3gF4hxe7WAnZFhxpiCye+WlBSVoWfW/i9XNhtrZS1JMr29MK34GlCTk9qO7FlRwds/Z5k7xPpHg==", + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.876.0.tgz", - "integrity": "sha512-iP5dz9XqwePbgnh7Bdrq5e1319JpCRKLyomUfHH1XVeXkIHmwIJdmTj1Upeo1J8L/5cLHmhXAN6CTN11bLo8SA==", - "license": "Apache-2.0", + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", "dependencies": { - "@aws-sdk/client-sso": "3.876.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/token-providers": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.876.0.tgz", - "integrity": "sha512-q/XSCP1uae5aB9veM8zcm6Gqu6A4ckX9ZbhHgCzURXVJDwp+nINW1hM9vppMjGw3ND9Ibx/adR+KfTI0TDMzqw==", - "license": "Apache-2.0", + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/lib-storage": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.876.0.tgz", - "integrity": "sha512-6wydbk8enmPQmj8I0NvRhDcq3J5GhY72RBwQKyShQdD8q6xLEbSnJAudTKSmsVzfjnL8hA/cP/dBY+6T6RvlYQ==", - "license": "Apache-2.0", + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "license": "MIT", "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/smithy-client": "^4.4.10", - "buffer": "5.6.0", - "events": "3.3.0", - "stream-browserify": "3.0.0", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@aws-sdk/client-s3": "^3.876.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "node_modules/@azure/core-http-compat": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.5.0.tgz", + "integrity": "sha512-BoSmXPx2er1Ai+wKlDvj29jIQespCNBwEmKyZVHO2kEFsWbGjAjwMCGzug3DJM5/QYIV3vej0S1zcU5bq9fa8w==", "license": "MIT", "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.873.0.tgz", - "integrity": "sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "tslib": "^2.6.2" + "@azure/abort-controller": "^2.1.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" } }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.873.0.tgz", - "integrity": "sha512-GIqoc8WgRcf/opBOZXFLmplJQKwOMjiOMmDz9gQkaJ8FiVJoAp8EGVmK2TOWZMQUYsavvHYsHaor5R2xwPoGVg==", - "license": "Apache-2.0", + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.876.0.tgz", - "integrity": "sha512-Xfb9/XP0WcQq/yJxUubfzMUF0AYSX10UUIRbCJog0/lnDNocEiGEIaarwuQzoxb9QW9TQ1l5dDc/5bOMa1YVGw==", - "license": "Apache-2.0", + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", - "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", - "license": "Apache-2.0", + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.873.0.tgz", - "integrity": "sha512-r+hIaORsW/8rq6wieDordXnA/eAu7xAPLue2InhoEX6ML7irP52BgiibHLpt9R0psiCzIHhju8qqKa4pJOrmiw==", - "license": "Apache-2.0", + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", - "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", - "license": "Apache-2.0", + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", - "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", - "license": "Apache-2.0", + "node_modules/@azure/core-xml": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz", + "integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==", + "license": "MIT", "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "fast-xml-parser": "^5.0.7", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.876.0.tgz", - "integrity": "sha512-h+TDs9EKAfXnrkogQpQz3o11zvs6Vh9+ehxyd35OcM7evnDeoV4GFjjnAKq+MxbBk/5Ewnvng+d6/WQDvMbj7Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-arn-parser": "3.873.0", - "@smithy/core": "^3.8.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.873.0.tgz", - "integrity": "sha512-AF55J94BoiuzN7g3hahy0dXTVZahVi8XxRBLgzNp6yQf0KTng+hb/V9UQZVYY1GZaDczvvvnqC54RGe9OZZ9zQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.876.0.tgz", - "integrity": "sha512-FR+8INfnbNv32QDQ5szxkWX6mB/QgezfNyx8LnAh1ErISZMmEFBxXXir+ZOfuV8vsmal1a6cy9qmnMNDaNnaNQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@smithy/core": "^3.8.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.876.0.tgz", - "integrity": "sha512-R4TZrkM2gUElTsotk8mt3y7iLG8TNi1LL1wgVdEEWSLOYTaFyglGdoNBMtEeP7lmXilaTy00AbYF6BakJvSTHg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.876.0", - "@aws-sdk/middleware-host-header": "3.873.0", - "@aws-sdk/middleware-logger": "3.876.0", - "@aws-sdk/middleware-recursion-detection": "3.873.0", - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/region-config-resolver": "3.873.0", - "@aws-sdk/types": "3.862.0", - "@aws-sdk/util-endpoints": "3.873.0", - "@aws-sdk/util-user-agent-browser": "3.873.0", - "@aws-sdk/util-user-agent-node": "3.876.0", - "@smithy/config-resolver": "^4.1.5", - "@smithy/core": "^3.8.0", - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/hash-node": "^4.0.5", - "@smithy/invalid-dependency": "^4.0.5", - "@smithy/middleware-content-length": "^4.0.5", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-retry": "^4.1.19", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/protocol-http": "^5.1.3", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.26", - "@smithy/util-defaults-mode-node": "^4.0.26", - "@smithy/util-endpoints": "^3.0.7", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.873.0.tgz", - "integrity": "sha512-q9sPoef+BBG6PJnc4x60vK/bfVwvRWsPgcoQyIra057S/QGjq5VkjvNk6H8xedf6vnKlXNBwq9BaANBXnldUJg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.876.0.tgz", - "integrity": "sha512-OMDcuaVlC2rbze92w4QcNfuEA0IeT2GsT1ByZCwe+Y9tZwxzj7fCiOOU0UmJfa+juuQ/YBzVYxnkrkz3Rg6DEw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/signature-v4": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.876.0.tgz", - "integrity": "sha512-iU08kaQbhXnY0CC2TBcr7y/2PqPwZP2CTWX/Rbq0NvhOyteikfh7ASC+bRfLUp0XMSHKvSb+w2dh8a0lvx4oHg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.876.0", - "@aws-sdk/nested-clients": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.862.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.862.0.tgz", - "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.873.0.tgz", - "integrity": "sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.873.0.tgz", - "integrity": "sha512-YByHrhjxYdjKRf/RQygRK1uh0As1FIi9+jXTcIEX/rBgN8mUByczr2u4QXBzw7ZdbdcOBMOkPnLRjNOWW1MkFg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-endpoints": "^3.0.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.723.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.723.0.tgz", - "integrity": "sha512-Yf2CS10BqK688DRsrKI/EO6B8ff5J86NXe4C+VCysK7UOgN0l1zOTeTukZ3H8Q9tYYX3oaF1961o8vRkFm7Nmw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.873.0.tgz", - "integrity": "sha512-AcRdbK6o19yehEcywI43blIBhOCSo6UgyWcuOJX5CFF8k39xm1ILCjQlRRjchLAxWrm0lU0Q7XV90RiMMFMZtA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.862.0", - "@smithy/types": "^4.3.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.876.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.876.0.tgz", - "integrity": "sha512-/ZIaeUt60JBdI0mNc7sZ8v3Tuzp8Pbe4gIAYnppGyF4KV8QA+Yu8tp2bGHfkKn150t1uvQ6P/4CwFfoGF34dzg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.876.0", - "@aws-sdk/types": "3.862.0", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.873.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.873.0.tgz", - "integrity": "sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-util": "^1.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-client": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", - "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-http-compat": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", - "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-client": "^1.10.0", - "@azure/core-rest-pipeline": "^1.22.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-lro": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", - "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.2.0", - "@azure/logger": "^1.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-paging": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", - "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline": { - "version": "1.22.2", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", - "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.10.0", - "@azure/core-tracing": "^1.3.0", - "@azure/core-util": "^1.13.0", - "@azure/logger": "^1.3.0", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-xml": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz", - "integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==", - "license": "MIT", - "dependencies": { - "fast-xml-parser": "^5.0.7", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/core-xml/node_modules/fast-xml-parser": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.1.tgz", - "integrity": "sha512-jbNkWiv2Ec1A7wuuxk0br0d0aTMUtQ4IkL+l/i1r9PRf6pLXjDgsBsWwO+UyczmQlnehi4Tbc8/KIvxGQe+I/A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/@azure/core-xml/node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/@azure/identity": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", - "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", - "license": "MIT", + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.17.0", - "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^4.2.0", - "@azure/msal-node": "^3.5.0", - "open": "^10.1.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/logger": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", - "license": "MIT", - "dependencies": { - "@typespec/ts-http-runtime": "^0.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/msal-browser": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.26.1.tgz", - "integrity": "sha512-GGCIsZXxyNm5QcQZ4maA9q+9UWmM+/87G+ybvPkrE32el1URSa9WYt0t67ks3/P0gspZX9RoEqyLqJ/X/JDnBQ==", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "15.13.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-common": { - "version": "15.13.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.1.tgz", - "integrity": "sha512-vQYQcG4J43UWgo1lj7LcmdsGUKWYo28RfEvDQAEMmQIMjSFufvb+pS0FJ3KXmrPmnWlt1vHDl3oip6mIDUQ4uA==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@azure/msal-node": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.2.tgz", - "integrity": "sha512-dQrex2LiXwlCe9WuBHnCsY+xxLyuMXSd2SDEYJuhqB7cE8u6QafiC1xy8j8eBjGO30AsRi2M6amH0ZKk7vJpjA==", - "license": "MIT", - "dependencies": { - "@azure/msal-common": "15.13.1", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@azure/msal-node/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@azure/storage-blob": { - "version": "12.29.1", - "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.29.1.tgz", - "integrity": "sha512-7ktyY0rfTM0vo7HvtK6E3UvYnI9qfd6Oz6z/+92VhGRveWng3kJwMKeUpqmW/NmwcDNbxHpSlldG+vsUnRFnBg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.3", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/core-xml": "^1.4.5", - "@azure/logger": "^1.1.4", - "@azure/storage-common": "^12.1.1", - "events": "^3.0.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/storage-common": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.1.1.tgz", - "integrity": "sha512-eIOH1pqFwI6UmVNnDQvmFeSg0XppuzDLFeUNO/Xht7ODAzRLgGDh7h550pSxoA+lPDxBl1+D2m/KG3jWzCUjTg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.1.4", - "events": "^3.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "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==", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", - "license": "MIT", - "dependencies": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz", - "integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@google-cloud/paginator": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", - "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", - "license": "Apache-2.0", - "dependencies": { - "arrify": "^2.0.0", - "extend": "^3.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/projectify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", - "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@google-cloud/promisify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", - "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@google-cloud/storage": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz", - "integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==", - "license": "Apache-2.0", - "dependencies": { - "@google-cloud/paginator": "^5.0.0", - "@google-cloud/projectify": "^4.0.0", - "@google-cloud/promisify": "<4.1.0", - "abort-controller": "^3.0.0", - "async-retry": "^1.3.3", - "duplexify": "^4.1.3", - "fast-xml-parser": "^4.4.1", - "gaxios": "^6.0.2", - "google-auth-library": "^9.6.3", - "html-entities": "^2.5.2", - "mime": "^3.0.0", - "p-limit": "^3.0.1", - "retry-request": "^7.0.0", - "teeny-request": "^9.0.0", - "uuid": "^8.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@google-cloud/storage/node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@google-cloud/storage/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "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==", - "engines": { - "node": ">=6.0.0" - } - }, - "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==", - "license": "MIT" - }, - "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==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.220.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", - "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", - "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.220.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", - "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.220.0", - "import-in-the-middle": "^3.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", - "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.9.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", - "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.9.0", - "@opentelemetry/resources": "2.9.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", - "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.9.0", - "@opentelemetry/resources": "2.9.0", - "@opentelemetry/sdk-trace": "2.9.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", - "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", - "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", - "dev": true, - "dependencies": { - "playwright": "1.45.3" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@redis/bloom": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", - "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@redis/client": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz", - "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==", - "license": "MIT", - "dependencies": { - "cluster-key-slot": "1.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@redis/json": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz", - "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@redis/search": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz", - "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@redis/time-series": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz", - "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==", - "license": "MIT", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@redis/client": "^5.8.1" - } - }, - "node_modules/@sentry/conventions": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", - "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/@sentry/core": { - "version": "10.66.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.66.0.tgz", - "integrity": "sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ==", - "license": "MIT", - "dependencies": { - "@sentry/conventions": "^0.16.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/node": { - "version": "10.66.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.66.0.tgz", - "integrity": "sha512-5Ow7iQiRjaSaEOmqEIkYV368hFFzShIZCXPoj+wX3JtOmKFrsNu6lr8/VJlQs7cyjFS6tzE50PrLrD8iAPS/8w==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/instrumentation": "^0.220.0", - "@opentelemetry/sdk-trace-base": "^2.9.0", - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.66.0", - "@sentry/node-core": "10.66.0", - "@sentry/opentelemetry": "10.66.0", - "@sentry/server-utils": "10.66.0", - "import-in-the-middle": "^3.0.0" + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" }, "engines": { - "node": ">=18" + "node": ">=20.0.0" } }, - "node_modules/@sentry/node-core": { - "version": "10.66.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.66.0.tgz", - "integrity": "sha512-SUnXHROqSdSetKgZC1goDEKCuMz3OmQ1h4rxzWeexLyqan+pensZXfLouP6jzZXlA8e/HP7uQg78LnfqYDcZlQ==", + "node_modules/@azure/logger": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.66.0", - "@sentry/opentelemetry": "10.66.0", - "import-in-the-middle": "^3.0.0" + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/core": { - "optional": true - }, - "@opentelemetry/exporter-trace-otlp-http": { - "optional": true - }, - "@opentelemetry/instrumentation": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - } + "node": ">=20.0.0" } }, - "node_modules/@sentry/opentelemetry": { - "version": "10.66.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.66.0.tgz", - "integrity": "sha512-K5Y9IettN9yIOnpqCs40HRLGqaGUoaQ50+ZsLqX2kPCk3TzJVpKZ9icKwaJ4Nxm72QgGVT5Ovfr/9FXbgd3b/Q==", + "node_modules/@azure/msal-browser": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz", + "integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==", "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.66.0" + "@azure/msal-common": "16.11.2" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + "node": ">=0.8.0" } }, - "node_modules/@sentry/server-utils": { - "version": "10.66.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.66.0.tgz", - "integrity": "sha512-h9EM9Wz9Mc6w2Vn7Fyh6ozjy4JC2UbUvWf9Ra1HYA4KMeYqjFA5/o0oB4pg4w/TF+rb+9OF/HNqxjuczxpudpA==", + "node_modules/@azure/msal-common": { + "version": "16.11.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz", + "integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==", "license": "MIT", - "dependencies": { - "@apm-js-collab/code-transformer": "^0.18.0", - "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", - "@apm-js-collab/tracing-hooks": "^0.13.0", - "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.66.0" - }, "engines": { - "node": ">=18" + "node": ">=0.8.0" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", - "integrity": "sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==", - "license": "Apache-2.0", + "node_modules/@azure/msal-node": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.1.tgz", + "integrity": "sha512-yqgoyOIMCH7TNaSLMBTP+4LUlbMMf1zgC8nzOFG95lmW82CmsAEtUT0J93e4BdqDcnX5qle/9X+yb7A8Mw9M0g==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@azure/msal-common": "16.11.2", + "jsonwebtoken": "^9.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20" } }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", - "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", - "license": "Apache-2.0", + "node_modules/@azure/storage-blob": { + "version": "12.33.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.33.0.tgz", + "integrity": "sha512-2SX8oP8PyblUcAFZSg39c8Ls+tFjavM6sBeV+qpw33mRzRhI/5hrFJmJ/x0H9xx5l6ECPvgSP8uPxqTeVbHNIA==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.3", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.5", + "@azure/logger": "^1.1.4", + "@azure/storage-common": "^12.4.1", + "events": "^3.0.0", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", - "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", - "license": "Apache-2.0", + "node_modules/@azure/storage-common": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.4.1.tgz", + "integrity": "sha512-t14unw/WofGDUi7TKJrsyXyPsN+NLgRm7hMaq0llxNmTIzt7f257+6LE6FKIJPh88zLj6M7LPvzve0fEYg/L3A==", + "license": "MIT", "dependencies": { - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "events": "^3.3.0", + "tslib": "^2.8.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, - "node_modules/@smithy/config-resolver": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", - "integrity": "sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "@smithy/util-config-provider": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=0.1.90" } }, - "node_modules/@smithy/core": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.8.0.tgz", - "integrity": "sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==", - "license": "Apache-2.0", + "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==", "dependencies": { - "@smithy/middleware-serde": "^4.0.9", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-stream": "^4.2.4", - "@smithy/util-utf8": "^4.0.0", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "@jridgewell/trace-mapping": "0.3.9" }, "engines": { - "node": ">=18.0.0" + "node": ">=12" } }, - "node_modules/@smithy/core/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.7.tgz", - "integrity": "sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==", - "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.5.tgz", - "integrity": "sha512-miEUN+nz2UTNoRYRhRqVTJCx7jMeILdAurStT2XoS+mhokkmz1xAPp95DFW9Gxt4iF2VBqpeF9HbTQ3kY1viOA==", - "license": "Apache-2.0", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz", + "integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==", + "dev": true, + "license": "MIT", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "tslib": "^2.6.2" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.5.tgz", - "integrity": "sha512-LCUQUVTbM6HFKzImYlSB9w4xafZmpdmZsOh9rIl7riPC3osCgGFVP+wwvYVw6pXda9PPT9TcEZxaq3XE81EdJQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.3.tgz", - "integrity": "sha512-yTTzw2jZjn/MbHu1pURbHdpjGbCuMHWncNBpJnQAPxOVnFUAbSIUSwafiphVDjNV93TdBJWmeVAds7yl5QCkcA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.5.tgz", - "integrity": "sha512-lGS10urI4CNzz6YlTe5EYG0YOpsSp3ra8MXyco4aqSkQDuyZPIw2hcaxDU82OUVtK7UY9hrSvgWtpsW5D4rb4g==", - "license": "Apache-2.0", + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.5.tgz", - "integrity": "sha512-JFnmu4SU36YYw3DIBVao3FsJh4Uw65vVDIqlWT4LzR6gXA0F3KP0IXFKKJrhaVzCBhAuMsrUUaT5I+/4ZhF7aw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", - "integrity": "sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==", + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" + "arrify": "^2.0.0", + "extend": "^3.0.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.5.tgz", - "integrity": "sha512-F7MmCd3FH/Q2edhcKd+qulWkwfChHbc9nhguBlVjSUE6hVHhec3q6uPQ+0u69S6ppvLtR3eStfCuEKMXBXhvvA==", + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.0.0", - "@smithy/chunked-blob-reader-native": "^4.0.0", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": ">=14.0.0" } }, - "node_modules/@smithy/hash-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", - "integrity": "sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==", + "node_modules/@google-cloud/promisify": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.4.tgz", + "integrity": "sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA==", "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": ">=10" } }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.5.tgz", - "integrity": "sha512-IJuDS3+VfWB67UC0GU0uYBG/TA30w+PlOaSo0GPm9UHS88A6rCP6uZxNjNYiyRtOcjv7TXn/60cW8ox1yuZsLg==", + "node_modules/@google-cloud/storage": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", - "integrity": "sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=10.0.0" } }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", - "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { - "node": ">=18.0.0" + "node": ">=10.10.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.5.tgz", - "integrity": "sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==", + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", - "integrity": "sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==", - "license": "Apache-2.0", + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "optional": true, "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "minipass": "^7.0.4" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.18.tgz", - "integrity": "sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-serde": "^4.0.9", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "@smithy/url-parser": "^4.0.5", - "@smithy/util-middleware": "^4.0.5", - "tslib": "^2.6.2" - }, + "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==", "engines": { - "node": ">=18.0.0" + "node": ">=6.0.0" } }, - "node_modules/@smithy/middleware-retry": { - "version": "4.1.19", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.19.tgz", - "integrity": "sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==", - "license": "Apache-2.0", + "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==", + "license": "MIT" + }, + "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==", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/protocol-http": "^5.1.3", - "@smithy/service-error-classification": "^4.0.7", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-retry": "^4.0.7", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=18.0.0" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } + "license": "MIT" }, - "node_modules/@smithy/middleware-serde": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.9.tgz", - "integrity": "sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==", - "license": "Apache-2.0", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">=18.0.0" + "node": ">= 8" } }, - "node_modules/@smithy/middleware-stack": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.5.tgz", - "integrity": "sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 8" } }, - "node_modules/@smithy/node-config-provider": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.4.tgz", - "integrity": "sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==", - "license": "Apache-2.0", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/shared-ini-file-loader": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=18.0.0" + "node": ">= 8" } }, - "node_modules/@smithy/node-http-handler": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.1.tgz", - "integrity": "sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==", + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/querystring-builder": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": ">=8.0.0" } }, - "node_modules/@smithy/property-provider": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.5.tgz", - "integrity": "sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==", + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=8.0.0" } }, - "node_modules/@smithy/protocol-http": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.3.tgz", - "integrity": "sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==", + "node_modules/@opentelemetry/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz", + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.5.tgz", - "integrity": "sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.3.2", - "@smithy/util-uri-escape": "^4.0.0", - "tslib": "^2.6.2" + "node": "^18.19.0 || >=20.6.0" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@smithy/querystring-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.5.tgz", - "integrity": "sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==", + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@smithy/service-error-classification": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.7.tgz", - "integrity": "sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==", + "node_modules/@opentelemetry/resources": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz", + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.5.tgz", - "integrity": "sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==", + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz", + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.3.tgz", - "integrity": "sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==", + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-middleware": "^4.0.5", - "@smithy/util-uri-escape": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" + "@opentelemetry/core": "2.9.0", + "@opentelemetry/resources": "2.9.0", + "@opentelemetry/sdk-trace": "2.9.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=18.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@smithy/smithy-client": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.10.tgz", - "integrity": "sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==", + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.8.0", - "@smithy/middleware-endpoint": "^4.1.18", - "@smithy/middleware-stack": "^4.0.5", - "@smithy/protocol-http": "^5.1.3", - "@smithy/types": "^4.3.2", - "@smithy/util-stream": "^4.2.4", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/types": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.2.tgz", - "integrity": "sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==", + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/url-parser": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.5.tgz", - "integrity": "sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.0.5", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" - }, + "node_modules/@redis/bloom": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz", + "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/util-base64": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", - "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", - "license": "Apache-2.0", + "node_modules/@redis/client": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz", + "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==", + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" + "cluster-key-slot": "1.1.2" }, "engines": { - "node": ">=18.0.0" + "node": ">= 18" } }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", - "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@redis/json": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz", + "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", - "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "node_modules/@redis/search": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz", + "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.8.1" } }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", - "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "tslib": "^2.6.2" + "node_modules/@redis/time-series": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz", + "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==", + "license": "MIT", + "engines": { + "node": ">= 18" }, + "peerDependencies": { + "@redis/client": "^5.8.1" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=14" } }, - "node_modules/@smithy/util-config-provider": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", - "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", - "license": "Apache-2.0", + "node_modules/@sentry/core": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.66.0.tgz", + "integrity": "sha512-9UbgSvds7bMJsP561eWmeyMLcfOmnwxtnx2QuW3yLobzP2Ob7CyJCOzP4tGzlTAGDrzShkFEZhiyuBUKiEK2oQ==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@sentry/conventions": "^0.16.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.26.tgz", - "integrity": "sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==", - "license": "Apache-2.0", + "node_modules/@sentry/node": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.66.0.tgz", + "integrity": "sha512-5Ow7iQiRjaSaEOmqEIkYV368hFFzShIZCXPoj+wX3JtOmKFrsNu6lr8/VJlQs7cyjFS6tzE50PrLrD8iAPS/8w==", + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "bowser": "^2.11.0", - "tslib": "^2.6.2" + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0", + "@sentry/node-core": "10.66.0", + "@sentry/opentelemetry": "10.66.0", + "@sentry/server-utils": "10.66.0", + "import-in-the-middle": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.26.tgz", - "integrity": "sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==", - "license": "Apache-2.0", + "node_modules/@sentry/node-core": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.66.0.tgz", + "integrity": "sha512-SUnXHROqSdSetKgZC1goDEKCuMz3OmQ1h4rxzWeexLyqan+pensZXfLouP6jzZXlA8e/HP7uQg78LnfqYDcZlQ==", + "license": "MIT", "dependencies": { - "@smithy/config-resolver": "^4.1.5", - "@smithy/credential-provider-imds": "^4.0.7", - "@smithy/node-config-provider": "^4.1.4", - "@smithy/property-provider": "^4.0.5", - "@smithy/smithy-client": "^4.4.10", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0", + "@sentry/opentelemetry": "10.66.0", + "import-in-the-middle": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } } }, - "node_modules/@smithy/util-endpoints": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.7.tgz", - "integrity": "sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==", - "license": "Apache-2.0", + "node_modules/@sentry/opentelemetry": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.66.0.tgz", + "integrity": "sha512-K5Y9IettN9yIOnpqCs40HRLGqaGUoaQ50+ZsLqX2kPCk3TzJVpKZ9icKwaJ4Nxm72QgGVT5Ovfr/9FXbgd3b/Q==", + "license": "MIT", "dependencies": { - "@smithy/node-config-provider": "^4.1.4", - "@smithy/types": "^4.3.2", - "tslib": "^2.6.2" + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", - "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", - "license": "Apache-2.0", + "node_modules/@sentry/server-utils": { + "version": "10.66.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.66.0.tgz", + "integrity": "sha512-h9EM9Wz9Mc6w2Vn7Fyh6ozjy4JC2UbUvWf9Ra1HYA4KMeYqjFA5/o0oB4pg4w/TF+rb+9OF/HNqxjuczxpudpA==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" + "@apm-js-collab/code-transformer": "^0.18.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", + "@apm-js-collab/tracing-hooks": "^0.13.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.66.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/@smithy/util-middleware": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.5.tgz", - "integrity": "sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==", + "node_modules/@smithy/core": { + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.5.tgz", + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-retry": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.7.tgz", - "integrity": "sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.10.tgz", + "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.0.7", - "@smithy/types": "^4.3.2", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-stream": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.4.tgz", - "integrity": "sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==", + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.7", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.7.tgz", + "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.1.1", - "@smithy/node-http-handler": "^4.1.1", - "@smithy/types": "^4.3.2", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", - "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "node_modules/@smithy/node-http-handler": { + "version": "4.9.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.7.tgz", + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", - "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "node_modules/@smithy/signature-v4": { + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.6.tgz", + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", + "@smithy/core": "^3.29.5", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/util-waiter": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.7.tgz", - "integrity": "sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==", + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.5", - "@smithy/types": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -2653,9 +1357,9 @@ } }, "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "license": "MIT", "engines": { "node": ">= 10" @@ -2795,26 +1499,27 @@ "dev": true }, "node_modules/@types/request": { - "version": "2.48.12", - "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", - "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", - "form-data": "^2.5.0" + "form-data": "^2.5.5" } }, "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", - "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, @@ -2862,12 +1567,6 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -3075,9 +1774,9 @@ } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz", - "integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.7.tgz", + "integrity": "sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -3085,7 +1784,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@ungap/structured-clone": { @@ -3188,9 +1887,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3226,6 +1925,18 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3292,14 +2003,40 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/balanced-match": { @@ -3328,9 +2065,9 @@ ] }, "node_modules/bignumber.js": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.2.1.tgz", - "integrity": "sha512-+NzaKgOUvInq9TIUZ1+DRspzf/HApkCwD4btfuasFTdrfnOxqx853TgDpMolp+uv4RpRp7bPcEU2zKr9+fRmyw==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", "engines": { "node": "*" @@ -3377,22 +2114,23 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -3403,6 +2141,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -3410,19 +2149,21 @@ "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/bowser": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", - "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3489,26 +2230,9 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, "node_modules/call-bind-apply-helpers": { @@ -3524,6 +2248,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3658,14 +2398,16 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3773,22 +2515,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -3814,6 +2540,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3822,6 +2549,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -3838,9 +2566,10 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -3920,7 +2649,8 @@ "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" }, "node_modules/enabled": { "version": "2.0.0", @@ -3929,9 +2659,10 @@ "license": "MIT" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -3997,7 +2728,8 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" }, "node_modules/eslint": { "version": "8.57.1", @@ -4276,6 +3008,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4309,44 +3042,49 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", - "content-disposition": "0.5.4", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/debug": { @@ -4406,10 +3144,26 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "funding": [ { "type": "github", @@ -4418,7 +3172,12 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -4473,16 +3232,17 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -4493,6 +3253,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -4500,7 +3261,8 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", @@ -4535,9 +3297,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4548,9 +3310,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4568,14 +3330,16 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4593,6 +3357,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4660,19 +3425,6 @@ "node": ">=14" } }, - "node_modules/gaxios/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/gcp-metadata": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", @@ -4873,17 +3625,6 @@ "node": ">=4" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4912,9 +3653,10 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -4939,18 +3681,23 @@ "license": "MIT" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -4981,6 +3728,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -5191,6 +3939,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -5214,10 +3974,21 @@ "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5267,12 +4038,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -5288,45 +4059,24 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -5469,14 +4219,19 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge2": { "version": "1.4.1", @@ -5523,6 +4278,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", "bin": { "mime": "cli.js" }, @@ -5563,10 +4319,11 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5757,9 +4514,10 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -5771,6 +4529,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", "dependencies": { "ee-first": "1.1.1" }, @@ -5878,6 +4637,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5892,6 +4652,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5912,9 +4687,10 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -5927,10 +4703,11 @@ } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -5939,11 +4716,12 @@ } }, "node_modules/playwright": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", - "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.45.3" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -5956,9 +4734,10 @@ } }, "node_modules/playwright-core": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", - "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -6067,9 +4846,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", @@ -6098,11 +4881,13 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6136,19 +4921,21 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" @@ -6361,7 +5148,8 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" }, "node_modules/semifies": { "version": "1.0.0", @@ -6381,23 +5169,24 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -6407,6 +5196,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -6414,7 +5204,8 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" }, "node_modules/serialize-error": { "version": "12.0.0", @@ -6444,39 +5235,25 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -6502,14 +5279,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -6621,9 +5453,10 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -6688,16 +5521,19 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/stubs": { "version": "3.0.0", @@ -6798,19 +5634,6 @@ "node": ">= 6" } }, - "node_modules/teeny-request/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -6840,6 +5663,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", "engines": { "node": ">=0.6" } @@ -6982,6 +5806,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -7026,6 +5851,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -7055,9 +5881,9 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7178,6 +6004,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 7d72e0a2..d456bc38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.10", + "version": "1.3.11", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", @@ -18,7 +18,7 @@ "keywords": [], "author": "screenappai", "devDependencies": { - "@playwright/test": "^1.45.3", + "@playwright/test": "^1.61.1", "@types/express": "^4.17.21", "@types/fs-extra": "^11.0.4", "@types/node": "^22.1.0", @@ -29,27 +29,30 @@ "nodemon": "^3.1.4" }, "dependencies": { - "@aws-sdk/client-s3": "^3.787.0", - "@aws-sdk/lib-storage": "^3.876.0", - "@azure/identity": "^4.13.0", - "@azure/storage-blob": "^12.29.1", - "@google-cloud/storage": "^7.16.0", + "@aws-sdk/client-s3": "^3.1089.0", + "@aws-sdk/lib-storage": "^3.1089.0", + "@azure/identity": "^4.13.1", + "@azure/storage-blob": "^12.33.0", + "@google-cloud/storage": "^7.21.0", "@sentry/node": "^10.66.0", - "axios": "^1.7.7", + "axios": "^1.18.1", "dotenv": "^16.4.5", - "express": "^4.19.2", + "express": "^4.22.2", "fs-extra": "^11.2.0", "moment": "^2.30.1", "moment-timezone": "^0.5.46", - "playwright": "^1.45.3", + "playwright": "^1.61.1", "prom-client": "^15.1.3", "redis": "^5.8.1", "serialize-error": "^12.0.0", "ts-node": "^10.9.2", - "uuid": "^11.1.0", + "uuid": "^11.1.1", "winston": "^3.17.0" }, "optionalDependencies": { "@zoom/rtms": "1.1.0" + }, + "overrides": { + "uuid": "$uuid" } } diff --git a/src/app/index.ts b/src/app/index.ts index 7bbc3bbf..9be9f81d 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -12,6 +12,19 @@ import { captureOperationalError } from '../monitoring/sentry'; const app = express(); +app.disable('x-powered-by'); +app.use((_req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Security-Policy', "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (NODE_ENV === 'production') { + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + next(); +}); + app.use(express.json({ verify: captureRawBody })); // Initialize Redis consumer service diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index 526704f4..e6d1f613 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -168,7 +168,13 @@ export class ZoomBot extends BotBase { private async joinMeeting({ pushState, ...params }: JoinParams & { pushState(state: BotStatus): void }): Promise { this.joinState = 'prejoin'; this._logger.info('Launching browser for Zoom...', { userId: params.userId }); - this.page = await createBrowserContext(params.url, this._correlationId, 'zoom', params.timezone); + this.page = await createBrowserContext( + params.url, + this._correlationId, + 'zoom', + params.timezone, + params.teamId, + ); const browserSession = getBrowserSession(this.page); const joinWork = this.joinMeetingInBrowser({ ...params, pushState }); diff --git a/src/config.ts b/src/config.ts index 6ef2d92f..5ee02748 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import dotenv from 'dotenv'; +import path from 'node:path'; import { UploaderType } from './types'; dotenv.config(); @@ -33,6 +34,12 @@ export interface ZoomRtmsCustomerCredentialsParseResult { error?: string; } +export interface ZoomChromeCustomerStorageStatePathsParseResult { + paths: Record; + entryErrors: Record; + error?: string; +} + const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null @@ -95,6 +102,69 @@ export const parseZoomRtmsCustomerCredentials = ( return { credentials, entryErrors }; }; +export const parseZoomChromeCustomerStorageStatePaths = ( + rawValue?: string +): ZoomChromeCustomerStorageStatePathsParseResult => { + const paths: Record = Object.create(null); + const entryErrors: Record = Object.create(null); + const teamByPath = new Map(); + if (!rawValue?.trim()) return { paths, entryErrors }; + + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + return { + paths, + entryErrors, + error: 'ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON must contain valid JSON', + }; + } + + if (!isPlainObject(parsed)) { + return { + paths, + entryErrors, + error: 'ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON must be an object keyed by teamId', + }; + } + + for (const [teamId, value] of Object.entries(parsed)) { + if ( + !teamId + || teamId.trim() !== teamId + || teamId.length > 256 + || /[\u0000-\u001f\u007f]/.test(teamId) + ) { + entryErrors[teamId] = 'teamId is invalid'; + continue; + } + + if ( + typeof value !== 'string' + || value.trim() !== value + || value.includes('\0') + || !path.isAbsolute(value) + ) { + entryErrors[teamId] = 'storage state path must be an absolute path'; + continue; + } + + const existingTeamId = teamByPath.get(value); + if (existingTeamId) { + delete paths[existingTeamId]; + entryErrors[existingTeamId] = 'storage state path must be unique per teamId'; + entryErrors[teamId] = 'storage state path must be unique per teamId'; + continue; + } + + teamByPath.set(value, teamId); + paths[teamId] = value; + } + + return { paths, entryErrors }; +}; + const requiredSettings = [ 'GCP_DEFAULT_REGION', 'GCP_MISC_BUCKET', @@ -146,6 +216,9 @@ if (!Number.isFinite(zoomRtmsEventTtlSeconds) || zoomRtmsEventTtlSeconds <= 0) { const zoomRtmsCustomerCredentials = parseZoomRtmsCustomerCredentials( process.env.ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON ); +const zoomChromeCustomerStorageStatePaths = parseZoomChromeCustomerStorageStatePaths( + process.env.ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON +); export default { port: process.env.PORT || 3000, @@ -163,7 +236,9 @@ export default { googleChromeUserDataDir: process.env.GOOGLE_CHROME_USER_DATA_DIR, googleChromeStorageStatePath: process.env.GOOGLE_CHROME_STORAGE_STATE_PATH, zoomChromeCdpUrl: process.env.ZOOM_CHROME_CDP_URL, - zoomChromeStorageStatePath: process.env.ZOOM_CHROME_STORAGE_STATE_PATH, + zoomChromeCustomerStorageStatePaths: zoomChromeCustomerStorageStatePaths.paths, + zoomChromeCustomerStorageStatePathErrors: zoomChromeCustomerStorageStatePaths.entryErrors, + zoomChromeCustomerStorageStatePathsError: zoomChromeCustomerStorageStatePaths.error, zoomRecordingTransport: zoomRecordingTransport as 'browser' | 'rtms', zoomRtmsFallbackEnabled: process.env.ZOOM_RTMS_FALLBACK_ENABLED === 'true', zoomRtms: { diff --git a/src/error.ts b/src/error.ts index a7ce5513..12f72a07 100644 --- a/src/error.ts +++ b/src/error.ts @@ -49,6 +49,16 @@ export class ZoomBrowserJoinBlockedError extends KnownError { } } +export class ZoomBrowserProfileConfigurationError extends KnownError { + public readonly teamId: string; + + constructor(teamId: string) { + super(`Zoom browser profile configuration is invalid for team ${teamId}`, false, 0); + this.name = 'ZoomBrowserProfileConfigurationError'; + this.teamId = teamId; + } +} + export type ZoomMeetingJoinFailureReason = | 'host_rejected' | 'sign_in_required' diff --git a/src/lib/chromium.test.ts b/src/lib/chromium.test.ts index a0c1f764..a66508b4 100644 --- a/src/lib/chromium.test.ts +++ b/src/lib/chromium.test.ts @@ -1,7 +1,99 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Browser, BrowserContext } from 'playwright'; -import { cleanupFailedBrowserSetup } from './chromium'; +import config, { parseZoomChromeCustomerStorageStatePaths } from '../config'; +import { ZoomBrowserProfileConfigurationError } from '../error'; +import { + cleanupFailedBrowserSetup, + getStorageStatePath, + selectZoomChromeStorageStatePath, +} from './chromium'; + +test('parses customer-scoped Zoom storage state paths without exposing values in errors', () => { + const parsed = parseZoomChromeCustomerStorageStatePaths(JSON.stringify({ + 'team-a': '/var/run/secrets/meeting-bot/zoom-profiles/team-a.json', + 'team-b': 'relative/must-not-appear.json', + })); + + assert.equal( + parsed.paths['team-a'], + '/var/run/secrets/meeting-bot/zoom-profiles/team-a.json', + ); + assert.match(parsed.entryErrors['team-b'], /absolute path/); + assert.equal(parsed.entryErrors['team-b'].includes('must-not-appear'), false); + + const malformed = parseZoomChromeCustomerStorageStatePaths('{secret-value'); + assert.match(malformed.error ?? '', /valid JSON/); + assert.equal(malformed.error?.includes('secret-value'), false); + + const duplicate = parseZoomChromeCustomerStorageStatePaths(JSON.stringify({ + 'team-a': '/profiles/shared.json', + 'team-b': '/profiles/shared.json', + })); + assert.equal(duplicate.paths['team-a'], undefined); + assert.equal(duplicate.paths['team-b'], undefined); + assert.match(duplicate.entryErrors['team-a'], /unique/); + assert.match(duplicate.entryErrors['team-b'], /unique/); +}); + +test('selects Zoom storage state only for the exact teamId', () => { + const paths = Object.assign(Object.create(null), { + 'team-a': '/profiles/team-a.json', + 'team-b': '/profiles/team-b.json', + }); + + assert.equal(selectZoomChromeStorageStatePath('team-a', paths), '/profiles/team-a.json'); + assert.equal(selectZoomChromeStorageStatePath('team-b', paths), '/profiles/team-b.json'); + assert.equal(selectZoomChromeStorageStatePath('TEAM-A', paths), undefined); + assert.equal(selectZoomChromeStorageStatePath('team', paths), undefined); + assert.equal(selectZoomChromeStorageStatePath('__proto__', paths), undefined); + assert.equal(selectZoomChromeStorageStatePath(undefined, paths), undefined); +}); + +test('does not fall back to another Zoom customer profile', () => { + const originalPaths = config.zoomChromeCustomerStorageStatePaths; + const originalEntryErrors = config.zoomChromeCustomerStorageStatePathErrors; + const originalError = config.zoomChromeCustomerStorageStatePathsError; + + try { + config.zoomChromeCustomerStorageStatePaths = Object.assign(Object.create(null), { + 'team-a': '/profiles/team-a.json', + }); + config.zoomChromeCustomerStorageStatePathErrors = Object.create(null); + config.zoomChromeCustomerStorageStatePathsError = undefined; + + assert.equal(getStorageStatePath('zoom', 'team-a'), '/profiles/team-a.json'); + assert.equal(getStorageStatePath('zoom', 'team-b'), undefined); + assert.equal(getStorageStatePath('zoom'), undefined); + } finally { + config.zoomChromeCustomerStorageStatePaths = originalPaths; + config.zoomChromeCustomerStorageStatePathErrors = originalEntryErrors; + config.zoomChromeCustomerStorageStatePathsError = originalError; + } +}); + +test('fails safely for an invalid declared customer profile', () => { + const originalPaths = config.zoomChromeCustomerStorageStatePaths; + const originalEntryErrors = config.zoomChromeCustomerStorageStatePathErrors; + const originalError = config.zoomChromeCustomerStorageStatePathsError; + + try { + config.zoomChromeCustomerStorageStatePaths = Object.create(null); + config.zoomChromeCustomerStorageStatePathErrors = Object.assign(Object.create(null), { + 'team-a': 'invalid path', + }); + config.zoomChromeCustomerStorageStatePathsError = undefined; + + assert.throws( + () => getStorageStatePath('zoom', 'team-a'), + ZoomBrowserProfileConfigurationError, + ); + } finally { + config.zoomChromeCustomerStorageStatePaths = originalPaths; + config.zoomChromeCustomerStorageStatePathErrors = originalEntryErrors; + config.zoomChromeCustomerStorageStatePathsError = originalError; + } +}); test('closes an owned browser when setup fails before creating a page', async () => { let closeCalls = 0; diff --git a/src/lib/chromium.ts b/src/lib/chromium.ts index 3bc1d0b8..21cb352e 100644 --- a/src/lib/chromium.ts +++ b/src/lib/chromium.ts @@ -1,5 +1,6 @@ import { Browser, BrowserContext, BrowserContextOptions, Page, chromium } from 'playwright'; import config from '../config'; +import { ZoomBrowserProfileConfigurationError } from '../error'; import { getCorrelationIdLog } from '../util/logger'; import { BrowserProvider, @@ -52,7 +53,7 @@ async function resizeBrowserWindow(page: Page, correlationId: string): Promise 0) await delay(retryDelayMs); } @@ -194,10 +195,29 @@ async function connectToExternalChromeWithRetry(cdpUrl: string, correlationId: s throw new Error(`Unable to connect to external Chrome within ${CDP_CONNECT_TIMEOUT_MS}ms: ${detail}`); } +export function selectZoomChromeStorageStatePath( + teamId: string | undefined, + customerPaths: Record, +): string | undefined { + if (!teamId || !Object.prototype.hasOwnProperty.call(customerPaths, teamId)) return undefined; + return customerPaths[teamId]; +} -function getStorageStatePath(botType: BotType): string | undefined { +export function getStorageStatePath(botType: BotType, teamId?: string): string | undefined { if (botType === 'google') return config.googleChromeStorageStatePath; - if (botType === 'zoom') return config.zoomChromeStorageStatePath; + if (botType === 'zoom') { + if (!teamId) return undefined; + if ( + config.zoomChromeCustomerStorageStatePathsError + || config.zoomChromeCustomerStorageStatePathErrors[teamId] + ) { + throw new ZoomBrowserProfileConfigurationError(teamId); + } + return selectZoomChromeStorageStatePath( + teamId, + config.zoomChromeCustomerStorageStatePaths, + ); + } return undefined; } @@ -298,7 +318,8 @@ export async function cleanupFailedBrowserSetup({ } } catch (cleanupError) { console.warn( - `${getCorrelationIdLog(correlationId)} Failed to clean up partial browser setup`, + '%s Failed to clean up partial browser setup', + getCorrelationIdLog(correlationId), cleanupError instanceof Error ? cleanupError.message : cleanupError ); } @@ -309,11 +330,13 @@ async function createBrowserContext( correlationId: string, botType: BotType = 'google', timezone?: string, + teamId?: string, ): Promise { const log = getCorrelationIdLog(correlationId); const trustedOrigin = getValidatedProviderOrigin(url, botType); const timezoneId = normalizeBrowserTimezone(timezone); const browserArgs = getBrowserArgs(botType); + const storageStatePath = getStorageStatePath(botType, teamId); const contextOptions: BrowserContextOptions = { viewport: VIEWPORT_SIZE, locale: 'en-US', @@ -349,8 +372,8 @@ async function createBrowserContext( const existingContext = botType === 'google' ? browser.contexts()[0] : undefined; context = existingContext ?? await browser.newContext({ ...contextOptions, - ...(getStorageStatePath(botType) - ? { storageState: getStorageStatePath(botType) } + ...(storageStatePath + ? { storageState: storageStatePath } : {}), }); ownsContext = !existingContext; @@ -438,8 +461,8 @@ async function createBrowserContext( try { context = await browser.newContext({ ...contextOptions, - ...(getStorageStatePath(botType) - ? { storageState: getStorageStatePath(botType) } + ...(storageStatePath + ? { storageState: storageStatePath } : {}), }); diff --git a/src/lib/diskUploaderPath.test.ts b/src/lib/diskUploaderPath.test.ts new file mode 100644 index 00000000..db337b9a --- /dev/null +++ b/src/lib/diskUploaderPath.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import path from 'node:path'; +import { + assertPathWithinTempFolder, + assertSafeFileComponent, +} from '../middleware/disk-uploader'; + +test('accepts only allowlisted temporary file components', () => { + assert.doesNotThrow(() => assertSafeFileComponent('recording_01-safe', 'test component')); + assert.throws(() => assertSafeFileComponent('../recording', 'test component'), /Invalid test component/); + assert.throws(() => assertSafeFileComponent('recording.mp4', 'test component'), /Invalid test component/); +}); + +test('keeps temporary recording paths inside the application media root', () => { + const root = path.resolve(process.cwd(), 'dist', '_tempvideo'); + const nested = path.join(root, 'tenant', 'recording.mp4'); + + assert.equal(assertPathWithinTempFolder(nested), nested); + assert.throws( + () => assertPathWithinTempFolder(path.resolve(root, '..', 'recording.mp4')), + /escapes its isolated directory/, + ); +}); diff --git a/src/middleware/disk-uploader.ts b/src/middleware/disk-uploader.ts index 1f66eae7..5991849f 100644 --- a/src/middleware/disk-uploader.ts +++ b/src/middleware/disk-uploader.ts @@ -17,11 +17,29 @@ import { getStorageProvider } from '../uploader/providers/factory'; import { getTimeString } from '../lib/datetime'; import { notifyRecordingCompleted, RecordingCompletedPayload } from '../services/notificationService'; import { writeWebmDurationMetadata } from '../lib/webmDuration'; +import { encodeFileNameSafebase64 } from '../util/strings'; console.log(' ----- PWD OR CWD ----- ', process.cwd()); const tempFolder = path.join(process.cwd(), 'dist', '_tempvideo'); const execFileAsync = promisify(execFile); +const SAFE_FILE_COMPONENT = /^[A-Za-z0-9_-]+$/; +const SAFE_FILE_EXTENSION = /^\.[A-Za-z0-9]+$/; + +export function assertSafeFileComponent(value: string, label: string): void { + if (!value || !SAFE_FILE_COMPONENT.test(value)) { + throw new Error(`Invalid ${label} for temporary recording path`); + } +} + +export function assertPathWithinTempFolder(filePath: string): string { + const root = path.resolve(tempFolder); + const resolved = path.resolve(filePath); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error('Temporary recording path escapes its isolated directory'); + } + return resolved; +} function isNoSuchUploadError(err: any, userId: string, logger: Logger): boolean { /** @@ -337,8 +355,10 @@ class DiskUploader implements IUploader { this._tempFileId, normalizedExtension ); - const sourcePath = path.resolve(filePath); - const resolvedTarget = path.resolve(targetPath); + // `filePath` is produced by the internal RTMS recorder; still enforce the + // temporary-media boundary before moving it into the uploader location. + const sourcePath = assertPathWithinTempFolder(filePath); + const resolvedTarget = assertPathWithinTempFolder(targetPath); if (sourcePath !== resolvedTarget) { await fs.promises.unlink(resolvedTarget).catch((error: NodeJS.ErrnoException) => { @@ -383,15 +403,21 @@ class DiskUploader implements IUploader { } private static getFolderPath(userId: string) { - const folderPath = path.join(tempFolder, userId); + const safeUserId = encodeFileNameSafebase64(userId); + assertSafeFileComponent(safeUserId, 'userId'); + const folderPath = path.join(tempFolder, safeUserId); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return folderPath; } private static getFilePath(userId: string, tempFileId: string, fileExtension: string) { + assertSafeFileComponent(tempFileId, 'temporary file ID'); + if (!SAFE_FILE_EXTENSION.test(fileExtension)) { + throw new Error('Invalid temporary recording file extension'); + } const fileName = `${tempFileId}${fileExtension}`; const folderPath = DiskUploader.getFolderPath(userId); - const filePath = path.join(folderPath, fileName); - return filePath; + const filePath = path.join(folderPath, fileName); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + return assertPathWithinTempFolder(filePath); } private async processRecordingUpload() { diff --git a/src/rtms/RtmsMediaRecorder.ts b/src/rtms/RtmsMediaRecorder.ts index c00b9055..48a3c1c9 100644 --- a/src/rtms/RtmsMediaRecorder.ts +++ b/src/rtms/RtmsMediaRecorder.ts @@ -75,9 +75,10 @@ export class RtmsMediaRecorder { private readonly logger: Logger, private readonly blackFrame: Buffer ) { - this.audioPath = path.join(folderPath, 'audio.raw'); - this.videoPath = path.join(folderPath, 'video.h264'); - this.outputPath = path.join(folderPath, 'recording.mp4'); + // `folderPath` is generated by mkdtemp inside the private create() factory. + this.audioPath = path.join(folderPath, 'audio.raw'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + this.videoPath = path.join(folderPath, 'video.h264'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal + this.outputPath = path.join(folderPath, 'recording.mp4'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal this.audioStream = fs.createWriteStream(this.audioPath, { highWaterMark: 4 * 1024 * 1024 }); this.videoStream = fs.createWriteStream(this.videoPath, { highWaterMark: 4 * 1024 * 1024 }); const rememberWriteError = (error: Error) => { @@ -278,7 +279,7 @@ export class RtmsMediaRecorder { folderPath: string, logger: Logger ): Promise { - const filePath = path.join(folderPath, 'black-frame.h264'); + const filePath = path.join(folderPath, 'black-frame.h264'); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal try { await execFileAsync('ffmpeg', [ '-y', '-hide_banner', '-loglevel', 'error', diff --git a/src/util/logger.ts b/src/util/logger.ts index 8203e367..4ecc2de3 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -108,7 +108,7 @@ export const createCorrelationId = ({ const entityId = botId ?? eventId; const name = `${userId}:${entityId}:${url}`; const id = uuidv5(name, NAMESPACE); - console.log(`[correlationId:${id}]`, { + console.log('[correlationId:%s]', id, { correlationId: id, userId, eventId, @@ -121,7 +121,7 @@ export const createCorrelationId = ({ } catch(err) { console.error('Unable to create deterministic correlationId', { userId, teamId, err }); const id = v4(); - console.log(`[correlationId:${id}]`, { + console.log('[correlationId:%s]', id, { correlationId: id, userId, eventId, From a8c0c68536667b7a5a5cddfd45cc7423b63b82cd Mon Sep 17 00:00:00 2001 From: jakob Date: Sun, 19 Jul 2026 12:42:09 +0200 Subject: [PATCH 48/53] fix(chromium): improve browser mimicry and add configurable storage state paths --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 42134483..94d71ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,13 @@ assets/videos *.wav __pycache__/ .env +.env.* +!.env.example .chrome/ gcp-key assets/screenshots /data debug-videos/ -.env.native # Local-only docker compose override for signal-handling testing — see compose.override.yml header compose.override.yml From a569671e26660ec7ff40044a6e7cbb562a613084 Mon Sep 17 00:00:00 2001 From: jakob Date: Sun, 19 Jul 2026 13:16:25 +0200 Subject: [PATCH 49/53] feat(rtms): support internal and customer credentials --- .env.example | 6 + README.md | 19 ++- docs/zoom-rtms.md | 221 +++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/config.ts | 13 ++ src/rtms/ZoomRtmsCredentials.test.ts | 67 +++++++- src/rtms/ZoomRtmsCredentials.ts | 21 +-- 8 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 docs/zoom-rtms.md diff --git a/.env.example b/.env.example index c8e23846..c0573d01 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,12 @@ GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 # Zoom recording transport: browser (default) or rtms. # RTMS is a separate Zoom app-based transport and does not use Chrome/CDP. ZOOM_RECORDING_TRANSPORT=browser +# Allow RTMS only after an explicit pre-join automated-bot rejection. +ZOOM_RTMS_FALLBACK_ENABLED=false +# Exact teamId allowed to use the global/internal OAuth credentials below. +# ZOOM_RTMS_GLOBAL_TEAM_ID=internal-team-id +# Exact teamId allowlist for customer S2S OAuth credentials. +# ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id"}} # ZOOM_RTMS_CLIENT_ID= # ZOOM_RTMS_CLIENT_SECRET= # ZOOM_RTMS_WEBHOOK_SECRET= diff --git a/README.md b/README.md index 441c5b6f..eb29447f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ Content-Type: application/json Zoom uses the browser recorder by default. With `ZOOM_RTMS_FALLBACK_ENABLED=true`, only an explicit Zoom automated-bot rejection before admission can fall back to RTMS. Host rejection, sign-in requirements, lobby timeouts, browser crashes and recording failures never trigger that fallback. -Fallback credentials are selected by the job's `teamId` from `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. Store S2S account credentials, not short-lived access tokens: +See [Zoom RTMS operations and customer onboarding](docs/zoom-rtms.md) for the complete setup, deployment modes, Zoom prerequisites, customer onboarding, and troubleshooting guide. + +Fallback credentials are selected by the job's exact `teamId`: customer teams use `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`, while the exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the internal/global settings. Store customer S2S account credentials, not short-lived access tokens: ```json { @@ -141,14 +143,20 @@ Fallback credentials are selected by the job's `teamId` from `ZOOM_RTMS_CUSTOMER } ``` -To record through Zoom Realtime Media Streams instead, set `ZOOM_RECORDING_TRANSPORT=rtms` and configure a user-managed Zoom General app with RTMS enabled. Set its public HTTPS webhook endpoint to `POST /zoom/rtms/webhook` and subscribe to `meeting.rtms_started`, `meeting.rtms_stopped`, and `meeting.rtms_interrupted`. +To use global/internal Zoom Realtime Media Streams directly, set `ZOOM_RECORDING_TRANSPORT=rtms`, set `ZOOM_RTMS_GLOBAL_TEAM_ID` to the exact internal job `teamId`, and configure a user-managed Zoom General app with RTMS enabled. Customer RTMS-first operation instead requires an enabled exact customer JSON entry. Set the General app's public HTTPS webhook endpoint to `POST /zoom/rtms/webhook` and subscribe to `meeting.rtms_started`, `meeting.rtms_stopped`, and `meeting.rtms_interrupted`. -Required settings: +Shared RTMS app settings: - `ZOOM_RTMS_CLIENT_ID`, `ZOOM_RTMS_CLIENT_SECRET`, `ZOOM_RTMS_WEBHOOK_SECRET` +- `REDIS_CONSUMER_ENABLED=true` for staging/production so webhook events reach the correct recording replica + +Internal/global mode only: + +- `ZOOM_RTMS_GLOBAL_TEAM_ID` to restrict the global/internal OAuth credentials to one exact Winkk team - Prefer a Server-to-Server OAuth app with `ZOOM_RTMS_OAUTH_ACCOUNT_ID`, `ZOOM_RTMS_OAUTH_CLIENT_ID`, and `ZOOM_RTMS_OAUTH_CLIENT_SECRET`; a short-lived `ZOOM_RTMS_OAUTH_ACCESS_TOKEN` is also supported for local testing - `ZOOM_RTMS_PARTICIPANT_USER_ID` to select and correlate the authorized host when using account-level OAuth -- `REDIS_CONSUMER_ENABLED=true` for staging/production so webhook events reach the correct recording replica + +Customer mode uses the exact enabled `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` entry instead of the internal/global OAuth settings. The General app needs `meeting:read:meeting_audio` and `meeting:read:meeting_video`; the OAuth app needs `meeting:update:participant_rtms_app_status` or its admin variant. The app must be authorized by the meeting host/account. RTMS does not anonymously join arbitrary Zoom links; the password in a join URL is not used. The RTMS output contains Zoom's mixed audio and active-speaker H.264 video and is uploaded as MP4 through the existing uploader. The official RTMS Node SDK currently requires Linux x64 (or macOS arm64); the browser transport remains available on other image architectures. @@ -501,7 +509,8 @@ Notes: | `ZOOM_CHROME_CDP_URL` | Optional CDP endpoint for isolated Zoom browser contexts. | - | | `ZOOM_RECORDING_TRANSPORT` | Primary Zoom transport (`browser` or `rtms`). | `browser` | | `ZOOM_RTMS_FALLBACK_ENABLED` | Allow RTMS only after an explicit pre-join Zoom automated-bot block. | `false` | -| `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Team-keyed S2S OAuth credentials for the RTMS fallback. | - | +| `ZOOM_RTMS_GLOBAL_TEAM_ID` | Exact internal team allowed to use global RTMS OAuth credentials. | - | +| `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Exact team-keyed customer S2S OAuth credentials for RTMS. | - | | `SENTRY_DSN` | Optional Sentry DSN; monitoring is a complete no-op when omitted. | - | | `SENTRY_ENVIRONMENT` | Sentry environment tag. | `NODE_ENV` | | `SENTRY_RELEASE` | Sentry release tag. | - | diff --git a/docs/zoom-rtms.md b/docs/zoom-rtms.md new file mode 100644 index 00000000..74ebe7ee --- /dev/null +++ b/docs/zoom-rtms.md @@ -0,0 +1,221 @@ +# Zoom RTMS operations and customer onboarding + +This guide covers two supported RTMS use cases: + +1. An internal private Zoom app used only by the developer's own Zoom account. +2. Explicit customer enablement selected by the exact Winkk `teamId`. + +RTMS does not join a meeting as an anonymous participant. It streams meeting media for an authorized Zoom user. The meeting URL is used only to extract the meeting ID; its password does not authorize RTMS. + +## Deployment modes + +| Mode | Primary transport | Credential selection | Intended use | +| --- | --- | --- | --- | +| Browser only | `browser` | None | Disable RTMS | +| Internal RTMS | `rtms` | Exact `ZOOM_RTMS_GLOBAL_TEAM_ID` | Private app in the company's own Zoom account | +| Customer fallback | `browser` with fallback enabled | Exact customer JSON entry or exact `ZOOM_RTMS_GLOBAL_TEAM_ID` | Browser first; RTMS only after an automated-bot block before admission | +| Customer RTMS first | `rtms` | Exact customer JSON entry | RTMS first for every configured team | + +Credential selection is fail-closed: + +- An enabled exact customer entry uses that customer's S2S OAuth credentials. +- The exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the global/internal OAuth credentials. +- Disabled, invalid, and unknown teams do not receive global credentials. + +`ZOOM_RECORDING_TRANSPORT` is deployment-wide. A single deployment cannot currently use RTMS first for the internal team while using browser first for customers. That requires a future per-team transport policy or separate deployments. + +`teamId` is a routing key, not proof of tenant ownership. The credential resolver assumes that the job producer already authenticated the caller and verified that the caller belongs to that team. Restrict the HTTP endpoint and Redis queue to trusted producers, validate bearer-token-to-team membership upstream, and never authorize RTMS from an unverified client-supplied `teamId`. + +## Common Zoom prerequisites + +Create one user-managed Zoom General app with RTMS enabled. Configure: + +- `meeting:read:meeting_audio` +- `meeting:read:meeting_video` +- `meeting.rtms_started` +- `meeting.rtms_stopped` +- `meeting.rtms_interrupted` +- A public HTTPS event endpoint ending in `/zoom/rtms/webhook` + +The app must have Zoom Developer Pack credits and the Zoom account must allow apps to access shared real-time meeting content. In staging and production, run the meeting bot with `REDIS_CONSUMER_ENABLED=true` so webhook events reach the correct replica. + +The following global app credentials are always required: + +```env +ZOOM_RTMS_CLIENT_ID=general-app-client-id +ZOOM_RTMS_CLIENT_SECRET=general-app-client-secret +ZOOM_RTMS_WEBHOOK_SECRET=general-app-webhook-secret +``` + +They belong to the shared RTMS General app and are used to verify webhooks and connect to RTMS media servers. Customer JSON entries do not replace these values. + +## Mode A: internal private app + +A private General app can be used without Marketplace publication by users in its own Zoom account. This is different from an unlisted app: an unlisted production app still completes Zoom review before external distribution. + +Create a Server-to-Server OAuth app in the same internal Zoom account with: + +- `meeting:update:participant_rtms_app_status`, or +- `meeting:update:participant_rtms_app_status:admin` + +Configure the internal Winkk team and the account-level OAuth credentials: + +```env +ZOOM_RECORDING_TRANSPORT=rtms +ZOOM_RTMS_GLOBAL_TEAM_ID=internal-winkk-team-id +ZOOM_RTMS_OAUTH_ACCOUNT_ID=internal-zoom-account-id +ZOOM_RTMS_OAUTH_CLIENT_ID=internal-s2s-client-id +ZOOM_RTMS_OAUTH_CLIENT_SECRET=internal-s2s-client-secret +ZOOM_RTMS_PARTICIPANT_USER_ID=internal-zoom-user-id +``` + +Every Zoom job still contains a Winkk `teamId`. Only a job whose `teamId` exactly matches `ZOOM_RTMS_GLOBAL_TEAM_ID` can use these credentials. No customer JSON entry is required for that internal team. + +For a short local test, `ZOOM_RTMS_OAUTH_ACCESS_TOKEN` can replace the account ID, client ID, and client secret. Do not store an expiring access token in production. + +## Mode B: customer enablement + +External customers must be able to authorize the shared RTMS General app. Use either: + +- a Zoom-approved Beta authorization URL for limited testing, or +- a published or unlisted production app after Zoom review. + +A private app cannot be shared with a different Zoom account. + +Each customer currently creates a private Server-to-Server OAuth app in their own Zoom account and grants: + +```text +meeting:update:participant_rtms_app_status:admin +``` + +Collect these values through an approved secret-sharing channel: + +- Zoom account ID +- S2S client ID +- S2S client secret +- Zoom user ID of the participant or host used for RTMS +- Exact Winkk `teamId` + +Do not accept credentials in chat, email, screenshots, tickets, or source control. + +Add the customer to `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`: + +```json +{ + "customer-team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id" + } +} +``` + +The outer key is the Winkk `teamId`. `participantUserId` is the Zoom user ID, not an email address. All values are required. + +For browser-first customer fallback: + +```env +ZOOM_RECORDING_TRANSPORT=browser +ZOOM_RTMS_FALLBACK_ENABLED=true +ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={...} +``` + +Only an explicit Zoom automated-bot block before browser admission triggers RTMS. Host rejection, sign-in requirements, lobby timeout, browser failure, and recording failure do not trigger the fallback. + +For RTMS first, set `ZOOM_RECORDING_TRANSPORT=rtms`. Enabled exact customer mappings and the exact internal team remain eligible; all other teams fail closed. + +## Customer onboarding checklist + +1. Confirm the customer's exact Winkk `teamId` from the job payload. +2. Ensure the shared Winkk General app is approved for external authorization. +3. Have the customer or their Zoom admin authorize the Winkk app. +4. Enable shared real-time meeting content in the customer's Zoom account settings. +5. Have the customer create and activate their S2S OAuth app with the required admin scope. +6. Obtain the account ID, client ID, client secret, and Zoom participant user ID securely. +7. Add an enabled exact entry to the customer JSON in the correct environment. +8. Restart the process or pods so the environment is reloaded. +9. Start a controlled meeting and verify the webhook, stream connection, finalization, and upload. + +## Own and external meetings + +| Scenario | Browser | RTMS REST start | +| --- | --- | --- | +| Customer hosts the meeting | Can join as a visible guest | Supported when the app and user are authorized | +| Customer is invited to an external meeting and has joined | Can join as a visible guest | Supported subject to host policy and approval | +| Customer only received a shared public link | Can attempt a visible guest join | The link alone is insufficient | +| Customer is the actual host but has not joined | Can attempt a visible guest join | May start before the host only when Zoom's Join Before Host setting permits it | +| Customer is an alternate host or invited participant but has not joined | Can attempt a visible guest join | Unavailable until that authorized user joins | + +For an external meeting, an authorized alternate host or invited participant must have joined; the meeting host or a designated alternate host must also be present. The actual host is the exception: Zoom can permit RTMS before the host joins when Join Before Host is enabled. The meeting host can allow, require approval for, or reject RTMS access. + +An in-meeting Zoom App can also call `startRTMS()` after the user opens it and the host approves. That in-meeting UI is not implemented in this repository. + +## Dev, production, and 1Password + +Use separate Zoom development and production credentials and separate 1Password vault items. + +Production RTMS workloads must run on `linux/amd64`. The Zoom RTMS SDK rejects Linux ARM64 even if a multi-architecture meeting-bot image is available; pin RTMS-capable pods to x86-64 nodes. + +Recommended items: + +- `Meeting Bot App Secrets`: shared General app credentials, internal S2S credentials, Sentry, and other application secrets. +- `Meeting Bot Customer RTMS Credentials`: only `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. + +The automatically generated 1Password `Password` field is not used by the meeting bot. The internal team ID and transport flags are not secrets and can live in the deployment configuration. + +Configure a separate public HTTPS `/zoom/rtms/webhook` endpoint for each environment and verify the rendered URL against the current infrastructure before entering it in Zoom. + +After changing a secret, confirm that the 1Password operator updated the Kubernetes Secret and restarted the meeting-bot pods. Never print secret values while checking the deployment. + +## Rotation and offboarding + +- Rotate a client secret immediately if it appears in chat, a screenshot, a log, or source control. +- Update every environment that uses the credential. +- Set a customer entry to `enabled: false` to revoke RTMS for that team, then restart the pods. +- Remove the entry after the retention period required by the operating process. +- When a customer is offboarded, have that customer remove its installation of the shared General app from its Zoom account. Do not disable the shared app globally while other tenants use it. + +Disabled and invalid customer entries never fall back to internal credentials. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Missing credentials for a team | Exact job `teamId`, spelling, JSON key, and `enabled` | +| Invalid customer configuration | JSON syntax and all required string fields | +| OAuth token request fails | Customer account ID, S2S client ID, secret, activation, and scopes | +| Zoom code `3000` | Meeting has not started; the bot retries until the join deadline | +| Zoom code `2308` or `2309` | Participant role or external-host authorization; customer credentials wait for approval, while global/internal credentials fail immediately | +| Zoom code `2310` | RTMS entitlement, app authorization, account setting, meeting state, and scope | +| Zoom code `2312` | `participantUserId` is incorrect | +| Zoom code `13262` | The General app client ID is missing from Zoom's "Allow apps to access meeting content" setting | +| Zoom code `13267` | RTMS app access is disabled in Zoom settings | +| Zoom code `13273` | The meeting does not support RTMS | +| No fresh start event | Webhook URL, webhook secret, event subscriptions, Redis, and operator ID | +| SDK unavailable | Run RTMS on Linux x64 or macOS arm64 | +| Duplicate recording rejected | Another stream is already reserved for the meeting and operator | + +The meeting bot waits up to `JOIN_WAIT_TIME_MINUTES` for initial authorization and a fresh `meeting.rtms_started` event. + +## Current product boundary + +Customer onboarding is manual and team-scoped. The repository does not yet implement: + +- a Zoom OAuth authorization-code callback, +- encrypted refresh-token storage per customer, +- a self-service "Connect Zoom" flow, +- per-user RTMS enablement within one Winkk team, +- per-team primary transport selection, or +- an in-meeting `startRTMS()` Zoom App interface. + +For self-service onboarding, use a reviewed shared General app and store OAuth grants dynamically per Winkk team instead of collecting customer S2S secrets. + +## Zoom references + +- [Add Realtime Media Streams to your app](https://developers.zoom.us/docs/rtms/meetings/add-features/) +- [Work with RTMS streams](https://developers.zoom.us/docs/rtms/meetings/work-with-streams/) +- [Zoom OAuth 2.0](https://developers.zoom.us/docs/integrations/oauth/) +- [Zoom app distribution](https://developers.zoom.us/docs/distribute/) +- [Sharing private and Beta apps](https://developers.zoom.us/docs/distribute/sharing-private-and-beta-apps/) diff --git a/package-lock.json b/package-lock.json index 2fa5167a..3b141faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.11", + "version": "1.3.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.11", + "version": "1.3.12", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1089.0", diff --git a/package.json b/package.json index d456bc38..7f4161a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.11", + "version": "1.3.12", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index 5ee02748..ee8db3d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -216,6 +216,18 @@ if (!Number.isFinite(zoomRtmsEventTtlSeconds) || zoomRtmsEventTtlSeconds <= 0) { const zoomRtmsCustomerCredentials = parseZoomRtmsCustomerCredentials( process.env.ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON ); +const zoomRtmsGlobalTeamId = process.env.ZOOM_RTMS_GLOBAL_TEAM_ID; +if ( + typeof zoomRtmsGlobalTeamId !== 'undefined' + && ( + !zoomRtmsGlobalTeamId + || zoomRtmsGlobalTeamId.trim() !== zoomRtmsGlobalTeamId + || zoomRtmsGlobalTeamId.length > 256 + || /[\u0000-\u001f\u007f]/.test(zoomRtmsGlobalTeamId) + ) +) { + throw new Error('ZOOM_RTMS_GLOBAL_TEAM_ID must contain one valid exact teamId'); +} const zoomChromeCustomerStorageStatePaths = parseZoomChromeCustomerStorageStatePaths( process.env.ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON ); @@ -250,6 +262,7 @@ export default { oauthClientId: process.env.ZOOM_RTMS_OAUTH_CLIENT_ID, oauthClientSecret: process.env.ZOOM_RTMS_OAUTH_CLIENT_SECRET, participantUserId: process.env.ZOOM_RTMS_PARTICIPANT_USER_ID, + globalTeamId: zoomRtmsGlobalTeamId, eventTtlSeconds: zoomRtmsEventTtlSeconds, customerCredentials: zoomRtmsCustomerCredentials.credentials, customerCredentialErrors: zoomRtmsCustomerCredentials.entryErrors, diff --git a/src/rtms/ZoomRtmsCredentials.test.ts b/src/rtms/ZoomRtmsCredentials.test.ts index 751d1936..437cbb95 100644 --- a/src/rtms/ZoomRtmsCredentials.test.ts +++ b/src/rtms/ZoomRtmsCredentials.test.ts @@ -18,6 +18,7 @@ const original = { oauthClientId: config.zoomRtms.oauthClientId, oauthClientSecret: config.zoomRtms.oauthClientSecret, participantUserId: config.zoomRtms.participantUserId, + globalTeamId: config.zoomRtms.globalTeamId, customerCredentials: config.zoomRtms.customerCredentials, customerCredentialErrors: config.zoomRtms.customerCredentialErrors, customerCredentialsError: config.zoomRtms.customerCredentialsError, @@ -39,6 +40,7 @@ test.beforeEach(() => { config.zoomRtms.oauthClientId = undefined; config.zoomRtms.oauthClientSecret = undefined; config.zoomRtms.participantUserId = undefined; + config.zoomRtms.globalTeamId = undefined; config.zoomRtms.customerCredentials = Object.create(null); config.zoomRtms.customerCredentialErrors = Object.create(null); config.zoomRtms.customerCredentialsError = undefined; @@ -52,6 +54,7 @@ test.after(() => { config.zoomRtms.oauthClientId = original.oauthClientId; config.zoomRtms.oauthClientSecret = original.oauthClientSecret; config.zoomRtms.participantUserId = original.participantUserId; + config.zoomRtms.globalTeamId = original.globalTeamId; config.zoomRtms.customerCredentials = original.customerCredentials; config.zoomRtms.customerCredentialErrors = original.customerCredentialErrors; config.zoomRtms.customerCredentialsError = original.customerCredentialsError; @@ -104,14 +107,70 @@ test('fails typed for missing, disabled, and invalid fallback credentials', () = ); }); -test('uses legacy global OAuth only for explicit RTMS mode', () => { - config.zoomRecordingTransport = 'rtms'; +test('uses global OAuth only for the explicitly configured internal team', () => { + config.zoomRtms.globalTeamId = 'internal-team'; config.zoomRtms.oauthAccessToken = 'legacy-access-token'; config.zoomRtms.participantUserId = 'legacy-operator'; - const selected = resolveZoomRtmsCredentials('team-without-mapping'); + const selected = resolveZoomRtmsCredentials('internal-team'); assert.equal(selected.api.source, 'legacy'); assert.equal(selected.api.oauthAccessToken, 'legacy-access-token'); - assert.equal(selected.eventScope.customerId, 'legacy'); + assert.equal(selected.eventScope.customerId, 'internal-team'); assert.equal(selected.eventScope.operatorId, 'legacy-operator'); + + assert.throws( + () => resolveZoomRtmsCredentials('team-without-mapping'), + ZoomRtmsCredentialsMissingError + ); +}); + +test('never falls back to global OAuth for disabled or invalid customer credentials', () => { + config.zoomRtms.globalTeamId = 'internal-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.customerCredentials['internal-team'] = customer(false); + + assert.throws( + () => resolveZoomRtmsCredentials('internal-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'disabled' + ); + + delete config.zoomRtms.customerCredentials['internal-team']; + config.zoomRtms.customerCredentialErrors['internal-team'] = 'invalid fields'; + assert.throws( + () => resolveZoomRtmsCredentials('internal-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_customer_configuration' + ); + + config.zoomRtms.customerCredentialErrors = Object.create(null); + config.zoomRtms.customerCredentialsError = 'invalid JSON'; + assert.throws( + () => resolveZoomRtmsCredentials('internal-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_customer_configuration' + ); +}); + +test('supports internal and customer credentials in the same deployment', () => { + config.zoomRtms.globalTeamId = 'internal-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.customerCredentials['customer-team'] = customer(); + + assert.equal(resolveZoomRtmsCredentials('internal-team').api.source, 'legacy'); + assert.equal(resolveZoomRtmsCredentials('customer-team').api.source, 'customer'); + assert.throws( + () => resolveZoomRtmsCredentials('unknown-team'), + ZoomRtmsCredentialsMissingError + ); +}); + +test('prefers an enabled exact customer mapping over global credentials', () => { + config.zoomRtms.globalTeamId = 'shared-team'; + config.zoomRtms.oauthAccessToken = 'legacy-access-token'; + config.zoomRtms.customerCredentials['shared-team'] = customer(); + + const selected = resolveZoomRtmsCredentials('shared-team'); + assert.equal(selected.api.source, 'customer'); + assert.equal(selected.api.oauthAccountId, 'account-a'); }); diff --git a/src/rtms/ZoomRtmsCredentials.ts b/src/rtms/ZoomRtmsCredentials.ts index 27645204..8dee69e8 100644 --- a/src/rtms/ZoomRtmsCredentials.ts +++ b/src/rtms/ZoomRtmsCredentials.ts @@ -23,12 +23,12 @@ export interface ResolvedZoomRtmsCredentials { eventScope: ZoomRtmsEventScope; } -const legacyCredentials = (): ResolvedZoomRtmsCredentials => { +const globalCredentials = (teamId: string): ResolvedZoomRtmsCredentials => { const rtmsClientId = config.zoomRtms.clientId; if (!rtmsClientId) { throw new ZoomRtmsCredentialConfigurationError( 'invalid_global_configuration', - 'legacy', + teamId, 'ZOOM_RTMS_CLIENT_ID is required for RTMS' ); } @@ -42,15 +42,15 @@ const legacyCredentials = (): ResolvedZoomRtmsCredentials => { if (!hasAccessToken && !hasServerCredentials) { throw new ZoomRtmsCredentialConfigurationError( 'invalid_global_configuration', - 'legacy', - 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the legacy Zoom RTMS OAuth account credentials' + teamId, + 'Configure ZOOM_RTMS_OAUTH_ACCESS_TOKEN or the global Zoom RTMS OAuth account credentials' ); } return { api: { source: 'legacy', - customerId: 'legacy', + customerId: teamId, rtmsClientId, participantUserId: config.zoomRtms.participantUserId, oauthAccessToken: config.zoomRtms.oauthAccessToken, @@ -59,15 +59,14 @@ const legacyCredentials = (): ResolvedZoomRtmsCredentials => { oauthClientSecret: config.zoomRtms.oauthClientSecret, }, eventScope: { - customerId: 'legacy', + customerId: teamId, operatorId: config.zoomRtms.participantUserId, }, }; }; export const resolveZoomRtmsCredentials = ( - teamId: string, - allowLegacy = config.zoomRecordingTransport === 'rtms' + teamId: string ): ResolvedZoomRtmsCredentials => { const customer = config.zoomRtms.customerCredentials[teamId]; if (customer?.enabled) { @@ -97,8 +96,6 @@ export const resolveZoomRtmsCredentials = ( }; } - if (allowLegacy) return legacyCredentials(); - if (config.zoomRtms.customerCredentialsError) { throw new ZoomRtmsCredentialConfigurationError( 'invalid_customer_configuration', @@ -124,5 +121,9 @@ export const resolveZoomRtmsCredentials = ( ); } + if (config.zoomRtms.globalTeamId === teamId) { + return globalCredentials(teamId); + } + throw new ZoomRtmsCredentialsMissingError(teamId); }; From 04d3c62715ffd6890dc425dd3a0810a465eaa75d Mon Sep 17 00:00:00 2001 From: jakob Date: Sun, 19 Jul 2026 14:03:49 +0200 Subject: [PATCH 50/53] feat(rtms): support shared and dedicated customer apps --- .env.example | 6 +- README.md | 34 ++++++- docs/zoom-rtms.md | 78 +++++++++++---- package-lock.json | 4 +- package.json | 2 +- src/bots/ZoomBot.ts | 7 +- src/config.ts | 90 ++++++++++++++++- src/rtms/ZoomRtmsCredentials.test.ts | 138 +++++++++++++++++++++++++++ src/rtms/ZoomRtmsCredentials.ts | 56 ++++++++--- src/rtms/ZoomRtmsEventStore.test.ts | 47 ++++++++- src/rtms/ZoomRtmsEventStore.ts | 131 +++++++++++++++---------- src/rtms/ZoomRtmsSdkClient.test.ts | 29 ++++++ src/rtms/ZoomRtmsSdkClient.ts | 40 ++++---- src/rtms/ZoomRtmsTransport.ts | 14 +-- src/rtms/types.ts | 13 +++ src/rtms/webhook.test.ts | 133 +++++++++++++++++++++++++- src/rtms/webhook.ts | 26 ++++- 17 files changed, 721 insertions(+), 127 deletions(-) create mode 100644 src/rtms/ZoomRtmsSdkClient.test.ts diff --git a/.env.example b/.env.example index c0573d01..dc82f152 100644 --- a/.env.example +++ b/.env.example @@ -35,8 +35,8 @@ GOOGLE_ANONYMOUS_JOIN_REQUEST_ATTEMPTS=10 # Values are read-only secret-mounted file paths selected by exact teamId. # ZOOM_CHROME_CUSTOMER_STORAGE_STATE_PATHS_JSON={"team-id":"/var/run/secrets/meeting-bot/zoom-profiles/team-id.json"} -# Zoom recording transport: browser (default) or rtms. -# RTMS is a separate Zoom app-based transport and does not use Chrome/CDP. +# Zoom transport: browser-only, browser with RTMS fallback, or forced RTMS. +# ZOOM_RECORDING_TRANSPORT=rtms forces RTMS and does not start Chrome for Zoom. ZOOM_RECORDING_TRANSPORT=browser # Allow RTMS only after an explicit pre-join automated-bot rejection. ZOOM_RTMS_FALLBACK_ENABLED=false @@ -44,6 +44,8 @@ ZOOM_RTMS_FALLBACK_ENABLED=false # ZOOM_RTMS_GLOBAL_TEAM_ID=internal-team-id # Exact teamId allowlist for customer S2S OAuth credentials. # ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id"}} +# Add rtmsApp for a dedicated private General app. Its webhook URL is /zoom/rtms/webhook/apps/. +# ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id","rtmsApp":{"webhookId":"unique-customer-app-id","clientId":"zoom-general-app-client-id","clientSecret":"zoom-general-app-client-secret","webhookSecret":"zoom-general-app-webhook-secret"}}} # ZOOM_RTMS_CLIENT_ID= # ZOOM_RTMS_CLIENT_SECRET= # ZOOM_RTMS_WEBHOOK_SECRET= diff --git a/README.md b/README.md index eb29447f..55e7fdaf 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Content-Type: application/json } ``` -Zoom uses the browser recorder by default. With `ZOOM_RTMS_FALLBACK_ENABLED=true`, only an explicit Zoom automated-bot rejection before admission can fall back to RTMS. Host rejection, sign-in requirements, lobby timeouts, browser crashes and recording failures never trigger that fallback. +Zoom transport is deployment-wide: `browser` with fallback disabled is browser-only, `browser` with `ZOOM_RTMS_FALLBACK_ENABLED=true` is browser→RTMS, and `rtms` is forced RTMS without a Zoom browser. Only an explicit Zoom automated-bot rejection before admission can trigger fallback. Host rejection, sign-in requirements, lobby timeouts, browser crashes and recording failures never do. See [Zoom RTMS operations and customer onboarding](docs/zoom-rtms.md) for the complete setup, deployment modes, Zoom prerequisites, customer onboarding, and troubleshooting guide. @@ -158,6 +158,32 @@ Internal/global mode only: Customer mode uses the exact enabled `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` entry instead of the internal/global OAuth settings. +Three credential profiles are supported simultaneously: + +- Internal: the exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the global RTMS app and global OAuth settings. +- Shared customer: an exact customer entry without `rtmsApp` uses the global RTMS app and that customer's S2S OAuth settings. +- Dedicated customer: an exact customer entry with a complete `rtmsApp` uses that customer's own General app and S2S OAuth app. Configure its webhook as `POST /zoom/rtms/webhook/apps/`. + +Dedicated customer example: + +```json +{ + "team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id", + "rtmsApp": { + "webhookId": "unique-customer-app-id", + "clientId": "zoom-general-app-client-id", + "clientSecret": "zoom-general-app-client-secret", + "webhookSecret": "zoom-general-app-webhook-secret" + } + } +} +``` + The General app needs `meeting:read:meeting_audio` and `meeting:read:meeting_video`; the OAuth app needs `meeting:update:participant_rtms_app_status` or its admin variant. The app must be authorized by the meeting host/account. RTMS does not anonymously join arbitrary Zoom links; the password in a join URL is not used. The RTMS output contains Zoom's mixed audio and active-speaker H.264 video and is uploaded as MP4 through the existing uploader. The official RTMS Node SDK currently requires Linux x64 (or macOS arm64); the browser transport remains available on other image architectures. ### Recording Completion Notifications (Optional) @@ -507,10 +533,10 @@ Notes: | `TEAMS_AUDIO_STABILIZATION_MS` | Delay before starting Microsoft Teams ffmpeg recording after joining | `1000` | | `GOOGLE_CHROME_CDP_URL` | Optional CDP endpoint for Google Meet joins, e.g. `http://host.docker.internal:9222`, to use an external Chrome instead of Docker Chrome. | - | | `ZOOM_CHROME_CDP_URL` | Optional CDP endpoint for isolated Zoom browser contexts. | - | -| `ZOOM_RECORDING_TRANSPORT` | Primary Zoom transport (`browser` or `rtms`). | `browser` | -| `ZOOM_RTMS_FALLBACK_ENABLED` | Allow RTMS only after an explicit pre-join Zoom automated-bot block. | `false` | +| `ZOOM_RECORDING_TRANSPORT` | Zoom transport: `browser`, or `rtms` for forced RTMS. | `browser` | +| `ZOOM_RTMS_FALLBACK_ENABLED` | With browser transport, allow RTMS only after an explicit pre-join automated-bot block. Ignored by forced RTMS. | `false` | | `ZOOM_RTMS_GLOBAL_TEAM_ID` | Exact internal team allowed to use global RTMS OAuth credentials. | - | -| `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Exact team-keyed customer S2S OAuth credentials for RTMS. | - | +| `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Exact team-keyed customer S2S OAuth credentials, optionally including a dedicated `rtmsApp`. | - | | `SENTRY_DSN` | Optional Sentry DSN; monitoring is a complete no-op when omitted. | - | | `SENTRY_ENVIRONMENT` | Sentry environment tag. | `NODE_ENV` | | `SENTRY_RELEASE` | Sentry release tag. | - | diff --git a/docs/zoom-rtms.md b/docs/zoom-rtms.md index 74ebe7ee..a600ec94 100644 --- a/docs/zoom-rtms.md +++ b/docs/zoom-rtms.md @@ -1,24 +1,32 @@ # Zoom RTMS operations and customer onboarding -This guide covers two supported RTMS use cases: +This guide covers three supported RTMS credential use cases: 1. An internal private Zoom app used only by the developer's own Zoom account. -2. Explicit customer enablement selected by the exact Winkk `teamId`. +2. One shared Zoom RTMS app with customer OAuth credentials selected by exact Winkk `teamId`. +3. A separate private Zoom RTMS app and OAuth app for each customer, also selected by exact `teamId`. RTMS does not join a meeting as an anonymous participant. It streams meeting media for an authorized Zoom user. The meeting URL is used only to extract the meeting ID; its password does not authorize RTMS. ## Deployment modes -| Mode | Primary transport | Credential selection | Intended use | +| Mode | Configuration | Behavior | +| --- | --- | --- | +| Browser only | `ZOOM_RECORDING_TRANSPORT=browser`, fallback disabled | Never uses RTMS | +| Browser then RTMS | `ZOOM_RECORDING_TRANSPORT=browser`, `ZOOM_RTMS_FALLBACK_ENABLED=true` | Uses RTMS only after an explicit automated-bot block before browser admission | +| Forced RTMS | `ZOOM_RECORDING_TRANSPORT=rtms` | Uses RTMS directly and never starts Chrome for Zoom | + +Transport selection is independent from credential selection: + +| Credential profile | Selection | RTMS General app | OAuth control app | | --- | --- | --- | --- | -| Browser only | `browser` | None | Disable RTMS | -| Internal RTMS | `rtms` | Exact `ZOOM_RTMS_GLOBAL_TEAM_ID` | Private app in the company's own Zoom account | -| Customer fallback | `browser` with fallback enabled | Exact customer JSON entry or exact `ZOOM_RTMS_GLOBAL_TEAM_ID` | Browser first; RTMS only after an automated-bot block before admission | -| Customer RTMS first | `rtms` | Exact customer JSON entry | RTMS first for every configured team | +| Internal | Exact `ZOOM_RTMS_GLOBAL_TEAM_ID` | Global app settings | Global OAuth settings | +| Shared customer | Exact customer JSON entry without `rtmsApp` | Global app settings | Customer entry | +| Dedicated customer | Exact customer JSON entry with `rtmsApp` | Customer entry | Customer entry | Credential selection is fail-closed: -- An enabled exact customer entry uses that customer's S2S OAuth credentials. +- An enabled exact customer entry uses that customer's S2S OAuth credentials and either the shared or its dedicated RTMS app. - The exact `ZOOM_RTMS_GLOBAL_TEAM_ID` uses the global/internal OAuth credentials. - Disabled, invalid, and unknown teams do not receive global credentials. @@ -28,18 +36,18 @@ Credential selection is fail-closed: ## Common Zoom prerequisites -Create one user-managed Zoom General app with RTMS enabled. Configure: +Every shared or dedicated Zoom General app must have RTMS enabled. Configure: - `meeting:read:meeting_audio` - `meeting:read:meeting_video` - `meeting.rtms_started` - `meeting.rtms_stopped` - `meeting.rtms_interrupted` -- A public HTTPS event endpoint ending in `/zoom/rtms/webhook` +- A public HTTPS event endpoint. The shared app uses `/zoom/rtms/webhook`; a dedicated app uses `/zoom/rtms/webhook/apps/`. The app must have Zoom Developer Pack credits and the Zoom account must allow apps to access shared real-time meeting content. In staging and production, run the meeting bot with `REDIS_CONSUMER_ENABLED=true` so webhook events reach the correct replica. -The following global app credentials are always required: +The following global app credentials are required for the internal and shared-customer profiles: ```env ZOOM_RTMS_CLIENT_ID=general-app-client-id @@ -47,7 +55,7 @@ ZOOM_RTMS_CLIENT_SECRET=general-app-client-secret ZOOM_RTMS_WEBHOOK_SECRET=general-app-webhook-secret ``` -They belong to the shared RTMS General app and are used to verify webhooks and connect to RTMS media servers. Customer JSON entries do not replace these values. +They belong to the shared RTMS General app and are used to verify webhooks and connect to RTMS media servers. A complete customer `rtmsApp` replaces them only for that exact customer. ## Mode A: internal private app @@ -73,7 +81,7 @@ Every Zoom job still contains a Winkk `teamId`. Only a job whose `teamId` exactl For a short local test, `ZOOM_RTMS_OAUTH_ACCESS_TOKEN` can replace the account ID, client ID, and client secret. Do not store an expiring access token in production. -## Mode B: customer enablement +## Mode B: shared app with multiple customers External customers must be able to authorize the shared RTMS General app. Use either: @@ -126,13 +134,45 @@ Only an explicit Zoom automated-bot block before browser admission triggers RTMS For RTMS first, set `ZOOM_RECORDING_TRANSPORT=rtms`. Enabled exact customer mappings and the exact internal team remain eligible; all other teams fail closed. +## Mode C: dedicated private app per customer + +Use this mode when each customer creates and pays for its own private Zoom General RTMS app and S2S OAuth app. The app stays local to that customer's Zoom account and does not depend on publication of the shared Winkk app. + +Add the customer's S2S settings plus a complete `rtmsApp` object: + +```json +{ + "customer-team-id": { + "enabled": true, + "accountId": "zoom-account-id", + "clientId": "zoom-s2s-client-id", + "clientSecret": "zoom-s2s-client-secret", + "participantUserId": "zoom-user-id", + "rtmsApp": { + "webhookId": "customer-app-unique-id", + "clientId": "zoom-general-app-client-id", + "clientSecret": "zoom-general-app-client-secret", + "webhookSecret": "zoom-general-app-webhook-secret" + } + } +} +``` + +Configure that General app's event endpoint as: + +```text +https:///zoom/rtms/webhook/apps/customer-app-unique-id +``` + +`webhookId` may contain letters, numbers, `_`, and `-`; it must be unique and must not be `global`. Dedicated app settings are all-or-nothing. Invalid, partial, duplicated, disabled, and unknown entries fail closed and never inherit another app. + ## Customer onboarding checklist 1. Confirm the customer's exact Winkk `teamId` from the job payload. -2. Ensure the shared Winkk General app is approved for external authorization. -3. Have the customer or their Zoom admin authorize the Winkk app. +2. For Mode B, ensure the shared Winkk General app is approved for external authorization. For Mode C, activate the customer's private General app. +3. For Mode B, have the customer admin authorize the shared Winkk app. For Mode C, have the customer activate its own app. 4. Enable shared real-time meeting content in the customer's Zoom account settings. -5. Have the customer create and activate their S2S OAuth app with the required admin scope. +5. Have the customer create and activate their S2S OAuth app with the required admin scope; for Mode C, also collect the dedicated General-app values and unique webhook ID. 6. Obtain the account ID, client ID, client secret, and Zoom participant user ID securely. 7. Add an enabled exact entry to the customer JSON in the correct environment. 8. Restart the process or pods so the environment is reloaded. @@ -165,7 +205,7 @@ Recommended items: The automatically generated 1Password `Password` field is not used by the meeting bot. The internal team ID and transport flags are not secrets and can live in the deployment configuration. -Configure a separate public HTTPS `/zoom/rtms/webhook` endpoint for each environment and verify the rendered URL against the current infrastructure before entering it in Zoom. +Configure a separate public HTTPS `/zoom/rtms/webhook` endpoint for each environment. Dedicated apps append `/apps/`. Verify the rendered URL against the current infrastructure before entering it in Zoom. After changing a secret, confirm that the 1Password operator updated the Kubernetes Secret and restarted the meeting-bot pods. Never print secret values while checking the deployment. @@ -184,7 +224,7 @@ Disabled and invalid customer entries never fall back to internal credentials. | Symptom | Check | | --- | --- | | Missing credentials for a team | Exact job `teamId`, spelling, JSON key, and `enabled` | -| Invalid customer configuration | JSON syntax and all required string fields | +| Invalid customer configuration | JSON syntax, all required strings, and a complete unique `rtmsApp` when dedicated | | OAuth token request fails | Customer account ID, S2S client ID, secret, activation, and scopes | | Zoom code `3000` | Meeting has not started; the bot retries until the join deadline | | Zoom code `2308` or `2309` | Participant role or external-host authorization; customer credentials wait for approval, while global/internal credentials fail immediately | @@ -193,7 +233,7 @@ Disabled and invalid customer entries never fall back to internal credentials. | Zoom code `13262` | The General app client ID is missing from Zoom's "Allow apps to access meeting content" setting | | Zoom code `13267` | RTMS app access is disabled in Zoom settings | | Zoom code `13273` | The meeting does not support RTMS | -| No fresh start event | Webhook URL, webhook secret, event subscriptions, Redis, and operator ID | +| No fresh start event | Correct shared/dedicated webhook URL, webhook secret, event subscriptions, Redis, and operator ID | | SDK unavailable | Run RTMS on Linux x64 or macOS arm64 | | Duplicate recording rejected | Another stream is already reserved for the meeting and operator | diff --git a/package-lock.json b/package-lock.json index 3b141faf..6cad4602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.12", + "version": "1.3.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.12", + "version": "1.3.13", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1089.0", diff --git a/package.json b/package.json index 7f4161a8..9f6f33bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.12", + "version": "1.3.13", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/ZoomBot.ts b/src/bots/ZoomBot.ts index e6d1f613..289798a8 100644 --- a/src/bots/ZoomBot.ts +++ b/src/bots/ZoomBot.ts @@ -57,7 +57,8 @@ export class ZoomBot extends BotBase { async join({ url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }: JoinParams): Promise { const _state: BotStatus[] = ['processing']; let recoveredBrowserError: ZoomBrowserJoinBlockedError | undefined; - let recordingTransport: 'browser' | 'rtms' = config.zoomRecordingTransport; + let recordingTransport: 'browser' | 'rtms' = + config.zoomTransportStrategy === 'rtms_only' ? 'rtms' : 'browser'; let fallbackResult = 'not_attempted'; const annotateFailure = (error: unknown, phase: string) => { @@ -81,7 +82,7 @@ export class ZoomBot extends BotBase { try { const pushState = (st: BotStatus) => _state.push(st); const joinParams = { url, name, bearerToken, teamId, timezone, userId, eventId, botId, uploader }; - if (config.zoomRecordingTransport === 'rtms') { + if (config.zoomTransportStrategy === 'rtms_only') { try { await new ZoomRtmsTransport(this._logger).record(joinParams, pushState); } catch (rtmsError) { @@ -93,7 +94,7 @@ export class ZoomBot extends BotBase { } catch (browserError) { if (!shouldUseZoomRtmsFallback( browserError, - config.zoomRtmsFallbackEnabled, + config.zoomTransportStrategy === 'browser_then_rtms', this.joinState )) { throw browserError; diff --git a/src/config.ts b/src/config.ts index ee8db3d9..864fc013 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,10 +22,21 @@ console.log('NODE_ENV', process.env.NODE_ENV); export interface ZoomRtmsCustomerCredentials { enabled: boolean; + /** Server-to-Server OAuth account ID. */ accountId: string; + /** Server-to-Server OAuth client ID. */ clientId: string; + /** Server-to-Server OAuth client secret. */ clientSecret: string; participantUserId: string; + rtmsApp?: ZoomRtmsCustomerAppCredentials; +} + +export interface ZoomRtmsCustomerAppCredentials { + webhookId: string; + clientId: string; + clientSecret: string; + webhookSecret: string; } export interface ZoomRtmsCustomerCredentialsParseResult { @@ -45,11 +56,22 @@ const isPlainObject = (value: unknown): value is Record => && value !== null && !Array.isArray(value); +const isValidExactId = (value: string): boolean => + Boolean(value) + && value.trim() === value + && value.length <= 256 + && !/[\u0000-\u001f\u007f]/.test(value); + +const isValidWebhookId = (value: string): boolean => + /^[A-Za-z0-9_-]{1,128}$/.test(value) + && value !== 'global'; + export const parseZoomRtmsCustomerCredentials = ( rawValue?: string ): ZoomRtmsCustomerCredentialsParseResult => { const credentials: Record = Object.create(null); const entryErrors: Record = Object.create(null); + const teamByWebhookId = new Map(); if (!rawValue?.trim()) return { credentials, entryErrors }; let parsed: unknown; @@ -72,7 +94,7 @@ export const parseZoomRtmsCustomerCredentials = ( } for (const [teamId, value] of Object.entries(parsed)) { - if (!teamId.trim() || !isPlainObject(value)) { + if (!isValidExactId(teamId) || !isPlainObject(value)) { entryErrors[teamId] = 'customer credentials must be an object'; continue; } @@ -90,12 +112,56 @@ export const parseZoomRtmsCustomerCredentials = ( continue; } + let rtmsApp: ZoomRtmsCustomerAppCredentials | undefined; + if (typeof value.rtmsApp !== 'undefined') { + if (!isPlainObject(value.rtmsApp)) { + entryErrors[teamId] = 'rtmsApp must be an object'; + continue; + } + + const invalidAppFields: string[] = []; + for (const field of ['webhookId', 'clientId', 'clientSecret', 'webhookSecret'] as const) { + if (typeof value.rtmsApp[field] !== 'string' || !value.rtmsApp[field].trim()) { + invalidAppFields.push(`rtmsApp.${field}`); + } + } + if ( + typeof value.rtmsApp.webhookId === 'string' + && value.rtmsApp.webhookId.trim() + && !isValidWebhookId(value.rtmsApp.webhookId) + ) { + invalidAppFields.push('rtmsApp.webhookId'); + } + if (invalidAppFields.length > 0) { + entryErrors[teamId] = `invalid or missing fields: ${Array.from(new Set(invalidAppFields)).join(', ')}`; + continue; + } + + const webhookId = value.rtmsApp.webhookId as string; + const existingTeamId = teamByWebhookId.get(webhookId); + if (existingTeamId) { + delete credentials[existingTeamId]; + entryErrors[existingTeamId] = 'rtmsApp.webhookId must be unique'; + entryErrors[teamId] = 'rtmsApp.webhookId must be unique'; + continue; + } + + teamByWebhookId.set(webhookId, teamId); + rtmsApp = { + webhookId, + clientId: (value.rtmsApp.clientId as string).trim(), + clientSecret: (value.rtmsApp.clientSecret as string).trim(), + webhookSecret: (value.rtmsApp.webhookSecret as string).trim(), + }; + } + credentials[teamId] = { enabled: value.enabled as boolean, accountId: (value.accountId as string).trim(), clientId: (value.clientId as string).trim(), clientSecret: (value.clientSecret as string).trim(), participantUserId: (value.participantUserId as string).trim(), + ...(rtmsApp ? { rtmsApp } : {}), }; } @@ -206,6 +272,25 @@ if (!['browser', 'rtms'].includes(zoomRecordingTransport)) { throw new Error('ZOOM_RECORDING_TRANSPORT must be browser or rtms'); } +export type ZoomRtmsTransportStrategy = + | 'browser_only' + | 'browser_then_rtms' + | 'rtms_only'; + +export const deriveZoomRtmsTransportStrategy = ( + transport: 'browser' | 'rtms', + fallbackEnabled: boolean +): ZoomRtmsTransportStrategy => { + if (transport === 'rtms') return 'rtms_only'; + return fallbackEnabled ? 'browser_then_rtms' : 'browser_only'; +}; + +const zoomRtmsFallbackEnabled = process.env.ZOOM_RTMS_FALLBACK_ENABLED === 'true'; +const zoomTransportStrategy = deriveZoomRtmsTransportStrategy( + zoomRecordingTransport as 'browser' | 'rtms', + zoomRtmsFallbackEnabled +); + const zoomRtmsEventTtlSeconds = process.env.ZOOM_RTMS_EVENT_TTL_SECONDS ? Number(process.env.ZOOM_RTMS_EVENT_TTL_SECONDS) : 3600; @@ -252,7 +337,8 @@ export default { zoomChromeCustomerStorageStatePathErrors: zoomChromeCustomerStorageStatePaths.entryErrors, zoomChromeCustomerStorageStatePathsError: zoomChromeCustomerStorageStatePaths.error, zoomRecordingTransport: zoomRecordingTransport as 'browser' | 'rtms', - zoomRtmsFallbackEnabled: process.env.ZOOM_RTMS_FALLBACK_ENABLED === 'true', + zoomRtmsFallbackEnabled, + zoomTransportStrategy, zoomRtms: { clientId: process.env.ZOOM_RTMS_CLIENT_ID, clientSecret: process.env.ZOOM_RTMS_CLIENT_SECRET, diff --git a/src/rtms/ZoomRtmsCredentials.test.ts b/src/rtms/ZoomRtmsCredentials.test.ts index 437cbb95..d384c543 100644 --- a/src/rtms/ZoomRtmsCredentials.test.ts +++ b/src/rtms/ZoomRtmsCredentials.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import config, { + deriveZoomRtmsTransportStrategy, parseZoomRtmsCustomerCredentials, ZoomRtmsCustomerCredentials, } from '../config'; @@ -13,6 +14,8 @@ import { const original = { transport: config.zoomRecordingTransport, rtmsClientId: config.zoomRtms.clientId, + rtmsClientSecret: config.zoomRtms.clientSecret, + rtmsWebhookSecret: config.zoomRtms.webhookSecret, oauthAccessToken: config.zoomRtms.oauthAccessToken, oauthAccountId: config.zoomRtms.oauthAccountId, oauthClientId: config.zoomRtms.oauthClientId, @@ -35,6 +38,8 @@ const customer = (enabled = true): ZoomRtmsCustomerCredentials => ({ test.beforeEach(() => { config.zoomRecordingTransport = 'browser'; config.zoomRtms.clientId = 'rtms-client'; + config.zoomRtms.clientSecret = 'rtms-secret'; + config.zoomRtms.webhookSecret = 'webhook-secret'; config.zoomRtms.oauthAccessToken = undefined; config.zoomRtms.oauthAccountId = undefined; config.zoomRtms.oauthClientId = undefined; @@ -49,6 +54,8 @@ test.beforeEach(() => { test.after(() => { config.zoomRecordingTransport = original.transport; config.zoomRtms.clientId = original.rtmsClientId; + config.zoomRtms.clientSecret = original.rtmsClientSecret; + config.zoomRtms.webhookSecret = original.rtmsWebhookSecret; config.zoomRtms.oauthAccessToken = original.oauthAccessToken; config.zoomRtms.oauthAccountId = original.oauthAccountId; config.zoomRtms.oauthClientId = original.oauthClientId; @@ -75,6 +82,82 @@ test('parses customer credential JSON without exposing secret values in errors', assert.equal(malformed.error?.includes('secret-value'), false); }); +test('parses complete dedicated RTMS app credentials', () => { + const dedicated = { + ...customer(), + rtmsApp: { + webhookId: 'customer-app-a', + clientId: 'dedicated-rtms-client', + clientSecret: 'dedicated-rtms-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ + 'team-a': dedicated, + })); + + assert.deepEqual({ ...parsed.credentials['team-a'] }, dedicated); + assert.deepEqual({ ...parsed.entryErrors }, {}); +}); + +test('rejects partial, unsafe, and duplicate dedicated app settings without leaking secrets', () => { + const secret = 'must-never-appear'; + const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ + partial: { + ...customer(), + rtmsApp: { + webhookId: 'partial-app', + clientId: 'dedicated-client', + clientSecret: secret, + }, + }, + unsafe: { + ...customer(), + rtmsApp: { + webhookId: '../unsafe', + clientId: 'dedicated-client', + clientSecret: secret, + webhookSecret: secret, + }, + }, + duplicateA: { + ...customer(), + rtmsApp: { + webhookId: 'duplicate-app', + clientId: 'dedicated-client-a', + clientSecret: secret, + webhookSecret: secret, + }, + }, + duplicateB: { + ...customer(), + rtmsApp: { + webhookId: 'duplicate-app', + clientId: 'dedicated-client-b', + clientSecret: secret, + webhookSecret: secret, + }, + }, + })); + + assert.equal(parsed.credentials.partial, undefined); + assert.equal(parsed.credentials.unsafe, undefined); + assert.equal(parsed.credentials.duplicateA, undefined); + assert.equal(parsed.credentials.duplicateB, undefined); + assert.match(parsed.entryErrors.partial, /rtmsApp\.webhookSecret/); + assert.match(parsed.entryErrors.unsafe, /rtmsApp\.webhookId/); + assert.match(parsed.entryErrors.duplicateA, /unique/); + assert.match(parsed.entryErrors.duplicateB, /unique/); + assert.equal(JSON.stringify(parsed.entryErrors).includes(secret), false); +}); + +test('derives browser-only, fallback, and forced RTMS strategies', () => { + assert.equal(deriveZoomRtmsTransportStrategy('browser', false), 'browser_only'); + assert.equal(deriveZoomRtmsTransportStrategy('browser', true), 'browser_then_rtms'); + assert.equal(deriveZoomRtmsTransportStrategy('rtms', false), 'rtms_only'); + assert.equal(deriveZoomRtmsTransportStrategy('rtms', true), 'rtms_only'); +}); + test('selects enabled team credentials for browser fallback', () => { config.zoomRtms.customerCredentials['team-a'] = customer(); @@ -82,10 +165,62 @@ test('selects enabled team credentials for browser fallback', () => { assert.equal(selected.api.source, 'customer'); assert.equal(selected.api.oauthAccountId, 'account-a'); assert.equal(selected.api.oauthClientId, 'oauth-client-a'); + assert.equal(selected.credentialMode, 'shared_customer'); + assert.equal(selected.app.appId, 'global'); + assert.equal(selected.app.clientId, 'rtms-client'); assert.equal(selected.eventScope.customerId, 'team-a'); + assert.equal(selected.eventScope.appId, 'global'); assert.equal(selected.eventScope.operatorId, 'operator-a'); }); +test('selects dedicated RTMS app and customer OAuth credentials together', () => { + config.zoomRtms.customerCredentials['team-a'] = { + ...customer(), + rtmsApp: { + webhookId: 'customer-app-a', + clientId: 'dedicated-rtms-client', + clientSecret: 'dedicated-rtms-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + + const selected = resolveZoomRtmsCredentials('team-a'); + assert.equal(selected.credentialMode, 'dedicated_customer'); + assert.equal(selected.app.appId, 'customer-app-a'); + assert.equal(selected.app.clientId, 'dedicated-rtms-client'); + assert.equal(selected.app.clientSecret, 'dedicated-rtms-secret'); + assert.equal(selected.app.webhookSecret, 'dedicated-webhook-secret'); + assert.equal(selected.api.rtmsClientId, 'dedicated-rtms-client'); + assert.equal(selected.api.oauthClientId, 'oauth-client-a'); + assert.equal(selected.eventScope.appId, 'customer-app-a'); +}); + +test('dedicated customer apps do not depend on global RTMS app credentials', () => { + config.zoomRtms.clientId = undefined; + config.zoomRtms.clientSecret = undefined; + config.zoomRtms.webhookSecret = undefined; + config.zoomRtms.customerCredentials['dedicated-team'] = { + ...customer(), + rtmsApp: { + webhookId: 'dedicated-app', + clientId: 'dedicated-client', + clientSecret: 'dedicated-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + config.zoomRtms.customerCredentials['shared-team'] = customer(); + + assert.equal( + resolveZoomRtmsCredentials('dedicated-team').credentialMode, + 'dedicated_customer' + ); + assert.throws( + () => resolveZoomRtmsCredentials('shared-team'), + (error: unknown) => error instanceof ZoomRtmsCredentialConfigurationError + && error.reason === 'invalid_global_configuration' + ); +}); + test('fails typed for missing, disabled, and invalid fallback credentials', () => { assert.throws( () => resolveZoomRtmsCredentials('missing-team'), @@ -114,7 +249,10 @@ test('uses global OAuth only for the explicitly configured internal team', () => const selected = resolveZoomRtmsCredentials('internal-team'); assert.equal(selected.api.source, 'legacy'); + assert.equal(selected.credentialMode, 'internal'); + assert.equal(selected.app.appId, 'global'); assert.equal(selected.api.oauthAccessToken, 'legacy-access-token'); + assert.equal(selected.eventScope.appId, 'global'); assert.equal(selected.eventScope.customerId, 'internal-team'); assert.equal(selected.eventScope.operatorId, 'legacy-operator'); diff --git a/src/rtms/ZoomRtmsCredentials.ts b/src/rtms/ZoomRtmsCredentials.ts index 8dee69e8..a501e1bb 100644 --- a/src/rtms/ZoomRtmsCredentials.ts +++ b/src/rtms/ZoomRtmsCredentials.ts @@ -1,6 +1,11 @@ import config from '../config'; import { KnownError, ZoomRtmsCredentialsMissingError } from '../error'; -import { ZoomRtmsApiCredentials, ZoomRtmsEventScope } from './types'; +import { + ZoomRtmsApiCredentials, + ZoomRtmsAppCredentials, + ZoomRtmsCredentialMode, + ZoomRtmsEventScope, +} from './types'; export type ZoomRtmsCredentialErrorReason = | 'disabled' @@ -19,20 +24,35 @@ export class ZoomRtmsCredentialConfigurationError extends KnownError { } export interface ResolvedZoomRtmsCredentials { + credentialMode: ZoomRtmsCredentialMode; + app: ZoomRtmsAppCredentials; api: ZoomRtmsApiCredentials; eventScope: ZoomRtmsEventScope; } -const globalCredentials = (teamId: string): ResolvedZoomRtmsCredentials => { - const rtmsClientId = config.zoomRtms.clientId; - if (!rtmsClientId) { +const globalAppCredentials = (teamId: string): ZoomRtmsAppCredentials => { + const clientId = config.zoomRtms.clientId; + const clientSecret = config.zoomRtms.clientSecret; + const webhookSecret = config.zoomRtms.webhookSecret; + if (!clientId || !clientSecret || !webhookSecret) { throw new ZoomRtmsCredentialConfigurationError( 'invalid_global_configuration', teamId, - 'ZOOM_RTMS_CLIENT_ID is required for RTMS' + 'ZOOM_RTMS_CLIENT_ID, ZOOM_RTMS_CLIENT_SECRET and ZOOM_RTMS_WEBHOOK_SECRET are required for the global RTMS app' ); } + return { + appId: 'global', + clientId, + clientSecret, + webhookSecret, + }; +}; + +const globalCredentials = (teamId: string): ResolvedZoomRtmsCredentials => { + const app = globalAppCredentials(teamId); + const hasAccessToken = Boolean(config.zoomRtms.oauthAccessToken); const hasServerCredentials = Boolean( config.zoomRtms.oauthAccountId @@ -48,10 +68,12 @@ const globalCredentials = (teamId: string): ResolvedZoomRtmsCredentials => { } return { + credentialMode: 'internal', + app, api: { source: 'legacy', customerId: teamId, - rtmsClientId, + rtmsClientId: app.clientId, participantUserId: config.zoomRtms.participantUserId, oauthAccessToken: config.zoomRtms.oauthAccessToken, oauthAccountId: config.zoomRtms.oauthAccountId, @@ -59,6 +81,7 @@ const globalCredentials = (teamId: string): ResolvedZoomRtmsCredentials => { oauthClientSecret: config.zoomRtms.oauthClientSecret, }, eventScope: { + appId: app.appId, customerId: teamId, operatorId: config.zoomRtms.participantUserId, }, @@ -70,26 +93,29 @@ export const resolveZoomRtmsCredentials = ( ): ResolvedZoomRtmsCredentials => { const customer = config.zoomRtms.customerCredentials[teamId]; if (customer?.enabled) { - const rtmsClientId = config.zoomRtms.clientId; - if (!rtmsClientId) { - throw new ZoomRtmsCredentialConfigurationError( - 'invalid_global_configuration', - teamId, - 'ZOOM_RTMS_CLIENT_ID is required for customer RTMS recordings' - ); - } + const app: ZoomRtmsAppCredentials = customer.rtmsApp + ? { + appId: customer.rtmsApp.webhookId, + clientId: customer.rtmsApp.clientId, + clientSecret: customer.rtmsApp.clientSecret, + webhookSecret: customer.rtmsApp.webhookSecret, + } + : globalAppCredentials(teamId); return { + credentialMode: customer.rtmsApp ? 'dedicated_customer' : 'shared_customer', + app, api: { source: 'customer', customerId: teamId, - rtmsClientId, + rtmsClientId: app.clientId, participantUserId: customer.participantUserId, oauthAccountId: customer.accountId, oauthClientId: customer.clientId, oauthClientSecret: customer.clientSecret, }, eventScope: { + appId: app.appId, customerId: teamId, operatorId: customer.participantUserId, }, diff --git a/src/rtms/ZoomRtmsEventStore.test.ts b/src/rtms/ZoomRtmsEventStore.test.ts index 9ad11cbf..abe32403 100644 --- a/src/rtms/ZoomRtmsEventStore.test.ts +++ b/src/rtms/ZoomRtmsEventStore.test.ts @@ -14,7 +14,12 @@ test.afterEach(() => { config.isRedisEnabled = originalRedisEnabled; }); -const scope = (customerId: string, operatorId: string): ZoomRtmsEventScope => ({ +const scope = ( + customerId: string, + operatorId: string, + appId = 'global' +): ZoomRtmsEventScope => ({ + appId, customerId, operatorId, }); @@ -106,6 +111,21 @@ test('allows only one customer reservation for the same meeting and Zoom operato assert.deepEqual(await store.waitForMeetingStart('12345678901', teamA, 1), event); }); +test('isolates identical events and reservations across dedicated apps', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'shared-operator', 'app-a'); + const teamB = scope('team-b', 'shared-operator', 'app-b'); + + assert.equal(await store.reserveMeeting('12345678901', 'owner-a', 60, teamA), true); + assert.equal(await store.reserveMeeting('12345678901', 'owner-b', 60, teamB), true); + + const event = startEvent(13, 'shared-operator', 'shared-stream'); + assert.equal(await store.publish(event, 'app-a'), true); + assert.equal(await store.publish(event, 'app-b'), true); + assert.deepEqual(await store.waitForMeetingStart('12345678901', teamA, 1), event); + assert.deepEqual(await store.waitForMeetingStart('12345678901', teamB, 1), event); +}); + test('moves early stream stop events into the owning customer queue', async () => { const store = new ZoomRtmsEventStore(); const eventScope = scope('team-a', 'operator-id'); @@ -135,4 +155,29 @@ test('prevents another customer from taking ownership of an active stream', asyn store.markStreamActive('stream-id', scope('team-b', 'operator-b')), /already owned by another customer/ ); + + await store.markStreamActive('stream-id', scope('team-b', 'operator-b', 'dedicated-app')); +}); + +test('keeps pending stream events isolated by app', async () => { + const store = new ZoomRtmsEventStore(); + const teamA = scope('team-a', 'operator-id', 'app-a'); + const teamB = scope('team-b', 'operator-id', 'app-b'); + const stopped: ZoomRtmsWebhookEvent = { + event: 'meeting.rtms_stopped', + event_ts: 14, + payload: { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'shared-stream-id', + stop_reason: 6, + }, + }; + + await store.publish(stopped, 'app-a'); + await store.markStreamActive('shared-stream-id', teamB); + await store.markStreamActive('shared-stream-id', teamA); + assert.deepEqual( + await store.waitForStreamEvent('shared-stream-id', teamA, 1), + stopped + ); }); diff --git a/src/rtms/ZoomRtmsEventStore.ts b/src/rtms/ZoomRtmsEventStore.ts index c43b93c7..2c1edc5b 100644 --- a/src/rtms/ZoomRtmsEventStore.ts +++ b/src/rtms/ZoomRtmsEventStore.ts @@ -28,36 +28,46 @@ export class ZoomRtmsEventStore { return scope.operatorId || '*'; } - private operatorRoutesKey(meetingId: string, operatorId: string): string { - return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${this.digest(operatorId)}:routes`; + private appId(scope: ZoomRtmsEventScope): string { + return scope.appId || 'global'; + } + + private appKeySegment(appId: string): string { + return appId === 'global' ? '' : `:app:${this.digest(appId)}`; + } + + private operatorRoutesKey(meetingId: string, appId: string, operatorId: string): string { + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}${this.appKeySegment(appId)}:operator:${this.digest(operatorId)}:routes`; } private meetingQueue( meetingId: string, + appId: string, operatorId: string, customerDigest: string ): string { - return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${this.digest(operatorId)}:customer:${customerDigest}:events`; + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}${this.appKeySegment(appId)}:operator:${this.digest(operatorId)}:customer:${customerDigest}:events`; } - private streamQueue(streamId: string, customerDigest: string): string { - return `${PREFIX}:stream:${streamId}:customer:${customerDigest}:events`; + private streamQueue(streamId: string, appId: string, customerDigest: string): string { + return `${PREFIX}:stream:${streamId}${this.appKeySegment(appId)}:customer:${customerDigest}:events`; } - private pendingStreamQueue(streamId: string): string { - return `${PREFIX}:stream:${streamId}:pending-events`; + private pendingStreamQueue(streamId: string, appId: string): string { + return `${PREFIX}:stream:${streamId}${this.appKeySegment(appId)}:pending-events`; } - private activeStreamKey(streamId: string): string { - return `${PREFIX}:stream:${streamId}:active`; + private activeStreamKey(streamId: string, appId: string): string { + return `${PREFIX}:stream:${streamId}${this.appKeySegment(appId)}:active`; } private reservationKey(meetingId: string, scope: ZoomRtmsEventScope): string { - return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}:operator:${this.digest(this.operatorId(scope))}:owner`; + return `${PREFIX}:meeting:${normalizeZoomMeetingId(meetingId)}${this.appKeySegment(this.appId(scope))}:operator:${this.digest(this.operatorId(scope))}:owner`; } - private dedupeKey(event: ZoomRtmsWebhookEvent): string { + private dedupeKey(event: ZoomRtmsWebhookEvent, appId: string): string { const identity = [ + ...(appId === 'global' ? [] : [appId]), event.event, event.event_ts ?? '', event.payload.meeting_uuid, @@ -108,12 +118,12 @@ export class ZoomRtmsEventStore { return this.blockingClient; } - async publish(event: ZoomRtmsWebhookEvent): Promise { + async publish(event: ZoomRtmsWebhookEvent, appId = 'global'): Promise { const streamId = event.payload.rtms_stream_id; if (!streamId) throw new Error('RTMS webhook is missing rtms_stream_id'); if (!config.isRedisEnabled) { - return this.publishLocally(event); + return this.publishLocally(event, appId); } await this.connect(); @@ -122,15 +132,19 @@ export class ZoomRtmsEventStore { let queueKeys: string[]; if (event.event === 'meeting.rtms_started') { const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); - const activeCustomer = await client.get(this.activeStreamKey(streamId)); + const activeCustomer = await client.get(this.activeStreamKey(streamId, appId)); queueKeys = activeCustomer - ? [this.streamQueue(streamId, activeCustomer)] - : await this.meetingQueuesForStart(meetingId, String(event.payload.operator_id ?? '')); + ? [this.streamQueue(streamId, appId, activeCustomer)] + : await this.meetingQueuesForStart( + meetingId, + String(event.payload.operator_id ?? ''), + appId + ); } else { - const activeCustomer = await client.get(this.activeStreamKey(streamId)); + const activeCustomer = await client.get(this.activeStreamKey(streamId, appId)); queueKeys = [activeCustomer - ? this.streamQueue(streamId, activeCustomer) - : this.pendingStreamQueue(streamId)]; + ? this.streamQueue(streamId, appId, activeCustomer) + : this.pendingStreamQueue(streamId, appId)]; } if (queueKeys.length === 0) return true; @@ -139,7 +153,7 @@ export class ZoomRtmsEventStore { 'EVAL', 'if redis.call("SET", KEYS[1], "1", "EX", ARGV[2], "NX") then for i = 2, #KEYS do redis.call("RPUSH", KEYS[i], ARGV[1]); redis.call("EXPIRE", KEYS[i], ARGV[2]); end; return 1; end; return 0;', String(queueKeys.length + 1), - this.dedupeKey(event), + this.dedupeKey(event, appId), ...queueKeys, JSON.stringify(event), String(ttl), @@ -153,7 +167,12 @@ export class ZoomRtmsEventStore { timeoutSeconds: number ): Promise { return this.waitFor( - this.meetingQueue(meetingId, this.operatorId(scope), this.digest(scope.customerId)), + this.meetingQueue( + meetingId, + this.appId(scope), + this.operatorId(scope), + this.digest(scope.customerId) + ), timeoutSeconds ); } @@ -164,21 +183,22 @@ export class ZoomRtmsEventStore { timeoutSeconds: number ): Promise { return this.waitFor( - this.streamQueue(streamId, this.digest(scope.customerId)), + this.streamQueue(streamId, this.appId(scope), this.digest(scope.customerId)), timeoutSeconds ); } async markStreamActive(streamId: string, scope: ZoomRtmsEventScope): Promise { const customerDigest = this.digest(scope.customerId); + const appId = this.appId(scope); if (!config.isRedisEnabled) { - const activeCustomer = this.localActiveStreams.get(streamId); + const activeCustomer = this.localActiveStreams.get(this.activeStreamKey(streamId, appId)); if (activeCustomer && activeCustomer !== customerDigest) { throw new Error('Zoom RTMS stream is already owned by another customer'); } - this.localActiveStreams.set(streamId, customerDigest); - const pendingQueue = this.pendingStreamQueue(streamId); - const targetQueue = this.streamQueue(streamId, customerDigest); + this.localActiveStreams.set(this.activeStreamKey(streamId, appId), customerDigest); + const pendingQueue = this.pendingStreamQueue(streamId, appId); + const targetQueue = this.streamQueue(streamId, appId, customerDigest); const pendingEvents = this.localQueues.get(pendingQueue) ?? []; this.localQueues.delete(pendingQueue); pendingEvents.forEach((event) => this.enqueueLocal(targetQueue, event)); @@ -194,9 +214,9 @@ export class ZoomRtmsEventStore { 'EVAL', 'local owner = redis.call("GET", KEYS[1]); if owner and owner ~= ARGV[1] then return -1; end; redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[2]); local events = redis.call("LRANGE", KEYS[2], 0, -1); for _, event in ipairs(events) do redis.call("RPUSH", KEYS[3], event); end; if #events > 0 then redis.call("EXPIRE", KEYS[3], ARGV[3]); end; redis.call("DEL", KEYS[2]); return #events;', '3', - this.activeStreamKey(streamId), - this.pendingStreamQueue(streamId), - this.streamQueue(streamId, customerDigest), + this.activeStreamKey(streamId, appId), + this.pendingStreamQueue(streamId, appId), + this.streamQueue(streamId, appId, customerDigest), customerDigest, String(activeTtl), String(config.zoomRtms.eventTtlSeconds), @@ -208,23 +228,25 @@ export class ZoomRtmsEventStore { async markStreamInactive(streamId: string, scope?: ZoomRtmsEventScope): Promise { const expectedCustomer = scope ? this.digest(scope.customerId) : undefined; + const appId = scope ? this.appId(scope) : 'global'; if (!config.isRedisEnabled) { - if (!expectedCustomer || this.localActiveStreams.get(streamId) === expectedCustomer) { - this.localActiveStreams.delete(streamId); + const activeStreamKey = this.activeStreamKey(streamId, appId); + if (!expectedCustomer || this.localActiveStreams.get(activeStreamKey) === expectedCustomer) { + this.localActiveStreams.delete(activeStreamKey); } return; } await this.connect(); if (!expectedCustomer) { - await this.getCommandClient().del(this.activeStreamKey(streamId)); + await this.getCommandClient().del(this.activeStreamKey(streamId, appId)); return; } await this.getCommandClient().sendCommand([ 'EVAL', 'if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]); end; return 0;', '1', - this.activeStreamKey(streamId), + this.activeStreamKey(streamId, appId), expectedCustomer, ]); } @@ -236,7 +258,11 @@ export class ZoomRtmsEventStore { scope: ZoomRtmsEventScope ): Promise { const key = this.reservationKey(meetingId, scope); - const routesKey = this.operatorRoutesKey(meetingId, this.operatorId(scope)); + const routesKey = this.operatorRoutesKey( + meetingId, + this.appId(scope), + this.operatorId(scope) + ); const customerDigest = this.digest(scope.customerId); if (!config.isRedisEnabled) { if (this.localReservations.has(key)) return false; @@ -268,7 +294,11 @@ export class ZoomRtmsEventStore { scope: ZoomRtmsEventScope ): Promise { const key = this.reservationKey(meetingId, scope); - const routesKey = this.operatorRoutesKey(meetingId, this.operatorId(scope)); + const routesKey = this.operatorRoutesKey( + meetingId, + this.appId(scope), + this.operatorId(scope) + ); const customerDigest = this.digest(scope.customerId); if (!config.isRedisEnabled) { if (this.localReservations.get(key) === ownerId) { @@ -319,58 +349,61 @@ export class ZoomRtmsEventStore { private async meetingQueuesForStart( meetingId: string, - operatorId: string + operatorId: string, + appId: string ): Promise { const operatorIds = operatorId ? [operatorId, '*'] : ['*']; const customerRoutes = await Promise.all(operatorIds.map(async (routeOperatorId) => ({ operatorId: routeOperatorId, customerDigests: await this.getCommandClient().sMembers( - this.operatorRoutesKey(meetingId, routeOperatorId) + this.operatorRoutesKey(meetingId, appId, routeOperatorId) ), }))); return customerRoutes.flatMap(({ operatorId: routeOperatorId, customerDigests }) => customerDigests.map((customerDigest) => - this.meetingQueue(meetingId, routeOperatorId, customerDigest) + this.meetingQueue(meetingId, appId, routeOperatorId, customerDigest) ) ); } private localMeetingQueuesForStart( meetingId: string, - operatorId: string + operatorId: string, + appId: string ): string[] { const operatorIds = operatorId ? [operatorId, '*'] : ['*']; return operatorIds.flatMap((routeOperatorId) => Array.from( - this.localRoutes.get(this.operatorRoutesKey(meetingId, routeOperatorId)) ?? [] + this.localRoutes.get(this.operatorRoutesKey(meetingId, appId, routeOperatorId)) ?? [] ).map((customerDigest) => - this.meetingQueue(meetingId, routeOperatorId, customerDigest) + this.meetingQueue(meetingId, appId, routeOperatorId, customerDigest) ) ); } - private publishLocally(event: ZoomRtmsWebhookEvent): boolean { + private publishLocally(event: ZoomRtmsWebhookEvent, appId = 'global'): boolean { const streamId = event.payload.rtms_stream_id; let queueKeys: string[]; if (event.event === 'meeting.rtms_started') { const meetingId = normalizeZoomMeetingId(event.payload.meeting_id ?? ''); - const activeCustomer = this.localActiveStreams.get(streamId); + const activeCustomer = this.localActiveStreams.get(this.activeStreamKey(streamId, appId)); queueKeys = activeCustomer - ? [this.streamQueue(streamId, activeCustomer)] + ? [this.streamQueue(streamId, appId, activeCustomer)] : this.localMeetingQueuesForStart( meetingId, - String(event.payload.operator_id ?? '') + String(event.payload.operator_id ?? ''), + appId ); } else { - const activeCustomer = this.localActiveStreams.get(streamId); + const activeCustomer = this.localActiveStreams.get(this.activeStreamKey(streamId, appId)); queueKeys = [activeCustomer - ? this.streamQueue(streamId, activeCustomer) - : this.pendingStreamQueue(streamId)]; + ? this.streamQueue(streamId, appId, activeCustomer) + : this.pendingStreamQueue(streamId, appId)]; } if (queueKeys.length === 0) return true; - const dedupeKey = this.dedupeKey(event); + const dedupeKey = this.dedupeKey(event, appId); if (this.localDedupe.has(dedupeKey)) return false; this.localDedupe.add(dedupeKey); const timer = setTimeout( diff --git a/src/rtms/ZoomRtmsSdkClient.test.ts b/src/rtms/ZoomRtmsSdkClient.test.ts new file mode 100644 index 00000000..46a3483d --- /dev/null +++ b/src/rtms/ZoomRtmsSdkClient.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildZoomRtmsSdkJoinParams } from './ZoomRtmsSdkClient'; +import { ZoomRtmsAppCredentials, ZoomRtmsPayload } from './types'; + +test('builds SDK join parameters from the resolved RTMS app', () => { + const app: ZoomRtmsAppCredentials = { + appId: 'customer-app', + clientId: 'customer-client', + clientSecret: 'customer-secret', + webhookSecret: 'customer-webhook-secret', + }; + const payload: ZoomRtmsPayload = { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.example.test', + }; + + assert.deepEqual(buildZoomRtmsSdkJoinParams(payload, app, 12_000), { + meeting_uuid: 'meeting-uuid', + rtms_stream_id: 'stream-id', + server_urls: 'wss://rtms.example.test', + client: 'customer-client', + secret: 'customer-secret', + timeout: 12_000, + pollInterval: 10, + is_verify_cert: 1, + }); +}); diff --git a/src/rtms/ZoomRtmsSdkClient.ts b/src/rtms/ZoomRtmsSdkClient.ts index 316ff168..e0710d0b 100644 --- a/src/rtms/ZoomRtmsSdkClient.ts +++ b/src/rtms/ZoomRtmsSdkClient.ts @@ -1,8 +1,7 @@ import { Logger } from 'winston'; import { createRequire } from 'module'; -import config from '../config'; import { KnownError } from '../error'; -import { ZoomRtmsPayload } from './types'; +import { ZoomRtmsAppCredentials, ZoomRtmsPayload } from './types'; import { RtmsMediaRecorder } from './RtmsMediaRecorder'; interface RtmsClient { @@ -72,24 +71,38 @@ export const assertZoomRtmsSdkAvailable = (): void => { loadSdk(); }; +export const buildZoomRtmsSdkJoinParams = ( + payload: ZoomRtmsPayload, + app: ZoomRtmsAppCredentials, + timeoutMs: number +): Record => ({ + meeting_uuid: payload.meeting_uuid, + rtms_stream_id: payload.rtms_stream_id, + server_urls: payload.server_urls, + client: app.clientId, + secret: app.clientSecret, + timeout: timeoutMs, + pollInterval: 10, + is_verify_cert: 1, +}); + export class ZoomRtmsSdkClient { private activeClient?: ActiveClient; private connectionIssue?: ZoomRtmsSdkConnectionIssue; constructor( private readonly recorder: RtmsMediaRecorder, - private readonly logger: Logger + private readonly logger: Logger, + private readonly app: ZoomRtmsAppCredentials ) {} async connect(payload: ZoomRtmsPayload, timeoutMs = 30_000): Promise { await this.close(); this.connectionIssue = undefined; - const clientId = config.zoomRtms.clientId; - const clientSecret = config.zoomRtms.clientSecret; - if (!clientId || !clientSecret) { + if (!this.app.clientId || !this.app.clientSecret) { throw new KnownError( - 'ZOOM_RTMS_CLIENT_ID and ZOOM_RTMS_CLIENT_SECRET are required for RTMS', + 'Zoom RTMS app client ID and client secret are required', false, 0 ); @@ -191,16 +204,9 @@ export class ZoomRtmsSdkClient { return; } - const joining = client.join({ - meeting_uuid: payload.meeting_uuid, - rtms_stream_id: payload.rtms_stream_id, - server_urls: payload.server_urls, - client: clientId, - secret: clientSecret, - timeout: boundedTimeoutMs, - pollInterval: 10, - is_verify_cert: 1, - }); + const joining = client.join( + buildZoomRtmsSdkJoinParams(payload, this.app, boundedTimeoutMs) + ); if (!joining) finish(new Error('Zoom RTMS SDK could not start the stream connection')); }); } catch (error) { diff --git a/src/rtms/ZoomRtmsTransport.ts b/src/rtms/ZoomRtmsTransport.ts index 74814319..5861e62a 100644 --- a/src/rtms/ZoomRtmsTransport.ts +++ b/src/rtms/ZoomRtmsTransport.ts @@ -71,13 +71,6 @@ export class ZoomRtmsTransport { let meetingReserved = false; try { - if (!config.zoomRtms.clientId || !config.zoomRtms.clientSecret || !config.zoomRtms.webhookSecret) { - throw new KnownError( - 'ZOOM_RTMS_CLIENT_ID, ZOOM_RTMS_CLIENT_SECRET and ZOOM_RTMS_WEBHOOK_SECRET are required', - false, - 0 - ); - } if (!config.isRedisEnabled && (NODE_ENV === 'production' || NODE_ENV === 'staging')) { throw new KnownError( 'Zoom RTMS requires REDIS_CONSUMER_ENABLED=true in multi-replica environments', @@ -104,7 +97,10 @@ export class ZoomRtmsTransport { ); } - this.logger.info('Requesting Zoom RTMS stream', { meetingId }); + this.logger.info('Requesting Zoom RTMS stream', { + meetingId, + credentialMode: credentials.credentialMode, + }); const startRequestedAt = Date.now(); const initialJoinDeadline = startRequestedAt + config.joinWaitTime * 60 * 1000; const startResult = await api.start( @@ -134,7 +130,7 @@ export class ZoomRtmsTransport { lastStartPayload = startEvent.payload; rtmsRequested = true; await zoomRtmsEventStore.markStreamActive(streamId, eventScope); - client = new ZoomRtmsSdkClient(recorder, this.logger); + client = new ZoomRtmsSdkClient(recorder, this.logger, credentials.app); await this.connectWithBackoff( client, lastStartPayload, diff --git a/src/rtms/types.ts b/src/rtms/types.ts index 30f12d64..93a1b3e5 100644 --- a/src/rtms/types.ts +++ b/src/rtms/types.ts @@ -30,6 +30,18 @@ export interface ZoomWebhookBody { payload?: Record; } +export type ZoomRtmsCredentialMode = + | 'internal' + | 'shared_customer' + | 'dedicated_customer'; + +export interface ZoomRtmsAppCredentials { + appId: string; + clientId: string; + clientSecret: string; + webhookSecret: string; +} + export interface ZoomRtmsApiCredentials { source: 'customer' | 'legacy'; customerId: string; @@ -42,6 +54,7 @@ export interface ZoomRtmsApiCredentials { } export interface ZoomRtmsEventScope { + appId: string; customerId: string; operatorId?: string; } diff --git a/src/rtms/webhook.test.ts b/src/rtms/webhook.test.ts index e070d8b7..a35051d6 100644 --- a/src/rtms/webhook.test.ts +++ b/src/rtms/webhook.test.ts @@ -34,10 +34,12 @@ const send = async ({ body, timestamp, signature, + webhookId, }: { body: Record; timestamp?: string; signature?: string; + webhookId?: string; }): Promise => { const rawBody = Buffer.from(JSON.stringify(body)); const req = { @@ -47,6 +49,7 @@ const send = async ({ 'x-zm-request-timestamp': timestamp, 'x-zm-signature': signature, }, + params: webhookId ? { webhookId } : {}, } as unknown as Request; const res = createResponse(); await handleZoomRtmsWebhook(req, res as unknown as Response); @@ -55,10 +58,45 @@ const send = async ({ test('validates Zoom challenges and signed RTMS events', async () => { const originalSecret = config.zoomRtms.webhookSecret; + const originalCustomerCredentials = config.zoomRtms.customerCredentials; const originalRedisEnabled = config.isRedisEnabled; config.zoomRtms.webhookSecret = 'webhook-secret'; + config.zoomRtms.customerCredentials = { + ...originalCustomerCredentials, + 'dedicated-team': { + enabled: true, + accountId: 'account-id', + clientId: 'oauth-client-id', + clientSecret: 'oauth-client-secret', + participantUserId: 'operator-id', + rtmsApp: { + webhookId: 'dedicated-app-1234', + clientId: 'rtms-client-id', + clientSecret: 'rtms-client-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }, + 'disabled-team': { + enabled: false, + accountId: 'disabled-account-id', + clientId: 'disabled-oauth-client-id', + clientSecret: 'disabled-oauth-client-secret', + participantUserId: 'disabled-operator-id', + rtmsApp: { + webhookId: 'disabled-app-1234', + clientId: 'disabled-rtms-client-id', + clientSecret: 'disabled-rtms-client-secret', + webhookSecret: 'disabled-webhook-secret', + }, + }, + }; config.isRedisEnabled = false; - const eventScope = { customerId: 'team-a', operatorId: 'operator-id' }; + const eventScope = { appId: 'global', customerId: 'team-a', operatorId: 'operator-id' }; + const dedicatedScope = { + appId: 'dedicated-app-1234', + customerId: 'dedicated-team', + operatorId: 'operator-id', + }; try { await zoomRtmsEventStore.reserveMeeting( @@ -67,6 +105,12 @@ test('validates Zoom challenges and signed RTMS events', async () => { 60, eventScope ); + await zoomRtmsEventStore.reserveMeeting( + '12345678901', + 'dedicated-webhook-test-owner', + 60, + dedicatedScope + ); const challenge = await send({ body: { event: 'endpoint.url_validation', @@ -118,13 +162,100 @@ test('validates Zoom challenges and signed RTMS events', async () => { signature: 'v0=invalid', }); assert.equal(rejected.statusCode, 401); + + const dedicatedChallenge = await send({ + webhookId: 'dedicated-app-1234', + body: { + event: 'endpoint.url_validation', + payload: { plainToken: 'dedicated-plain-token' }, + }, + }); + assert.deepEqual( + dedicatedChallenge.body, + buildZoomUrlValidationResponse( + 'dedicated-plain-token', + 'dedicated-webhook-secret' + ) + ); + + const dedicatedBody = { + ...body, + event_ts: Date.now() + 1, + payload: { + ...body.payload, + rtms_stream_id: 'dedicated-stream-id', + }, + }; + const dedicatedSigned = await send({ + webhookId: 'dedicated-app-1234', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'dedicated-webhook-secret' + ), + }); + assert.equal(dedicatedSigned.statusCode, 204); + assert.equal( + ( + await zoomRtmsEventStore.waitForMeetingStart( + '12345678901', + dedicatedScope, + 1 + ) + )?.payload.rtms_stream_id, + 'dedicated-stream-id' + ); + + const wrongAppSecret = await send({ + webhookId: 'dedicated-app-1234', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'webhook-secret' + ), + }); + assert.equal(wrongAppSecret.statusCode, 401); + + const unknownApp = await send({ + webhookId: 'unknown-dedicated-app', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'dedicated-webhook-secret' + ), + }); + assert.equal(unknownApp.statusCode, 404); + + const disabledApp = await send({ + webhookId: 'disabled-app-1234', + body: dedicatedBody, + timestamp, + signature: buildZoomWebhookSignature( + JSON.stringify(dedicatedBody), + timestamp, + 'disabled-webhook-secret' + ), + }); + assert.equal(disabledApp.statusCode, 404); } finally { await zoomRtmsEventStore.releaseMeeting( '12345678901', 'webhook-test-owner', eventScope ); + await zoomRtmsEventStore.releaseMeeting( + '12345678901', + 'dedicated-webhook-test-owner', + dedicatedScope + ); config.zoomRtms.webhookSecret = originalSecret; + config.zoomRtms.customerCredentials = originalCustomerCredentials; config.isRedisEnabled = originalRedisEnabled; } }); diff --git a/src/rtms/webhook.ts b/src/rtms/webhook.ts index e918f1d0..01327796 100644 --- a/src/rtms/webhook.ts +++ b/src/rtms/webhook.ts @@ -26,9 +26,30 @@ const headerValue = (value: string | string[] | undefined): string | undefined = const router = express.Router(); +const resolveWebhook = ( + webhookId?: string +): { appId: string; secret?: string } | undefined => { + if (typeof webhookId === 'undefined') { + return { appId: 'global', secret: config.zoomRtms.webhookSecret }; + } + + const credentials = Object.values(config.zoomRtms.customerCredentials) + .find((customer) => + customer.enabled + && customer.rtmsApp?.webhookId === webhookId + ); + return credentials?.rtmsApp + ? { appId: webhookId, secret: credentials.rtmsApp.webhookSecret } + : undefined; +}; + export const handleZoomRtmsWebhook = async (req: RawBodyRequest, res: Response) => { const body = req.body as ZoomWebhookBody; - const secret = config.zoomRtms.webhookSecret; + const webhook = resolveWebhook(req.params?.webhookId); + if (!webhook) { + return res.status(404).json({ error: 'Zoom RTMS webhook is not configured' }); + } + const { appId, secret } = webhook; if (body.event === 'endpoint.url_validation') { const plainToken = body.payload?.plainToken; @@ -88,7 +109,7 @@ export const handleZoomRtmsWebhook = async (req: RawBodyRequest, res: Response) }; try { - await zoomRtmsEventStore.publish(event); + await zoomRtmsEventStore.publish(event, appId); return res.sendStatus(204); } catch (error) { console.error('Unable to persist Zoom RTMS webhook event', error); @@ -97,5 +118,6 @@ export const handleZoomRtmsWebhook = async (req: RawBodyRequest, res: Response) }; router.post('/', handleZoomRtmsWebhook); +router.post('/apps/:webhookId', handleZoomRtmsWebhook); export default router; From 782d5ab7a5ac8a3c8079599c97edf235963d861b Mon Sep 17 00:00:00 2001 From: jakob Date: Sun, 19 Jul 2026 15:37:39 +0200 Subject: [PATCH 51/53] feat(rtms): enforce dedicated customer apps --- .env.example | 2 + README.md | 2 + docs/zoom-rtms.md | 117 ++++++++++++++++++++++++++- package-lock.json | 4 +- package.json | 2 +- src/config.ts | 30 ++++++- src/rtms/ZoomRtmsCredentials.test.ts | 28 +++++++ 7 files changed, 176 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index dc82f152..53957cfa 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,8 @@ ZOOM_RTMS_FALLBACK_ENABLED=false # Exact teamId allowed to use the global/internal OAuth credentials below. # ZOOM_RTMS_GLOBAL_TEAM_ID=internal-team-id # Exact teamId allowlist for customer S2S OAuth credentials. +# Enforce shared or dedicated customer apps; auto keeps per-entry inference for compatibility. +ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=auto # ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id"}} # Add rtmsApp for a dedicated private General app. Its webhook URL is /zoom/rtms/webhook/apps/. # ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={"team-id":{"enabled":true,"accountId":"zoom-account-id","clientId":"zoom-s2s-client-id","clientSecret":"zoom-s2s-client-secret","participantUserId":"zoom-user-id","rtmsApp":{"webhookId":"unique-customer-app-id","clientId":"zoom-general-app-client-id","clientSecret":"zoom-general-app-client-secret","webhookSecret":"zoom-general-app-webhook-secret"}}} diff --git a/README.md b/README.md index 55e7fdaf..93010b74 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ Internal/global mode only: - `ZOOM_RTMS_PARTICIPANT_USER_ID` to select and correlate the authorized host when using account-level OAuth Customer mode uses the exact enabled `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` entry instead of the internal/global OAuth settings. +Set `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=dedicated_customer` to reject any customer entry that does not contain its own complete `rtmsApp`. Use `shared_customer` to reject dedicated app entries, or `auto` for backwards-compatible mixed deployments. Three credential profiles are supported simultaneously: @@ -536,6 +537,7 @@ Notes: | `ZOOM_RECORDING_TRANSPORT` | Zoom transport: `browser`, or `rtms` for forced RTMS. | `browser` | | `ZOOM_RTMS_FALLBACK_ENABLED` | With browser transport, allow RTMS only after an explicit pre-join automated-bot block. Ignored by forced RTMS. | `false` | | `ZOOM_RTMS_GLOBAL_TEAM_ID` | Exact internal team allowed to use global RTMS OAuth credentials. | - | +| `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE` | Customer credential policy: `auto`, `shared_customer`, or `dedicated_customer`. | `auto` | | `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON` | Exact team-keyed customer S2S OAuth credentials, optionally including a dedicated `rtmsApp`. | - | | `SENTRY_DSN` | Optional Sentry DSN; monitoring is a complete no-op when omitted. | - | | `SENTRY_ENVIRONMENT` | Sentry environment tag. | `NODE_ENV` | diff --git a/docs/zoom-rtms.md b/docs/zoom-rtms.md index a600ec94..26e20b00 100644 --- a/docs/zoom-rtms.md +++ b/docs/zoom-rtms.md @@ -127,6 +127,7 @@ For browser-first customer fallback: ```env ZOOM_RECORDING_TRANSPORT=browser ZOOM_RTMS_FALLBACK_ENABLED=true +ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=shared_customer ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON={...} ``` @@ -138,6 +139,77 @@ For RTMS first, set `ZOOM_RECORDING_TRANSPORT=rtms`. Enabled exact customer mapp Use this mode when each customer creates and pays for its own private Zoom General RTMS app and S2S OAuth app. The app stays local to that customer's Zoom account and does not depend on publication of the shared Winkk app. +### Customer setup + +Complete these steps while signed in to the Zoom account that will own and pay for the RTMS usage. Repeat the complete setup for every customer account and for every environment that needs separate credentials. + +#### 1. Create the private General RTMS app + +1. Open [Build an app in Zoom App Marketplace](https://marketplace.zoom.us/develop/create), select **General App**, and create the app. Zoom's detailed instructions are in [Create an OAuth app](https://developers.zoom.us/docs/integrations/create/). +2. Name it for the owning account, for example `winkk AI Notetaker - Customer Name`. +3. Under **Basic Information**, select **User-managed**. Zoom requires an RTMS app to be user-managed. +4. Keep the app private: do not submit it for Marketplace publication. For an unpublished same-account app, use the **Development** credentials and install it from **Local Test** with **Add App Now**, then **Allow**. Local-test access is limited to members of that Zoom account. +5. Under **Access**, enable **Event Subscription** and add exactly these meeting events: + - `meeting.rtms_started` + - `meeting.rtms_stopped` + - `meeting.rtms_interrupted` +6. Set the event notification endpoint to one unique dedicated webhook URL: + + ```text + Production: https://rtms.winkk.ai/zoom/rtms/webhook/apps/ + Development: https://dev.rtms.winkk.ai/zoom/rtms/webhook/apps/ + ``` + + Choose `` once for this customer and environment. It may contain only letters, numbers, `_`, and `-`, must be unique across all customer entries, and must not be `global`. Zoom validates this public HTTPS endpoint when the subscription is saved. +7. Under **Scopes**, add: + - `meeting:read:meeting_audio` + - `meeting:read:meeting_video` +8. Copy the following values without posting them in chat or source control: + - **Client ID** from the General app's Development credentials + - **Client Secret** from the same credential set + - **Secret Token** from **Access**; this is the webhook secret and is not the Client Secret + +See [Add Realtime Media Streams to your app](https://developers.zoom.us/docs/rtms/meetings/add-features/) for Zoom's current RTMS event, scope, and user-managed-app requirements. + +#### 2. Create the private Server-to-Server OAuth control app + +1. Open [Build an app in Zoom App Marketplace](https://marketplace.zoom.us/develop/create), select **Server-to-Server OAuth App**, and create it. Follow Zoom's [Server-to-Server OAuth app guide](https://developers.zoom.us/docs/internal-apps/create/). +2. Name it for the owning account, for example `winkk AI Notetaker Control - Customer Name`. +3. Complete the required app information. +4. Add exactly this required granular scope: + + ```text + meeting:update:participant_rtms_app_status:admin + ``` + + If the onboarding process must look up the participant's Zoom user ID from their email, also add `user:read:user:admin` and use Zoom's [Get a user API](https://developers.zoom.us/docs/api/users/#tag/users/GET/users/{userId}). It is not required by the meeting bot after `participantUserId` is known. +5. Activate the S2S app. Zoom does not issue usable account-credential tokens while it is inactive. +6. Copy its **Account ID**, **Client ID**, and **Client Secret**. + +The S2S app does not need an event subscription. It only obtains a short-lived account token and calls Zoom's [participant RTMS status API](https://developers.zoom.us/docs/api/meetings/#tag/meetings/PATCH/live_meetings/{meetingId}/rtms_app/status). + +#### 3. Enable RTMS for the Zoom account + +As a Zoom account admin, enable **Share realtime meeting content with apps**, then add the dedicated General app's Client ID under **Allow apps to access meeting content**. The meeting bot starts RTMS through the REST API, so Zoom's auto-start setting is optional. The account also needs Zoom Developer Pack credits. Zoom documents these prerequisites and settings in [Getting started with RTMS](https://developers.zoom.us/docs/rtms/meetings/getting-started/) and the [participant RTMS status API](https://developers.zoom.us/docs/api/meetings/#tag/meetings/PATCH/live_meetings/{meetingId}/rtms_app/status). + +#### 4. Map the customer to its exact Winkk team + +Obtain the exact Winkk `teamId` from the authenticated meeting job and the Zoom `id` of the user whose meeting participation authorizes RTMS. The Zoom user ID is the `id` returned by Zoom's Get a user API; it is not an email address, meeting ID, account ID, or Winkk team ID. + +Map the values as follows: + +| Zoom/Winkk source | Customer JSON field | +| --- | --- | +| Exact Winkk team ID | Outer JSON key | +| S2S Account ID | `accountId` | +| S2S Client ID | `clientId` | +| S2S Client Secret | `clientSecret` | +| Zoom user's `id` | `participantUserId` | +| Chosen unique webhook identifier | `rtmsApp.webhookId` | +| General app Client ID | `rtmsApp.clientId` | +| General app Client Secret | `rtmsApp.clientSecret` | +| General app Access Secret Token | `rtmsApp.webhookSecret` | + Add the customer's S2S settings plus a complete `rtmsApp` object: ```json @@ -158,14 +230,51 @@ Add the customer's S2S settings plus a complete `rtmsApp` object: } ``` -Configure that General app's event endpoint as: +Multiple dedicated accounts are stored in the same environment variable, keyed by their exact and distinct Winkk team IDs: -```text -https:///zoom/rtms/webhook/apps/customer-app-unique-id +```json +{ + "winkk-team-id": { + "enabled": true, + "accountId": "winkk-zoom-account-id", + "clientId": "winkk-s2s-client-id", + "clientSecret": "winkk-s2s-client-secret", + "participantUserId": "winkk-zoom-user-id", + "rtmsApp": { + "webhookId": "winkk-prod", + "clientId": "winkk-general-app-client-id", + "clientSecret": "winkk-general-app-client-secret", + "webhookSecret": "winkk-general-app-secret-token" + } + }, + "customer-team-id": { + "enabled": true, + "accountId": "customer-zoom-account-id", + "clientId": "customer-s2s-client-id", + "clientSecret": "customer-s2s-client-secret", + "participantUserId": "customer-zoom-user-id", + "rtmsApp": { + "webhookId": "customer-prod", + "clientId": "customer-general-app-client-id", + "clientSecret": "customer-general-app-client-secret", + "webhookSecret": "customer-general-app-secret-token" + } + } +} ``` `webhookId` may contain letters, numbers, `_`, and `-`; it must be unique and must not be `global`. Dedicated app settings are all-or-nothing. Invalid, partial, duplicated, disabled, and unknown entries fail closed and never inherit another app. +Store the complete JSON as the single secret environment variable `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. With the current browser-first deployment, the non-secret transport settings are: + +```env +ZOOM_RECORDING_TRANSPORT=browser +ZOOM_RTMS_FALLBACK_ENABLED=true +ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE=dedicated_customer +``` + +Store `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE` in the same 1Password item as the customer JSON. Dedicated mode rejects customer entries without a complete `rtmsApp`, preventing an accidental fallback to the shared Winkk General app. Global `ZOOM_RTMS_*` app and OAuth values are not used for an enabled dedicated entry with `rtmsApp`; they may coexist only for the separate internal/shared profiles. After updating the secret, restart the process or let the 1Password operator restart the pods, then verify one controlled meeting for each exact team ID. + ## Customer onboarding checklist 1. Confirm the customer's exact Winkk `teamId` from the job payload. @@ -201,7 +310,7 @@ Production RTMS workloads must run on `linux/amd64`. The Zoom RTMS SDK rejects L Recommended items: - `Meeting Bot App Secrets`: shared General app credentials, internal S2S credentials, Sentry, and other application secrets. -- `Meeting Bot Customer RTMS Credentials`: only `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. +- `Meeting Bot Customer RTMS Credentials`: `ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE` and `ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON`. The automatically generated 1Password `Password` field is not used by the meeting bot. The internal team ID and transport flags are not secrets and can live in the deployment configuration. diff --git a/package-lock.json b/package-lock.json index 6cad4602..82ce2bb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.13", + "version": "1.3.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.13", + "version": "1.3.14", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1089.0", diff --git a/package.json b/package.json index 9f6f33bf..6526e6ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.13", + "version": "1.3.14", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index 864fc013..74500a4c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,6 +32,11 @@ export interface ZoomRtmsCustomerCredentials { rtmsApp?: ZoomRtmsCustomerAppCredentials; } +export type ZoomRtmsCustomerCredentialMode = + | 'auto' + | 'shared_customer' + | 'dedicated_customer'; + export interface ZoomRtmsCustomerAppCredentials { webhookId: string; clientId: string; @@ -67,7 +72,8 @@ const isValidWebhookId = (value: string): boolean => && value !== 'global'; export const parseZoomRtmsCustomerCredentials = ( - rawValue?: string + rawValue?: string, + credentialMode: ZoomRtmsCustomerCredentialMode = 'auto' ): ZoomRtmsCustomerCredentialsParseResult => { const credentials: Record = Object.create(null); const entryErrors: Record = Object.create(null); @@ -137,6 +143,11 @@ export const parseZoomRtmsCustomerCredentials = ( continue; } + if (credentialMode === 'shared_customer') { + entryErrors[teamId] = 'rtmsApp is not allowed in shared_customer mode'; + continue; + } + const webhookId = value.rtmsApp.webhookId as string; const existingTeamId = teamByWebhookId.get(webhookId); if (existingTeamId) { @@ -155,6 +166,11 @@ export const parseZoomRtmsCustomerCredentials = ( }; } + if (credentialMode === 'dedicated_customer' && !rtmsApp) { + entryErrors[teamId] = 'rtmsApp is required in dedicated_customer mode'; + continue; + } + credentials[teamId] = { enabled: value.enabled as boolean, accountId: (value.accountId as string).trim(), @@ -298,8 +314,17 @@ if (!Number.isFinite(zoomRtmsEventTtlSeconds) || zoomRtmsEventTtlSeconds <= 0) { throw new Error('ZOOM_RTMS_EVENT_TTL_SECONDS must be a positive number'); } +const zoomRtmsCustomerCredentialMode = + process.env.ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE ?? 'auto'; +if (!['auto', 'shared_customer', 'dedicated_customer'].includes(zoomRtmsCustomerCredentialMode)) { + throw new Error( + 'ZOOM_RTMS_CUSTOMER_CREDENTIAL_MODE must be auto, shared_customer or dedicated_customer' + ); +} + const zoomRtmsCustomerCredentials = parseZoomRtmsCustomerCredentials( - process.env.ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON + process.env.ZOOM_RTMS_CUSTOMER_CREDENTIALS_JSON, + zoomRtmsCustomerCredentialMode as ZoomRtmsCustomerCredentialMode ); const zoomRtmsGlobalTeamId = process.env.ZOOM_RTMS_GLOBAL_TEAM_ID; if ( @@ -350,6 +375,7 @@ export default { participantUserId: process.env.ZOOM_RTMS_PARTICIPANT_USER_ID, globalTeamId: zoomRtmsGlobalTeamId, eventTtlSeconds: zoomRtmsEventTtlSeconds, + customerCredentialMode: zoomRtmsCustomerCredentialMode as ZoomRtmsCustomerCredentialMode, customerCredentials: zoomRtmsCustomerCredentials.credentials, customerCredentialErrors: zoomRtmsCustomerCredentials.entryErrors, customerCredentialsError: zoomRtmsCustomerCredentials.error, diff --git a/src/rtms/ZoomRtmsCredentials.test.ts b/src/rtms/ZoomRtmsCredentials.test.ts index d384c543..1f72b754 100644 --- a/src/rtms/ZoomRtmsCredentials.test.ts +++ b/src/rtms/ZoomRtmsCredentials.test.ts @@ -100,6 +100,34 @@ test('parses complete dedicated RTMS app credentials', () => { assert.deepEqual({ ...parsed.entryErrors }, {}); }); +test('enforces the configured customer credential mode', () => { + const dedicated = { + ...customer(), + rtmsApp: { + webhookId: 'customer-app-a', + clientId: 'dedicated-rtms-client', + clientSecret: 'dedicated-rtms-secret', + webhookSecret: 'dedicated-webhook-secret', + }, + }; + + const dedicatedOnly = parseZoomRtmsCustomerCredentials(JSON.stringify({ + shared: customer(), + dedicated, + }), 'dedicated_customer'); + assert.equal(dedicatedOnly.credentials.shared, undefined); + assert.deepEqual({ ...dedicatedOnly.credentials.dedicated }, dedicated); + assert.match(dedicatedOnly.entryErrors.shared, /rtmsApp is required/); + + const sharedOnly = parseZoomRtmsCustomerCredentials(JSON.stringify({ + shared: customer(), + dedicated, + }), 'shared_customer'); + assert.deepEqual({ ...sharedOnly.credentials.shared }, customer()); + assert.equal(sharedOnly.credentials.dedicated, undefined); + assert.match(sharedOnly.entryErrors.dedicated, /rtmsApp is not allowed/); +}); + test('rejects partial, unsafe, and duplicate dedicated app settings without leaking secrets', () => { const secret = 'must-never-appear'; const parsed = parseZoomRtmsCustomerCredentials(JSON.stringify({ From 706198b3e9b5b6982142fd2d7ef0d2c73c6a2c1b Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 12 Aug 2026 22:52:41 +0200 Subject: [PATCH 52/53] fix(core): skip placeholder legacy bot callbacks --- package-lock.json | 4 ++-- package.json | 2 +- src/bots/botService.test.ts | 15 +++++++++++++++ src/services/botService.ts | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/bots/botService.test.ts diff --git a/package-lock.json b/package-lock.json index 82ce2bb1..9190f32f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.14", + "version": "1.3.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.14", + "version": "1.3.15", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1089.0", diff --git a/package.json b/package.json index 6526e6ad..25f6f21b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.14", + "version": "1.3.15", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/botService.test.ts b/src/bots/botService.test.ts new file mode 100644 index 00000000..e8df91cb --- /dev/null +++ b/src/bots/botService.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { hasUsableLegacyCoreToken } from '../services/botService'; + +test('legacy Core callback skips missing and placeholder bearer tokens', () => { + assert.equal(hasUsableLegacyCoreToken(''), false); + assert.equal(hasUsableLegacyCoreToken(' your-auth-token '), false); + assert.equal(hasUsableLegacyCoreToken('PLACEHOLDER'), false); + assert.equal(hasUsableLegacyCoreToken('unused'), false); +}); + +test('legacy Core callback accepts a real bearer token value', () => { + assert.equal(hasUsableLegacyCoreToken('eyJhbGciOiJSUzI1NiJ9.payload.signature'), true); +}); diff --git a/src/services/botService.ts b/src/services/botService.ts index f7cbecd2..abfb078a 100644 --- a/src/services/botService.ts +++ b/src/services/botService.ts @@ -3,6 +3,14 @@ import { BotStatus, IVFSResponse, LogCategory, LogSubCategory } from '../types'; import config from '../config'; import { Logger } from 'winston'; +export const hasUsableLegacyCoreToken = (token: string): boolean => { + const normalized = token.trim().toLowerCase(); + return normalized.length > 0 && + normalized !== 'your-auth-token' && + normalized !== 'placeholder' && + normalized !== 'unused'; +}; + export const patchBotStatus = async ({ eventId, botId, @@ -16,6 +24,10 @@ export const patchBotStatus = async ({ provider: 'google' | 'microsoft' | 'zoom', status: BotStatus[], }, logger: Logger) => { + if (!hasUsableLegacyCoreToken(token)) { + logger.debug('Skipping legacy Core bot status callback because no user bearer token was supplied'); + return true; + } try { const apiV2 = createApiV2(token, config.serviceKey); const response = await apiV2.patch< @@ -59,6 +71,10 @@ export const addBotLog = async ({ category: LogCategory, subCategory: LogSubCategory, }, logger: Logger) => { + if (!hasUsableLegacyCoreToken(token)) { + logger.debug('Skipping legacy Core bot log callback because no user bearer token was supplied'); + return true; + } try { const apiV2 = createApiV2(token, config.serviceKey); const response = await apiV2.patch< From 085a357f05a96b0aaa6401f3a6716623f6cd5e98 Mon Sep 17 00:00:00 2001 From: jakob Date: Wed, 9 Sep 2026 22:04:09 +0200 Subject: [PATCH 53/53] fix: reduce Google Meet recording load and release 1.3.16 --- CHANGELOG.md | 5 +++++ README.md | 12 ++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/bots/GoogleMeetBot.ts | 14 ++++++++------ src/lib/recording.test.ts | 16 ++++++++++++++++ 6 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 src/lib/recording.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ec7e2b..13393f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - N/A +## [1.3.16] - 2026-09-09 + +### Fixed +- Reduce Google Meet recording load by preferring VP8 for WebM and limiting capture to 25 fps; honor the existing video bitrate setting. + ## [1.0.0] - 2024-01-01 ### Added diff --git a/README.md b/README.md index 93010b74..8db0ff6e 100644 --- a/README.md +++ b/README.md @@ -721,3 +721,15 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- **Note**: This project is for educational and legitimate automation purposes. Please ensure compliance with the terms of service of the platforms you're automating. + +### Browser recording quality + +Google Meet and the shared browser recorder prefer VP8/Opus for WebM, with VP9 +as a compatibility fallback. Google Meet capture requests at most 25 fps to reduce +encoding load. `RECORDING_VIDEO_BITS_PER_SECOND` sets the video bitrate target +(default: 4000000). MP4 continues to prefer H.264/AAC. + +If audio stutters, inspect audio packet timestamps as well as browser CPU load: +continuous decoding alone does not rule out gaps in the recording timeline. +Changing encoder settings requires a new recording to verify the improvement; +it cannot recover audio missing from an existing file. diff --git a/package-lock.json b/package-lock.json index 9190f32f..45d96e5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meeting-bot", - "version": "1.3.15", + "version": "1.3.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meeting-bot", - "version": "1.3.15", + "version": "1.3.16", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1089.0", diff --git a/package.json b/package.json index 25f6f21b..9cf80182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meeting-bot", - "version": "1.3.15", + "version": "1.3.16", "description": "Run meeting bots for Google Meet, Microsoft Teams and Zoom", "main": "index.js", "license": "MIT", diff --git a/src/bots/GoogleMeetBot.ts b/src/bots/GoogleMeetBot.ts index bdc5b836..b3fbc633 100644 --- a/src/bots/GoogleMeetBot.ts +++ b/src/bots/GoogleMeetBot.ts @@ -674,12 +674,13 @@ export class GoogleMeetBot extends MeetBotBase { } }); - const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension); + // Match the shared recorder: prefer VP8 to reduce real-time encoding load. + const { mimeTypes } = getRecordingMimeTypesForExtension(config.uploaderFileExtension, true); // Inject the MediaRecorder code into the browser context using page.evaluate await this.page.evaluate( - async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes }: - { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[] }) => { + async ({ teamId, duration, inactivityLimit, loneParticipantExitDelayMs, userId, slightlySecretId, activateInactivityDetectionAfter, activateInactivityDetectionAfterMinutes, mimeTypes, videoBitsPerSecond }: + { teamId:string, userId: string, duration: number, inactivityLimit: number, loneParticipantExitDelayMs: number, slightlySecretId: string, activateInactivityDetectionAfter: string, activateInactivityDetectionAfterMinutes: number, mimeTypes: string[], videoBitsPerSecond: number }) => { let timeoutId: NodeJS.Timeout; let inactivitySilenceDetectionTimeout: NodeJS.Timeout; let isOnValidGoogleMeetPageInterval: NodeJS.Timeout; @@ -707,7 +708,7 @@ export class GoogleMeetBot extends MeetBotBase { } const stream: MediaStream = await (navigator.mediaDevices as any).getDisplayMedia({ - video: true, + video: { frameRate: { ideal: 25, max: 25 } }, audio: { autoGainControl: false, channels: 2, @@ -732,7 +733,7 @@ export class GoogleMeetBot extends MeetBotBase { } console.log(`Media Recorder will use ${selectedMimeType} codecs...`); - const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType }); + const mediaRecorder = new MediaRecorder(stream, { mimeType: selectedMimeType, videoBitsPerSecond }); console.log(`Media Recorder actual mime type: ${mediaRecorder.mimeType}`); let chunkUploadChain: Promise = Promise.resolve(); let isStoppingRecording = false; @@ -1250,7 +1251,8 @@ export class GoogleMeetBot extends MeetBotBase { slightlySecretId: this.slightlySecretId, activateInactivityDetectionAfterMinutes: config.activateInactivityDetectionAfter, activateInactivityDetectionAfter: new Date(new Date().getTime() + config.activateInactivityDetectionAfter * 60 * 1000).toISOString(), - mimeTypes + mimeTypes, + videoBitsPerSecond: config.recordingVideoBitsPerSecond } ); diff --git a/src/lib/recording.test.ts b/src/lib/recording.test.ts new file mode 100644 index 00000000..b1036d86 --- /dev/null +++ b/src/lib/recording.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getRecordingMimeTypesForExtension, mp4MimeType, vp8MimeType, vp9MimeType, webmMimeType } from './recording'; + +test('real-time WebM capture prefers VP8 with VP9 compatibility fallbacks', () => { + const { mimeTypes } = getRecordingMimeTypesForExtension('.webm', true); + assert.equal(mimeTypes.find(() => true), vp8MimeType); + assert.equal(mimeTypes.find((mimeType) => mimeType !== vp8MimeType), webmMimeType); + assert.equal(mimeTypes.find((mimeType) => mimeType === vp9MimeType), vp9MimeType); +}); + +test('real-time MP4 capture retains H.264 preference before WebM fallback', () => { + const { mimeTypes } = getRecordingMimeTypesForExtension('.mp4', true); + assert.equal(mimeTypes[0], mp4MimeType); + assert.equal(mimeTypes.find((mimeType) => mimeType !== mp4MimeType), vp8MimeType); +});