Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/zoom

src/locators.ts

101 lines · 3.4 KB · TypeScript
import { readOptionalString } from './auth.ts'
import type { ZoomMeetingLocator, ZoomUserLocator, ZoomWebinarLocator } from './types.ts'

const DEFAULT_USER_ID = 'me'

/**
 * Resolve the Zoom user path segment. Defaults to `me` (OAuth user context;
 * Server-to-Server also accepts `me` for the app owner).
 */
export function normalizeUserLocator(params: Record<string, unknown> = {}): ZoomUserLocator {
	const userId =
		readOptionalString(params.userId) ??
		readOptionalString(params.user_id) ??
		readOptionalString(params.email) ??
		DEFAULT_USER_ID
	return { userId: encodePathSegment(userId) }
}

export function normalizeMeetingLocator(params: Record<string, unknown> = {}): ZoomMeetingLocator {
	const fromUrl = parseZoomResourceUrl(readOptionalString(params.meetingUrl) ?? readOptionalString(params.url))
	const meetingId =
		readOptionalString(params.meetingId) ??
		readOptionalString(params.meeting_id) ??
		(typeof params.id === 'number' ? String(params.id) : undefined) ??
		readOptionalString(params.id) ??
		fromUrl?.id
	if (!meetingId) {
		throw new Error(
			'A meeting locator is required (meetingId, meeting_id, id, or a zoom.us/j/{id} meetingUrl).',
		)
	}
	const occurrenceId =
		readOptionalString(params.occurrenceId) ??
		readOptionalString(params.occurrence_id) ??
		fromUrl?.occurrenceId
	return occurrenceId
		? { meetingId: encodePathSegment(meetingId), occurrenceId }
		: { meetingId: encodePathSegment(meetingId) }
}

export function normalizeWebinarLocator(params: Record<string, unknown> = {}): ZoomWebinarLocator {
	const fromUrl = parseZoomResourceUrl(readOptionalString(params.webinarUrl) ?? readOptionalString(params.url))
	const webinarId =
		readOptionalString(params.webinarId) ??
		readOptionalString(params.webinar_id) ??
		(typeof params.id === 'number' ? String(params.id) : undefined) ??
		readOptionalString(params.id) ??
		fromUrl?.id
	if (!webinarId) {
		throw new Error(
			'A webinar locator is required (webinarId, webinar_id, id, or a zoom.us/w/{id} webinarUrl).',
		)
	}
	const occurrenceId =
		readOptionalString(params.occurrenceId) ??
		readOptionalString(params.occurrence_id) ??
		fromUrl?.occurrenceId
	return occurrenceId
		? { webinarId: encodePathSegment(webinarId), occurrenceId }
		: { webinarId: encodePathSegment(webinarId) }
}

export function parseZoomResourceUrl(value: string | undefined): {
	id: string
	occurrenceId?: string
} | null {
	if (!value) return null
	let parsed: URL
	try {
		parsed = new URL(value)
	} catch {
		return null
	}
	if (!isZoomHost(parsed.host)) {
		throw new Error(
			`Meeting and webinar URLs must be on zoom.us (received ${parsed.host}). Do not pass personal aliases or third-party hosts.`,
		)
	}
	const match = parsed.pathname.match(/\/(?:j|w|webinar|wc)\/(\d+)/i)
	if (!match?.[1]) {
		throw new Error(
			'Could not parse a numeric Zoom meeting or webinar id from that URL. Use /j/{id} or /w/{id}.',
		)
	}
	const occurrenceId = parsed.searchParams.get('occurrence_id') ?? undefined
	return occurrenceId ? { id: match[1], occurrenceId } : { id: match[1] }
}

export function encodePathSegment(value: string): string {
	const trimmed = value.trim()
	if (!trimmed) {
		throw new Error('Path segment must be a non-empty string.')
	}
	if (trimmed.includes('%')) return trimmed
	return encodeURIComponent(trimmed)
}

function isZoomHost(host: string): boolean {
	const normalized = host.toLowerCase()
	return normalized === 'zoom.us' || normalized.endsWith('.zoom.us')
}