postiz/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts

747 lines
20 KiB
TypeScript

import {
AuthTokenDetails,
PostDetails,
PostResponse,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import sharp from 'sharp';
import { lookup } from 'mime-types';
import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import { Integration } from '@prisma/client';
import { PostPlug } from '@gitroom/helpers/decorators/post.plug';
import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto';
import imageToPDF from 'image-to-pdf';
import { Readable } from 'stream';
export class LinkedinProvider extends SocialAbstract implements SocialProvider {
identifier = 'linkedin';
name = 'LinkedIn';
oneTimeToken = true;
isBetweenSteps = false;
scopes = [
'openid',
'profile',
'w_member_social',
'r_basicprofile',
'rw_organization_admin',
'w_organization_social',
'r_organization_social',
];
refreshWait = true;
editor = 'normal' as const;
async refreshToken(refresh_token: string): Promise<AuthTokenDetails> {
const {
access_token: accessToken,
refresh_token: refreshToken,
expires_in,
} = await (
await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
client_id: process.env.LINKEDIN_CLIENT_ID!,
client_secret: process.env.LINKEDIN_CLIENT_SECRET!,
}),
})
).json();
const { vanityName } = await (
await this.fetch('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
const {
name,
sub: id,
picture,
} = await (
await this.fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
return {
id,
accessToken,
refreshToken,
expiresIn: expires_in,
name,
picture,
username: vanityName,
};
}
async generateAuthUrl() {
const state = makeId(6);
const codeVerifier = makeId(30);
const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${
process.env.LINKEDIN_CLIENT_ID
}&prompt=none&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/integrations/social/linkedin`
)}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`;
return {
url,
codeVerifier,
state,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const body = new URLSearchParams();
body.append('grant_type', 'authorization_code');
body.append('code', params.code);
body.append(
'redirect_uri',
`${process.env.FRONTEND_URL}/integrations/social/linkedin${
params.refresh ? `?refresh=${params.refresh}` : ''
}`
);
body.append('client_id', process.env.LINKEDIN_CLIENT_ID!);
body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!);
const {
access_token: accessToken,
expires_in: expiresIn,
refresh_token: refreshToken,
scope,
} = await (
await this.fetch('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
).json();
this.checkScopes(this.scopes, scope);
const {
name,
sub: id,
picture,
} = await (
await this.fetch('https://api.linkedin.com/v2/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
const { vanityName } = await (
await this.fetch('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
).json();
return {
id,
accessToken,
refreshToken,
expiresIn,
name,
picture,
username: vanityName,
};
}
async company(token: string, data: { url: string }) {
const { url } = data;
const getCompanyVanity = url.match(
/^https?:\/\/(?:www\.)?linkedin\.com\/company\/([^/]+)\/?$/
);
if (!getCompanyVanity || !getCompanyVanity?.length) {
throw new Error('Invalid LinkedIn company URL');
}
const { elements } = await (
await this.fetch(
`https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${getCompanyVanity[1]}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202501',
Authorization: `Bearer ${token}`,
},
}
)
).json();
return {
options: elements.map((e: { localizedName: string; id: string }) => ({
label: e.localizedName,
value: `@[${e.localizedName}](urn:li:organization:${e.id})`,
}))?.[0],
};
}
protected async uploadPicture(
fileName: string,
accessToken: string,
personId: string,
picture: any,
type = 'personal' as 'company' | 'personal'
) {
// Determine the appropriate endpoint based on file type
const isVideo = fileName.indexOf('mp4') > -1;
const isPdf = fileName.toLowerCase().indexOf('pdf') > -1;
let endpoint: string;
if (isVideo) {
endpoint = 'videos';
} else if (isPdf) {
endpoint = 'documents';
} else {
endpoint = 'images';
}
const {
value: { uploadUrl, image, video, document, uploadInstructions, ...all },
} = await (
await this.fetch(
`https://api.linkedin.com/rest/${endpoint}?action=initializeUpload`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202501',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
initializeUploadRequest: {
owner:
type === 'personal'
? `urn:li:person:${personId}`
: `urn:li:organization:${personId}`,
...(isVideo
? {
fileSizeBytes: picture.length,
uploadCaptions: false,
uploadThumbnail: false,
}
: {}),
},
}),
}
)
).json();
const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl;
const finalOutput = video || image || document;
const etags = [];
for (let i = 0; i < picture.length; i += 1024 * 1024 * 2) {
const upload = await this.fetch(sendUrlRequest, {
method: 'PUT',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202501',
Authorization: `Bearer ${accessToken}`,
...(isVideo
? { 'Content-Type': 'application/octet-stream' }
: isPdf
? { 'Content-Type': 'application/pdf' }
: {}),
},
body: picture.slice(i, i + 1024 * 1024 * 2),
});
etags.push(upload.headers.get('etag'));
}
if (isVideo) {
const a = await this.fetch(
'https://api.linkedin.com/rest/videos?action=finalizeUpload',
{
method: 'POST',
body: JSON.stringify({
finalizeUploadRequest: {
video,
uploadToken: '',
uploadedPartIds: etags,
},
}),
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202501',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}
);
}
return finalOutput;
}
protected fixText(text: string) {
const pattern = /@\[.+?]\(urn:li:organization.+?\)/g;
const matches = text.match(pattern) || [];
const splitAll = text.split(pattern);
const splitTextReformat = splitAll.map((p) => {
return p
.replace(/\\/g, '\\\\')
.replace(/</g, '\\<')
.replace(/>/g, '\\>')
.replace(/#/g, '\\#')
.replace(/~/g, '\\~')
.replace(/_/g, '\\_')
.replace(/\|/g, '\\|')
.replace(/\[/g, '\\[')
.replace(/]/g, '\\]')
.replace(/\*/g, '\\*')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\{/g, '\\{')
.replace(/}/g, '\\}')
.replace(/@/g, '\\@');
});
const connectAll = splitTextReformat.reduce((all, current) => {
const match = matches.shift();
all.push(current);
if (match) {
all.push(match);
}
return all;
}, [] as string[]);
return connectAll.join('');
}
private async convertImagesToPdfCarousel(
postDetails: PostDetails<LinkedinDto>[],
firstPost: PostDetails<LinkedinDto>
): Promise<PostDetails<LinkedinDto>[]> {
// Collect all images from all posts
const allImages = postDetails.flatMap(
(post) =>
post.media?.filter(
(media) =>
media.path.toLowerCase().includes('.jpg') ||
media.path.toLowerCase().includes('.jpeg') ||
media.path.toLowerCase().includes('.png')
) || []
);
if (allImages.length === 0) {
return postDetails;
}
// Convert images to buffers and get dimensions
const imageData = await Promise.all(
allImages.map(async (media) => {
const buffer = await readOrFetch(media.path);
const image = sharp(buffer, {
animated: lookup(media.path) === 'image/gif',
});
const metadata = await image.metadata();
return {
buffer,
width: metadata.width || 0,
height: metadata.height || 0,
};
})
);
// Use the dimensions of the first image for the PDF page size
// You could also use the largest dimensions if you prefer
const firstImageDimensions = imageData[0];
const pageSize = [firstImageDimensions.width, firstImageDimensions.height];
// Convert images to PDF with exact image dimensions
const pdfStream = imageToPDF(
imageData.map((data) => data.buffer),
pageSize
);
// Convert stream to buffer
const pdfBuffer = await this.streamToBuffer(pdfStream);
// Create a temporary file-like object for the PDF
const pdfMedia = {
path: 'carousel.pdf',
buffer: pdfBuffer,
};
// Return modified post details with PDF instead of images
const modifiedFirstPost = {
...firstPost,
media: [pdfMedia] as any[],
};
// Remove media from other posts since we're combining everything into one PDF
const modifiedRestPosts = postDetails.slice(1).map((post) => ({
...post,
media: [] as any[],
}));
return [modifiedFirstPost, ...modifiedRestPosts];
}
private async streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
private async processMediaForPosts(
postDetails: PostDetails<LinkedinDto>[],
accessToken: string,
personId: string,
type: 'company' | 'personal'
): Promise<Record<string, string[]>> {
const mediaUploads = await Promise.all(
postDetails.flatMap(
(post) =>
post.media?.map(async (media) => {
let mediaBuffer: Buffer;
// Check if media has a buffer (from PDF conversion)
if (
media &&
typeof media === 'object' &&
'buffer' in media &&
Buffer.isBuffer(media.buffer)
) {
mediaBuffer = (media as any).buffer;
} else {
mediaBuffer = await this.prepareMediaBuffer(media.path);
}
const uploadedMediaId = await this.uploadPicture(
media.path,
accessToken,
personId,
mediaBuffer,
type
);
return {
id: uploadedMediaId,
postId: post.id,
};
}) || []
)
);
return mediaUploads.reduce((acc, upload) => {
if (!upload?.id) return acc;
acc[upload.postId] = acc[upload.postId] || [];
acc[upload.postId].push(upload.id);
return acc;
}, {} as Record<string, string[]>);
}
private async prepareMediaBuffer(mediaUrl: string): Promise<Buffer> {
const isVideo = mediaUrl.indexOf('mp4') > -1;
if (isVideo) {
return Buffer.from(await readOrFetch(mediaUrl));
}
return await sharp(await readOrFetch(mediaUrl), {
animated: lookup(mediaUrl) === 'image/gif',
})
.toFormat('jpeg')
.resize({ width: 1000 })
.toBuffer();
}
private buildPostContent(isPdf: boolean, mediaIds: string[]) {
if (mediaIds.length === 0) {
return {};
}
if (mediaIds.length === 1) {
return {
content: {
media: {
...(isPdf ? { title: 'slides.pdf' } : {}),
id: mediaIds[0],
},
},
};
}
return {
content: {
multiImage: {
images: mediaIds.map((id) => ({ id })),
},
},
};
}
private createLinkedInPostPayload(
id: string,
type: 'company' | 'personal',
message: string,
mediaIds: string[],
isPdf: boolean
) {
const author =
type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`;
return {
author,
commentary: this.fixText(message),
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [] as string[],
thirdPartyDistributionChannels: [] as string[],
},
...this.buildPostContent(isPdf, mediaIds),
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
};
}
private async createMainPost(
id: string,
accessToken: string,
firstPost: PostDetails,
mediaIds: string[],
type: 'company' | 'personal',
isPdf: boolean
): Promise<string> {
const postPayload = this.createLinkedInPostPayload(
id,
type,
firstPost.message,
mediaIds,
isPdf
);
const response = await this.fetch('https://api.linkedin.com/rest/posts', {
method: 'POST',
headers: {
'LinkedIn-Version': '202501',
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(postPayload),
});
if (response.status !== 201 && response.status !== 200) {
throw new Error('Error posting to LinkedIn');
}
return response.headers.get('x-restli-id')!;
}
private async createCommentPost(
id: string,
accessToken: string,
post: PostDetails,
parentPostId: string,
type: 'company' | 'personal'
): Promise<string> {
const actor =
type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`;
const response = await this.fetch(
`https://api.linkedin.com/v2/socialActions/${decodeURIComponent(
parentPostId
)}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
actor,
object: parentPostId,
message: {
text: this.fixText(post.message),
},
}),
}
);
const { object } = await response.json();
return object;
}
private createPostResponse(
postId: string,
originalPostId: string,
isMainPost: boolean = false
): PostResponse {
const baseUrl = isMainPost
? 'https://www.linkedin.com/feed/update/'
: 'https://www.linkedin.com/embed/feed/update/';
return {
status: 'posted',
postId,
id: originalPostId,
releaseURL: `${baseUrl}${postId}`,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails<LinkedinDto>[],
integration: Integration,
type = 'personal' as 'company' | 'personal'
): Promise<PostResponse[]> {
let processedPostDetails = postDetails;
const [firstPost] = postDetails;
// Check if we should convert images to PDF carousel
if (firstPost.settings?.post_as_images_carousel) {
processedPostDetails = await this.convertImagesToPdfCarousel(
postDetails,
firstPost
);
}
const [processedFirstPost, ...restPosts] = processedPostDetails;
// Process and upload media for all posts
const uploadedMedia = await this.processMediaForPosts(
processedPostDetails,
accessToken,
id,
type
);
// Get media IDs for the main post
const mainPostMediaIds = (
uploadedMedia[processedFirstPost.id] || []
).filter(Boolean);
// Create the main LinkedIn post
const mainPostId = await this.createMainPost(
id,
accessToken,
processedFirstPost,
mainPostMediaIds,
type,
!!firstPost.settings?.post_as_images_carousel
);
// Build response array starting with main post
const responses: PostResponse[] = [
this.createPostResponse(mainPostId, processedFirstPost.id, true),
];
// Create comment posts for remaining posts
for (const post of restPosts) {
const commentPostId = await this.createCommentPost(
id,
accessToken,
post,
mainPostId,
type
);
responses.push(this.createPostResponse(commentPostId, post.id, false));
}
return responses;
}
@PostPlug({
identifier: 'linkedin-repost-post-users',
title: 'Add Re-posters',
description: 'Add accounts to repost your post',
pickIntegration: ['linkedin', 'linkedin-page'],
fields: [],
})
async repostPostUsers(
integration: Integration,
originalIntegration: Integration,
postId: string,
information: any,
isPersonal = true
) {
await this.fetch(`https://api.linkedin.com/rest/posts`, {
body: JSON.stringify({
author:
(isPersonal ? 'urn:li:person:' : `urn:li:organization:`) +
`${integration.internalId}`,
commentary: '',
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
reshareContext: {
parent: postId,
},
}),
method: 'POST',
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202504',
Authorization: `Bearer ${integration.token}`,
},
});
}
override async mention(token: string, data: { query: string }) {
const { elements } = await (
await this.fetch(
`https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${encodeURIComponent(
data.query
)}&projection=(elements*(id,localizedName,logoV2(original~:playableStreams)))`,
{
headers: {
'X-Restli-Protocol-Version': '2.0.0',
'Content-Type': 'application/json',
'LinkedIn-Version': '202504',
Authorization: `Bearer ${token}`,
},
}
)
).json();
return elements.map((p: any) => ({
id: String(p.id),
label: p.localizedName,
image: p.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0]?.identifier || '',
}));
}
mentionFormat(idOrHandle: string, name: string) {
return `@[${name.replace('@', '')}](urn:li:organization:${idOrHandle})`;
}
}