feat: linkedin post as carousel

This commit is contained in:
Nevo David 2025-06-08 15:52:00 +07:00
parent cdbf173ca9
commit 40f71c0bf3
3 changed files with 335 additions and 200 deletions

View File

@ -1,38 +0,0 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": true,
"dynamicImport": true
},
"target": "es2020",
"baseUrl": "/Users/nevodavid/Projects/gitroom",
"paths": {
"@gitroom/backend/*": ["apps/backend/src/*"],
"@gitroom/cron/*": ["apps/cron/src/*"],
"@gitroom/frontend/*": ["apps/frontend/src/*"],
"@gitroom/helpers/*": ["libraries/helpers/src/*"],
"@gitroom/nestjs-libraries/*": ["libraries/nestjs-libraries/src/*"],
"@gitroom/react/*": ["libraries/react-shared-libraries/src/*"],
"@gitroom/plugins/*": ["libraries/plugins/src/*"],
"@gitroom/workers/*": ["apps/workers/src/*"],
"@gitroom/extension/*": ["apps/extension/src/*"]
},
"keepClassNames": true,
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"loose": true
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
},
"sourceMaps": true,
"minify": false
}

View File

@ -1,38 +0,0 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": true,
"dynamicImport": true
},
"target": "es2020",
"baseUrl": "/Users/nevodavid/Projects/gitroom",
"paths": {
"@gitroom/backend/*": ["apps/backend/src/*"],
"@gitroom/cron/*": ["apps/cron/src/*"],
"@gitroom/frontend/*": ["apps/frontend/src/*"],
"@gitroom/helpers/*": ["libraries/helpers/src/*"],
"@gitroom/nestjs-libraries/*": ["libraries/nestjs-libraries/src/*"],
"@gitroom/react/*": ["libraries/react-shared-libraries/src/*"],
"@gitroom/plugins/*": ["libraries/plugins/src/*"],
"@gitroom/workers/*": ["apps/workers/src/*"],
"@gitroom/extension/*": ["apps/extension/src/*"]
},
"keepClassNames": true,
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"loose": true
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
},
"sourceMaps": true,
"minify": false
}

View File

