Skip to content

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

Package listing

@kody/linkedin

src/client.ts

1027 lines · 30.1 KB · TypeScript
import {
	LINKEDIN_CONNECT_URL,
	LINKEDIN_UPLOAD_ORIGIN,
	LINKEDIN_VERSION,
	LinkedInApiError,
	linkedinRequest,
	linkedinRequestWithResponse,
	linkedInRestHeadersPreview,
	uploadLinkedInImageBytes,
} from './request.ts'
import type {
	CreateArticlePostInput,
	CreateImagePostInput,
	CreatePostInput,
	CreateTextPostInput,
	CreateVideoPostInput,
	DeletePostInput,
	FinalizeVideoUploadInput,
	JsonRecord,
	LinkedInUserInfo,
	LinkedInVisibility,
	RegisterImageUploadInput,
	RegisterVideoUploadInput,
	UploadVideoPartInput,
	VideoUploadInstruction,
} from './types.ts'

export type {
	CreateArticlePostInput,
	CreateImagePostInput,
	CreatePostInput,
	CreateTextPostInput,
	CreateVideoPostInput,
	DeletePostInput,
	FinalizeVideoUploadInput,
	JsonRecord,
	LinkedInRequestInput,
	LinkedInRequestResult,
	LinkedInUserInfo,
	LinkedInVisibility,
	QueryInput,
	QueryValue,
	RegisterImageUploadInput,
	RegisterVideoUploadInput,
	UploadVideoPartInput,
	VideoUploadInstruction,
} from './types.ts'

export {
	LinkedInApiError,
	LINKEDIN_ACCESS_TOKEN_SECRET,
	LINKEDIN_API_ORIGIN,
	LINKEDIN_INTEGRATION,
	LINKEDIN_UPLOAD_ORIGIN,
	LINKEDIN_VERSION,
	RESTLI_PROTOCOL_VERSION,
	linkedinRequest,
	linkedinRequestWithResponse,
	rawLinkedInRequest,
	request,
} from './request.ts'

export { requireRecord, requireStringField } from './types.ts'

export async function getUserInfo(): Promise<LinkedInUserInfo> {
	const userInfo = await linkedinRequest<LinkedInUserInfo>('/v2/userinfo')
	if (!stringValue(userInfo.sub)) {
		throw new Error('LinkedIn userinfo response did not include the required sub field.')
	}
	return userInfo
}

export async function getPersonUrn(): Promise<{ personUrn: string; userInfo: LinkedInUserInfo }> {
	const userInfo = await getUserInfo()
	return {
		personUrn: personUrnFromUserInfo(userInfo),
		userInfo,
	}
}

export async function smokeTest(): Promise<JsonRecord> {
	const userInfo = await getUserInfo()
	return {
		ok: true,
		integration: 'linkedin',
		endpoint: 'https://api.linkedin.com/v2/userinfo',
		tokenMode: 'saved OAuth integration',
		hasSubject: Boolean(userInfo.sub),
		hasName: Boolean(userInfo.name),
		hasEmail: Boolean(userInfo.email),
		emailVerified: userInfo.email_verified ?? null,
		hasPicture: Boolean(userInfo.picture),
		hasLocale: Boolean(userInfo.locale),
		personUrnPresent: Boolean(userInfo.sub),
		connectUrl: LINKEDIN_CONNECT_URL,
		refreshTokenNote:
			'No linkedinRefreshToken is currently saved; reconnect with https://kody.codes/connect/oauth?provider=linkedin when the access token expires.',
	}
}

export async function createTextPost(input: CreateTextPostInput): Promise<JsonRecord> {
	const text = requireString(input.text, 'create-text-post requires params.text.')
	const visibility = normalizeVisibility(input.visibility)
	const author = await resolveAuthor(input.author)
	const payload = buildTextPostPayload({
		author,
		commentary: text,
		visibility,
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})

	if (input.dryRun) {
		return buildPostDryRun('create-text-post', payload)
	}

	requireConfirm(
		input.confirm,
		'create-text-post publishes to LinkedIn. Pass params.confirm = true only after explicit user approval.',
	)
	return createPostRequest('create-text-post', payload)
}

