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 { const userInfo = await linkedinRequest('/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 { 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 { 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 { 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:' } 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 { 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 { 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('/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 { 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 { 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('/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 { 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 { 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('/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 { 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 { 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 { 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 { 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 { if (author) return requirePersonUrn(author, 'params.author') return (await getPersonUrn()).personUrn } async function resolveCreateVideoBytes(input: CreateVideoPostInput): Promise { 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 { 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 { 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 { 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 { 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 }