@ -12,6 +12,8 @@ import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.ab
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';
@ -200,13 +202,24 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
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, uploadInstructions, ...all },
value: { uploadUrl, image, video, document, uploadInstructions, ...all },
} = await (
await this.fetch(
`https://api.linkedin.com/v2/${
fileName.indexOf('mp4') > -1 ? 'videos' : 'images'
}?action=initializeUpload`,
`https://api.linkedin.com/rest/${endpoint}?action=initializeUpload`,
{
method: 'POST',
headers: {
@ -221,7 +234,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
type === 'personal'
? `urn:li:person:${personId}`
: `urn:li:organization:${personId}`,
...(fileName.indexOf('mp4') > -1
...(isVideo
? {
fileSizeBytes: picture.length,
uploadCaptions: false,
@ -235,7 +248,7 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
).json();
const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl;
const finalOutput = video || image;
const finalOutput = video || image || document;
const etags = [];
for (let i = 0; i < picture.length; i += 1024 * 1024 * 2) {
@ -245,8 +258,10 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': '202501',
Authorization: `Bearer ${accessToken}`,
...(fileName.indexOf('mp4') > -1
...(isVideo
? { 'Content-Type': 'application/octet-stream' }
: isPdf
? { 'Content-Type': 'application/pdf' }
: {}),
},
body: picture.slice(i, i + 1024 * 1024 * 2),
@ -255,9 +270,9 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
etags.push(upload.headers.get('etag'));
}
if (fileName.indexOf('mp4') > -1) {
if (isVideo) {
const a = await this.fetch(
'https://api.linkedin.com/v2/videos?action=finalizeUpload',
'https://api.linkedin.com/rest/videos?action=finalizeUpload',
{
method: 'POST',
body: JSON.stringify({
@ -315,147 +330,343 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
return connectAll.join('');
}
async post<LinkedinDto>(
id: string,
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.url.toLowerCase().includes('.jpg') ||
media.url.toLowerCase().includes('.jpeg') ||
media.url.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.url);
const image = sharp(buffer, {
animated: lookup(media.url) === '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 = {
url: '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,
postDetails: PostDetails[],
integration: Integration,
type = 'personal' as 'company' | 'personal'
): Promise<PostResponse[]> {
const [firstPost, ...restPosts] = postDetails;
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 ('buffer' in media && Buffer.isBuffer((media as any).buffer)) {
mediaBuffer = (media as any).buffer;
} else {
mediaBuffer = await this.prepareMediaBuffer(media.url);
}
const uploadedMediaId = await this.uploadPicture(
media.url,
accessToken,
personId,
mediaBuffer,
type
);
const uploadAll = (
await Promise.all(
postDetails.flatMap((p) =>
p?.media?.flatMap(async (m) => {
return {
id: await this.uploadPicture(
m.url,
accessToken,
id,
m.url.indexOf('mp4') > -1
? Buffer.from(await readOrFetch(m.url))
: await sharp(await readOrFetch(m.url), {
animated: lookup(m.url) === 'image/gif',
})
.toFormat('jpeg')
.resize({
width: 1000,
})
.toBuffer(),
type
),
postId: p.id,
id: uploadedMediaId,
postId: post.id,
};
})
)
}) || []
)
).reduce((acc, val) => {
if (!val?.id) {
return acc;
}
acc[val.postId] = acc[val.postId] || [];
acc[val.postId].push(val.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[]>);
}
const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f);
private async prepareMediaBuffer(mediaUrl: string): Promise<Buffer> {
const isVideo = mediaUrl.indexOf('mp4') > -1;
const data = await this.fetch('https://api.linkedin.com/v2/posts', {
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',
idPdf: boolean
): Promise<string> {
const postPayload = this.createLinkedInPostPayload(
id,
type,
firstPost.message,
mediaIds,
idPdf,
);
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({
author:
type === 'personal'
? `urn:li:person:${id}`
: `urn:li:organization:${id}`,
commentary: this.fixText(firstPost.message),
visibility: 'PUBLIC',
distribution: {
feedDistribution: 'MAIN_FEED',
targetEntities: [],
thirdPartyDistributionChannels: [],
},
...(media_ids.length > 0
? {
content: {
...(media_ids.length === 0
? {}
: media_ids.length === 1
? {
media: {
id: media_ids[0],
},
}
: {
multiImage: {
images: media_ids.map((id) => ({
id,
})),
},
}),
},
}
: {}),
lifecycleState: 'PUBLISHED',
isReshareDisabledByAuthor: false,
}),
body: JSON.stringify(postPayload),
});
if (data.status !== 201 && data.status !== 200) {
console.log('LinkedIn post response:', response);
if (response.status !== 201 && response.status !== 200) {
throw new Error('Error posting to LinkedIn');
}
const topPostId = data.headers.get('x-restli-id')!;
return response.headers.get('x-restli-id')!;
}
const ids = [
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`,
{
status: 'posted',
postId: topPostId,
id: firstPost.id,
releaseURL: `https://www.linkedin.com/feed/update/${topPostId}`,
},
];
for (const post of restPosts) {
const { object } = await (
await this.fetch(
`https://api.linkedin.com/v2/socialActions/${decodeURIComponent(
topPostId
)}/comments`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
actor:
type === 'personal'
? `urn:li:person:${id}`
: `urn:li:organization:${id}`,
object: topPostId,
message: {
text: this.fixText(post.message),
},
}),
}
)
).json();
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
actor,
object: parentPostId,
message: {
text: this.fixText(post.message),
},
}),
}
);
ids.push({
status: 'posted',
postId: object,
id: post.id,
releaseURL: `https://www.linkedin.com/embed/feed/update/${object}`,
});
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
);
}
return ids;
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({