export async function createArticlePost(input: CreateArticlePostInput): Promise<JsonRecord> {
	const text = requireString(input.text, 'create-article-post requires params.text.')
	const url = requireUrl(input.url, 'create-article-post requires params.url to be a valid URL.')
	const title = requireString(input.title, 'create-article-post requires params.title.')
	const description = requireString(
		input.description,
		'create-article-post requires params.description.',
	)
	const visibility = normalizeVisibility(input.visibility)
	const author = await resolveAuthor(input.author)
	let thumbnail = stringValue(input.thumbnail)
	if (thumbnail) {
		thumbnail = requireImageUrn(
			thumbnail,
			'create-article-post params.thumbnail must be a LinkedIn image URN.',
		)
	} else if (!input.thumbnailUrl) {
		throw new Error(
			'create-article-post requires params.thumbnail or params.thumbnailUrl because LinkedIn does not scrape link previews.',
		)
	}

	if (input.dryRun && !thumbnail) {
		thumbnail = 'urn:li:image:<uploaded-from-thumbnail-url>'
	}

	if (!input.dryRun) {
		requireConfirm(
			input.confirm,
			'create-article-post uploads and publishes to LinkedIn. Pass params.confirm = true only after explicit user approval.',
		)
		if (!thumbnail) {
			const image = await imageBytesFromUrl(
				requireString(
					input.thumbnailUrl,
					'create-article-post requires params.thumbnailUrl.',
				),
			)
			const registered = await registerImageUpload({ owner: author, confirm: true })
			const registeredValue = asRecord(registered.value) ?? registered
			const uploadUrl = requireString(
				registeredValue.uploadUrl,
				'LinkedIn initializeUpload response is missing value.uploadUrl.',
			)
			thumbnail = requireImageUrn(
				registeredValue.image,
				'LinkedIn initializeUpload response is missing value.image.',
			)
			await uploadLinkedInImageBytes(uploadUrl, image.bytes, image.contentType)
		}
	}

	thumbnail = requireImageUrn(
		thumbnail,
		'create-article-post could not resolve a LinkedIn image URN.',
	)
	const payload = buildArticleSharePayload({
		author,
		commentary: text,
		url,
		title,
		description,
		thumbnail,
		visibility,
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})

	if (input.dryRun) {
		return {
			...buildPostDryRun('create-article-post', payload),
			plannedSteps: input.thumbnail
				? ['create /rest/posts article post with the supplied image URN']
				: [
						'initialize /rest/images upload',
						'fetch and upload params.thumbnailUrl',
						'create /rest/posts article post with title, description, and image URN',
					],
		}
	}

	return createPostRequest('create-article-post', payload)
}

export async function deletePost(input: DeletePostInput): Promise<JsonRecord> {
	const postUrn = requirePostUrn(input.postUrn)
	const path = '/v2/ugcPosts/' + encodeURIComponent(postUrn)
	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'delete-post',
			method: 'DELETE',
			endpoint: 'https://api.linkedin.com' + path,
			postUrn,
			deleteNote: 'Pass params.confirm = true only after explicit user approval.',
		}
	}
	requireConfirm(
		input.confirm,
		'delete-post permanently deletes a LinkedIn post. Pass params.confirm = true only after explicit user approval.',
	)
	const result = await linkedinRequestWithResponse(path, {
		method: 'DELETE',
		restli: true,
	})
	return {
		utility: 'delete-post',
		ok: result.ok,
		status: result.status,
		statusText: result.statusText,
		postUrn,
	}
}

export async function registerImageUpload(input: RegisterImageUploadInput): Promise<JsonRecord> {
	const owner = input.owner
		? requirePersonUrn(input.owner, 'params.owner')
		: (await getPersonUrn()).personUrn
	const payload = {
		initializeUploadRequest: {
			owner,
		},
	}

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'register-image-upload',
			method: 'POST',
			endpoint: 'https://api.linkedin.com/rest/images?action=initializeUpload',
			requiredScope: 'w_member_social',
			headers: linkedInRestHeadersPreview(),
			payload,
			nextStep: 'Upload image bytes to value.uploadUrl, then use value.image with create-image-post.',
		}
	}

	requireConfirm(
		input.confirm,
		'register-image-upload initializes a LinkedIn image upload. Pass params.confirm = true only when you intend to upload media.',
	)
	try {
		return linkedinRequest<JsonRecord>('/rest/images', {
			method: 'POST',
			query: { action: 'initializeUpload' },
			restli: true,
			linkedinVersion: LINKEDIN_VERSION,
			body: payload,
		})
	} catch (error) {
		throw enhancePostingError(error, 'register-image-upload')
	}
}

export async function createImagePost(input: CreateImagePostInput): Promise<JsonRecord> {
	const text = requireString(input.text, 'create-image-post requires params.text.')
	const imageUrn = requireImageUrn(
		input.imageUrn ?? input.asset,
		'create-image-post requires params.imageUrn or params.asset to be a LinkedIn image URN from register-image-upload.',
	)
	const visibility = normalizeVisibility(input.visibility)
	const author = await resolveAuthor(input.author)
	const payload = buildImageSharePayload({
		author,
		commentary: text,
		imageUrn,
		title: stringValue(input.title),
		altText: stringValue(input.altText) ?? stringValue(input.description),
		visibility,
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})

	if (input.dryRun) {
		return buildPostDryRun('create-image-post', payload)
	}

	requireConfirm(
		input.confirm,
		'create-image-post publishes to LinkedIn. Pass params.confirm = true only after explicit user approval.',
	)
	return createPostRequest('create-image-post', payload)
}

export async function registerVideoUpload(input: RegisterVideoUploadInput): Promise<JsonRecord> {
	const owner = input.owner
		? requirePersonUrn(input.owner, 'params.owner')
		: (await getPersonUrn()).personUrn
	const fileSizeBytes = requirePositiveInteger(
		input.fileSizeBytes,
		'register-video-upload requires params.fileSizeBytes.',
	)
	const initializeUploadRequest: JsonRecord = {
		owner,
		fileSizeBytes,
		uploadCaptions: input.uploadCaptions === true,
		uploadThumbnail: input.uploadThumbnail === true,
	}
	if (input.linkbackContext) initializeUploadRequest.linkbackContext = input.linkbackContext
	if (input.templateName) initializeUploadRequest.templateName = input.templateName
	const payload = { initializeUploadRequest }

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'register-video-upload',
			method: 'POST',
			endpoint: 'https://api.linkedin.com/rest/videos?action=initializeUpload',
			requiredScope: 'w_member_social',
			headers: linkedInRestHeadersPreview(),
			payload,
			nextStep:
				'PUT each byte range to the returned uploadInstructions URLs, collect ETags in order, then call finalize-video-upload.',
		}
	}

	requireConfirm(
		input.confirm,
		'register-video-upload initializes a LinkedIn video upload. Pass params.confirm = true only when you intend to upload media.',
	)
	try {
		return linkedinRequest<JsonRecord>('/rest/videos', {
			method: 'POST',
			query: { action: 'initializeUpload' },
			restli: true,
			linkedinVersion: LINKEDIN_VERSION,
			body: payload,
		})
	} catch (error) {
		throw enhancePostingError(error, 'register-video-upload')
	}
}

export async function uploadVideoPart(input: UploadVideoPartInput): Promise<JsonRecord> {
	const uploadUrl = requireLinkedInUploadUrl(input.uploadUrl)

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'upload-video-part',
			method: 'PUT',
			endpoint: uploadUrl,
			byteLength: getProvidedByteLength(input),
			headers: { 'content-type': input.contentType ?? 'application/octet-stream' },
			nextStep:
				'Run with confirm: true, then pass the returned uploadedPartId to finalize-video-upload in uploadInstructions order.',
		}
	}

	requireConfirm(
		input.confirm,
		'upload-video-part PUTs bytes to a LinkedIn pre-signed upload URL. Pass params.confirm = true only when you intend to upload media.',
	)
	const bytes = await resolveUploadBytes(input)
	return uploadVideoPartBytes(uploadUrl, bytes, input.contentType)
}

export async function finalizeVideoUpload(input: FinalizeVideoUploadInput): Promise<JsonRecord> {
	const video = requireVideoUrn(input.video, 'finalize-video-upload requires params.video.')
	const uploadedPartIds = requireStringArray(
		input.uploadedPartIds,
		'finalize-video-upload requires params.uploadedPartIds.',
	)
	const payload = {
		finalizeUploadRequest: {
			video,
			uploadToken: input.uploadToken ?? '',
			uploadedPartIds,
		},
	}

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'finalize-video-upload',
			method: 'POST',
			endpoint: 'https://api.linkedin.com/rest/videos?action=finalizeUpload',
			requiredScope: 'w_member_social',
			headers: linkedInRestHeadersPreview(),
			payload,
			nextStep: 'After LinkedIn finishes processing the video, pass the video URN to create-video-post.',
		}
	}

	requireConfirm(
		input.confirm,
		'finalize-video-upload finalizes a LinkedIn video upload. Pass params.confirm = true only when all video parts are uploaded.',
	)
	try {
		return linkedinRequest<JsonRecord>('/rest/videos', {
			method: 'POST',
			query: { action: 'finalizeUpload' },
			restli: true,
			linkedinVersion: LINKEDIN_VERSION,
			body: payload,
		})
	} catch (error) {
		throw enhancePostingError(error, 'finalize-video-upload')
	}
}

export async function createVideoPost(input: CreateVideoPostInput): Promise<JsonRecord> {
	const text = requireString(input.text, 'create-video-post requires params.text.')
	const visibility = normalizeVisibility(input.visibility)
	const author = await resolveAuthor(input.author)

	if (input.videoUrn) {
		return createVideoPostFromUrn({
			utilityName: 'create-video-post',
			text,
			videoUrn: input.videoUrn,
			visibility,
			author,
			title: stringValue(input.title),
			isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
			dryRun: input.dryRun,
			confirm: input.confirm,
		})
	}

	if (input.dryRun) {
		return {
			dryRun: true,
			utility: 'create-video-post',
			requiredScope: 'w_member_social',
			plannedSteps: [
				'initialize /rest/videos upload',
				'PUT video bytes to returned uploadInstructions URLs without Authorization',
				'finalize /rest/videos upload with uploadedPartIds',
				'create /rest/posts media post',
			],
			uploadSource: input.uploadFromUrl
				? 'uploadFromUrl'
				: input.uploadFromBytes !== undefined
					? 'uploadFromBytes'
					: null,
			publishNote:
				'Pass params.confirm = true only after explicit user approval to upload and publish this video.',
		}
	}

	requireConfirm(
		input.confirm,
		'create-video-post uploads and publishes to LinkedIn. Pass params.confirm = true only after explicit user approval.',
	)
	const bytes = await resolveCreateVideoBytes(input)
	const fileSizeBytes = input.fileSizeBytes ?? bytes.byteLength
	if (fileSizeBytes !== bytes.byteLength) {
		throw new Error('create-video-post params.fileSizeBytes must match the resolved video byte length.')
	}
	const registered = await registerVideoUpload({
		owner: author,
		fileSizeBytes,
		uploadCaptions: input.uploadCaptions,
		uploadThumbnail: input.uploadThumbnail,
		confirm: true,
	})
	const registeredValue = asRecord(registered.value) ?? registered
	const uploadInstructions = requireUploadInstructions(registeredValue.uploadInstructions)
	const uploadedPartIds = await uploadVideoParts(uploadInstructions, bytes)
	await finalizeVideoUpload({
		video: requireVideoUrn(
			registeredValue.video,
			'LinkedIn initializeUpload response is missing value.video.',
		),
		uploadToken: stringValue(registeredValue.uploadToken) ?? '',
		uploadedPartIds,
		confirm: true,
	})

	return createVideoPostFromUrn({
		utilityName: 'create-video-post',
		text,
		videoUrn: requireVideoUrn(
			registeredValue.video,
			'LinkedIn initializeUpload response is missing value.video.',
		),
		visibility,
		author,
		title: stringValue(input.title),
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
		confirm: true,
	})
}

export async function createPost(input: CreatePostInput): Promise<JsonRecord> {
	const commentary = requireString(
		input.commentary ?? input.text,
		'create-post requires params.commentary or params.text.',
	)
	const visibility = normalizeVisibility(input.visibility)
	const author = await resolveAuthor(input.author)
	const payload = buildBasePostPayload({
		author,
		commentary,
		visibility,
		distribution: asRecord(input.distribution) ?? undefined,
		content: asRecord(input.content) ?? undefined,
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})

	if (input.dryRun) {
		return buildPostDryRun('create-post', payload)
	}

	requireConfirm(
		input.confirm,
		'create-post publishes to LinkedIn. Pass params.confirm = true only after explicit user approval.',
	)
	return createPostRequest('create-post', payload)
}

export function personUrnFromUserInfo(userInfo: LinkedInUserInfo): string {
	const sub = requireString(userInfo.sub, 'LinkedIn userinfo is missing sub; cannot build person URN.')
	return 'urn:li:person:' + sub
}

async function createPostRequest(utilityName: string, payload: JsonRecord): Promise<JsonRecord> {
	try {
		const result = await linkedinRequestWithResponse('/rest/posts', {
			method: 'POST',
			restli: true,
			linkedinVersion: LINKEDIN_VERSION,
			body: payload,
		})
		return {
			utility: utilityName,
			ok: result.ok,
			status: result.status,
			statusText: result.statusText,
			postId: result.headers['x-restli-id'] ?? null,
			body: result.body,
		}
	} catch (error) {
		throw enhancePostingError(error, utilityName)
	}
}

function buildTextPostPayload(input: {
	author: string
	commentary: string
	visibility: LinkedInVisibility
	isReshareDisabledByAuthor?: boolean
}): JsonRecord {
	return buildBasePostPayload(input)
}

function buildArticleSharePayload(input: {
	author: string
	commentary: string
	url: string
	title: string
	description: string
	thumbnail: string
	visibility: LinkedInVisibility
	isReshareDisabledByAuthor?: boolean
}): JsonRecord {
	const article: JsonRecord = {
		source: input.url,
		title: input.title,
		description: input.description,
		thumbnail: requireImageUrn(
			input.thumbnail,
			'create-article-post params.thumbnail must be a LinkedIn image URN.',
		),
	}

	return buildBasePostPayload({
		author: input.author,
		commentary: input.commentary,
		visibility: input.visibility,
		content: {
			article,
		},
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})
}

function buildImageSharePayload(input: {
	author: string
	commentary: string
	imageUrn: string
	title?: string
	altText?: string
	visibility: LinkedInVisibility
	isReshareDisabledByAuthor?: boolean
}): JsonRecord {
	const media: JsonRecord = {
		id: input.imageUrn,
	}
	if (input.title) media.title = input.title
	if (input.altText) media.altText = input.altText

	return buildBasePostPayload({
		author: input.author,
		commentary: input.commentary,
		visibility: input.visibility,
		content: {
			media,
		},
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})
}

function buildVideoSharePayload(input: {
	author: string
	commentary: string
	videoUrn: string
	title?: string
	visibility: LinkedInVisibility
	isReshareDisabledByAuthor?: boolean
}): JsonRecord {
	const media: JsonRecord = {
		id: input.videoUrn,
	}
	if (input.title) media.title = input.title

	return buildBasePostPayload({
		author: input.author,
		commentary: input.commentary,
		visibility: input.visibility,
		content: {
			media,
		},
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})
}

async function createVideoPostFromUrn(input: {
	utilityName: string
	text: string
	videoUrn: string
	visibility: LinkedInVisibility
	author: string
	title?: string
	isReshareDisabledByAuthor?: boolean
	dryRun?: boolean
	confirm?: boolean
}): Promise<JsonRecord> {
	const videoUrn = requireVideoUrn(
		input.videoUrn,
		'create-video-post requires params.videoUrn to be a LinkedIn video URN.',
	)
	const payload = buildVideoSharePayload({
		author: input.author,
		commentary: input.text,
		videoUrn,
		title: input.title,
		visibility: input.visibility,
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor,
	})

	if (input.dryRun) {
		return buildPostDryRun(input.utilityName, payload)
	}

	requireConfirm(
		input.confirm,
		'create-video-post publishes to LinkedIn. Pass params.confirm = true only after explicit user approval.',
	)
	return createPostRequest(input.utilityName, payload)
}

function buildBasePostPayload(input: {
	author: string
	commentary: string
	visibility: LinkedInVisibility
	distribution?: JsonRecord
	content?: JsonRecord
	isReshareDisabledByAuthor?: boolean
}): JsonRecord {
	const payload: JsonRecord = {
		author: input.author,
		commentary: input.commentary,
		visibility: input.visibility,
		distribution: input.distribution ?? buildMainFeedDistribution(),
		lifecycleState: 'PUBLISHED',
		isReshareDisabledByAuthor: input.isReshareDisabledByAuthor ?? false,
	}
	if (input.content) payload.content = input.content
	return payload
}

function buildMainFeedDistribution(): JsonRecord {
	return {
		feedDistribution: 'MAIN_FEED',
		targetEntities: [],
		thirdPartyDistributionChannels: [],
	}
}

function buildPostDryRun(utility: string, payload: JsonRecord): JsonRecord {
	return {
		dryRun: true,
		utility,
		method: 'POST',
		endpoint: 'https://api.linkedin.com/rest/posts',
		requiredScope: 'w_member_social',
		headers: linkedInRestHeadersPreview(),
		payload,
		publishNote: 'Pass params.confirm = true only after explicit user approval to publish this post.',
	}
}

async function resolveAuthor(author?: string): Promise<string> {
	if (author) return requirePersonUrn(author, 'params.author')
	return (await getPersonUrn()).personUrn
}

async function resolveCreateVideoBytes(input: CreateVideoPostInput): Promise<ArrayBuffer> {
	if (input.uploadFromBytes !== undefined) {
		return bytesFromUnknown(input.uploadFromBytes, 'params.uploadFromBytes')
	}
	if (input.uploadFromUrl) return bytesFromUrl(input.uploadFromUrl)
	throw new Error(
		'create-video-post requires params.videoUrn, params.uploadFromBytes, or params.uploadFromUrl.',
	)
}

async function resolveUploadBytes(input: UploadVideoPartInput): Promise<ArrayBuffer> {
	if (input.bytes !== undefined) return bytesFromUnknown(input.bytes, 'params.bytes')
	if (input.bytesBase64) return bytesFromBase64(input.bytesBase64)
	if (input.uploadFromUrl) return bytesFromUrl(input.uploadFromUrl)
	throw new Error('upload-video-part requires params.bytes, params.bytesBase64, or params.uploadFromUrl.')
}

async function bytesFromUrl(url: string): Promise<ArrayBuffer> {
	const parsed = new URL(requireUrl(url, 'Video upload URL source must be a valid URL.'))
	if (parsed.protocol !== 'https:') {
		throw new Error('Video upload URL source must use https.')
	}
	const response = await fetch(parsed.toString())
	if (!response.ok) {
		throw new Error('Failed to fetch video bytes: ' + response.status + ' ' + response.statusText)
	}
	return response.arrayBuffer()
}

async function imageBytesFromUrl(
	url: string,
): Promise<{ bytes: ArrayBuffer; contentType: string }> {
	const parsed = new URL(requireUrl(url, 'Image upload URL source must be a valid URL.'))
	if (parsed.protocol !== 'https:') {
		throw new Error('Image upload URL source must use https.')
	}
	const response = await fetch(parsed.toString())
	if (!response.ok) {
		throw new Error('Failed to fetch image bytes: ' + response.status + ' ' + response.statusText)
	}
	const contentType = String(response.headers.get('content-type') || '')
		.split(';')[0]
		.trim()
		.toLowerCase()
	if (!contentType.startsWith('image/')) {
		throw new Error(
			'Image upload URL source must return an image content type, received ' +
				(contentType || 'unknown') +
				'.',
		)
	}
	return {
		bytes: await response.arrayBuffer(),
		contentType,
	}
}

function bytesFromUnknown(value: unknown, label: string): ArrayBuffer {
	if (value instanceof ArrayBuffer) return value
	if (ArrayBuffer.isView(value)) {
		return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)
	}
	if (Array.isArray(value)) {
		return new Uint8Array(value.map((item) => requireByte(item, label))).buffer
	}
	if (typeof value === 'string') {
		return value.startsWith('data:') ? bytesFromDataUrl(value) : bytesFromBase64(value)
	}
	throw new Error(label + ' must be an ArrayBuffer, byte array, base64 string, or data URL.')
}

function getProvidedByteLength(input: UploadVideoPartInput): number | null {
	if (input.bytes !== undefined) return bytesFromUnknown(input.bytes, 'params.bytes').byteLength
	if (input.bytesBase64) return bytesFromBase64(input.bytesBase64).byteLength
	return null
}

function bytesFromDataUrl(value: string): ArrayBuffer {
	const commaIndex = value.indexOf(',')
	if (commaIndex === -1) {
		throw new Error('Data URL video bytes are missing a comma separator.')
	}
	return bytesFromBase64(value.slice(commaIndex + 1))
}

function bytesFromBase64(value: string): ArrayBuffer {
	const binary = atob(value)
	const bytes = new Uint8Array(binary.length)
	for (let index = 0; index < binary.length; index += 1) {
		bytes[index] = binary.charCodeAt(index)
	}
	return bytes.buffer
}

function requireByte(value: unknown, label: string): number {
	if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 255) {
		throw new Error(label + ' byte arrays must contain integers from 0 through 255.')
	}
	return value
}

async function uploadVideoParts(
	instructions: VideoUploadInstruction[],
	bytes: ArrayBuffer,
): Promise<string[]> {
	const uploadedPartIds: string[] = []
	for (const instruction of instructions) {
		if (instruction.lastByte >= bytes.byteLength) {
			throw new Error('LinkedIn upload instruction byte range exceeds the provided video byte length.')
		}
		const partBytes = bytes.slice(instruction.firstByte, instruction.lastByte + 1)
		const result = await uploadVideoPartBytes(instruction.uploadUrl, partBytes)
		uploadedPartIds.push(requireString(result.uploadedPartId, 'LinkedIn upload response did not include an ETag.'))
	}
	return uploadedPartIds
}

async function uploadVideoPartBytes(
	uploadUrl: string,
	bytes: ArrayBuffer,
	contentType = 'application/octet-stream',
): Promise<JsonRecord> {
	const response = await fetch(requireLinkedInUploadUrl(uploadUrl), {
		method: 'PUT',
		headers: {
			'content-type': contentType,
		},
		body: bytes,
	})
	const text = await response.text()
	const etag = response.headers.get('etag')
	if (!response.ok) {
		throw new LinkedInApiError(
			'LinkedIn video part upload failed: ' + response.status + ' ' + response.statusText,
			{
				status: response.status,
				statusText: response.statusText,
				details: text,
			},
		)
	}
	return {
		ok: response.ok,
		status: response.status,
		statusText: response.statusText,
		etag,
		uploadedPartId: normalizeEtag(etag),
	}
}

function requirePersonUrn(value: string, label: string): string {
	const urn = requireString(value, label + ' must be a LinkedIn person URN.')
	if (!urn.startsWith('urn:li:person:')) {
		throw new Error(label + ' must start with urn:li:person:.')
	}
	return urn
}

function requireVideoUrn(value: unknown, message: string): string {
	const videoUrn = requireString(value, message)
	if (!videoUrn.startsWith('urn:li:video:')) {
		throw new Error(message)
	}
	return videoUrn
}

function requireImageUrn(value: unknown, message: string): string {
	const imageUrn = requireString(value, message)
	if (!imageUrn.startsWith('urn:li:image:')) {
		throw new Error(message)
	}
	return imageUrn
}

function requirePostUrn(value: unknown): string {
	const postUrn = requireString(value, 'delete-post requires params.postUrn.')
	if (!postUrn.startsWith('urn:li:share:') && !postUrn.startsWith('urn:li:ugcPost:')) {
		throw new Error('delete-post params.postUrn must be a LinkedIn share or ugcPost URN.')
	}
	return postUrn
}

function requirePositiveInteger(value: unknown, message: string): number {
	if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
		throw new Error(message)
	}
	return value
}

function requireStringArray(value: unknown, message: string): string[] {
	if (!Array.isArray(value) || value.length === 0) {
		throw new Error(message)
	}
	return value.map((item) => requireString(item, message))
}

function requireUploadInstructions(value: unknown): VideoUploadInstruction[] {
	if (!Array.isArray(value) || value.length === 0) {
		throw new Error('LinkedIn initializeUpload response did not include uploadInstructions.')
	}
	return value.map((item) => {
		const record = asRecord(item)
		if (!record) {
			throw new Error('LinkedIn uploadInstructions must be objects.')
		}
		return {
			...record,
			uploadUrl: requireLinkedInUploadUrl(record.uploadUrl),
			firstByte: requireNonNegativeInteger(
				record.firstByte,
				'LinkedIn upload instruction is missing firstByte.',
			),
			lastByte: requireNonNegativeInteger(
				record.lastByte,
				'LinkedIn upload instruction is missing lastByte.',
			),
		}
	})
}

function requireNonNegativeInteger(value: unknown, message: string): number {
	if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
		throw new Error(message)
	}
	return value
}

function requireLinkedInUploadUrl(value: unknown): string {
	const uploadUrl = requireUrl(value, 'LinkedIn upload URL must be a valid URL.')
	const parsed = new URL(uploadUrl)
	if (parsed.origin !== LINKEDIN_UPLOAD_ORIGIN) {
		throw new Error('LinkedIn video upload URLs must use https://www.linkedin.com.')
	}
	return parsed.toString()
}

function requireConfirm(confirm: unknown, message: string): void {
	if (confirm !== true) {
		throw new Error(message)
	}
}

function enhancePostingError(error: unknown, utilityName: string): Error {
	if (error instanceof LinkedInApiError && (error.status === 401 || error.status === 403)) {
		return new LinkedInApiError(
			utilityName +
				' failed with LinkedIn auth/scope status ' +
				error.status +
				'. Confirm the access token is current and the LinkedIn app has the Share on LinkedIn product with w_member_social granted. Reconnect at https://kody.codes/connect/oauth?provider=linkedin.',
			{
				status: error.status,
				statusText: error.statusText,
				details: error.details,
			},
		)
	}
	return error instanceof Error ? error : new Error(String(error))
}

function normalizeVisibility(value: unknown): LinkedInVisibility {
	const visibility = value === undefined ? 'PUBLIC' : requireString(value, 'visibility must be a string.')
	if (visibility === 'PUBLIC' || visibility === 'CONNECTIONS' || visibility === 'LOGGED_IN') {
		return visibility
	}
	throw new Error('visibility must be PUBLIC, CONNECTIONS, or LOGGED_IN.')
}

function requireUrl(value: unknown, message: string): string {
	const url = requireString(value, message)
	try {
		return new URL(url).toString()
	} catch {
		throw new Error(message)
	}
}

function requireString(value: unknown, message: string): string {
	if (typeof value !== 'string' || value.length === 0) {
		throw new Error(message)
	}
	return value
}

function normalizeEtag(value: string | null): string | null {
	if (!value) return null
	return value.replace(/^\"|\"$/g, '')
}

function stringValue(value: unknown): string | undefined {
	return typeof value === 'string' && value.length > 0 ? value : undefined
}

function asRecord(value: unknown): JsonRecord | null {
	if (value && typeof value === 'object' && !Array.isArray(value)) {
		return value as JsonRecord
	}
	return null
}