@ldraney/mcp-linkedin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.js ADDED
@@ -0,0 +1,853 @@
1
+ /**
2
+ * @file LinkedIn MCP Tools Implementation
3
+ * All 20 tools for LinkedIn posting, scheduling, and authentication
4
+ */
5
+
6
+ require('dotenv').config();
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const LinkedInAPI = require('./linkedin-api');
11
+ const schemas = require('./schemas');
12
+ const { getDatabase } = require('./database');
13
+
14
+ /**
15
+ * Get LinkedIn API client instance
16
+ * @returns {LinkedInAPI}
17
+ */
18
+ function getAPIClient() {
19
+ return new LinkedInAPI({
20
+ accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
21
+ apiVersion: process.env.LINKEDIN_API_VERSION,
22
+ personId: process.env.LINKEDIN_PERSON_ID
23
+ });
24
+ }
25
+
26
+ /**
27
+ * Create a simple text post on LinkedIn
28
+ * @param {import('./types').CreatePostInput} input
29
+ * @returns {Promise<import('./types').CreatePostOutput>}
30
+ */
31
+ async function linkedin_create_post(input) {
32
+ // Validate input
33
+ const validated = schemas.CreatePostInputSchema.parse(input);
34
+
35
+ const api = getAPIClient();
36
+
37
+ const postData = {
38
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
39
+ commentary: validated.commentary,
40
+ visibility: validated.visibility,
41
+ distribution: {
42
+ feedDistribution: 'MAIN_FEED'
43
+ },
44
+ lifecycleState: 'PUBLISHED'
45
+ };
46
+
47
+ const result = await api.createPost(postData);
48
+
49
+ return {
50
+ postUrn: result.postUrn,
51
+ message: 'Post created successfully',
52
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Create a post with a link (article preview)
58
+ * @param {import('./types').CreatePostWithLinkInput} input
59
+ * @returns {Promise<import('./types').CreatePostOutput>}
60
+ */
61
+ async function linkedin_create_post_with_link(input) {
62
+ // Validate input
63
+ const validated = schemas.CreatePostWithLinkInputSchema.parse(input);
64
+
65
+ const api = getAPIClient();
66
+
67
+ const postData = {
68
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
69
+ commentary: validated.commentary,
70
+ visibility: validated.visibility,
71
+ distribution: {
72
+ feedDistribution: 'MAIN_FEED'
73
+ },
74
+ content: {
75
+ article: {
76
+ source: validated.url,
77
+ ...(validated.title && { title: validated.title }),
78
+ ...(validated.description && { description: validated.description })
79
+ }
80
+ },
81
+ lifecycleState: 'PUBLISHED'
82
+ };
83
+
84
+ const result = await api.createPost(postData);
85
+
86
+ return {
87
+ postUrn: result.postUrn,
88
+ message: 'Post with link created successfully',
89
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Retrieve user's recent posts
95
+ * @param {import('./types').GetPostsInput} input
96
+ * @returns {Promise<import('./types').GetPostsOutput>}
97
+ */
98
+ async function linkedin_get_my_posts(input = {}) {
99
+ // Validate input with defaults
100
+ const validated = schemas.GetPostsInputSchema.parse(input);
101
+
102
+ const api = getAPIClient();
103
+ const result = await api.getPosts({
104
+ limit: validated.limit,
105
+ offset: validated.offset
106
+ });
107
+
108
+ // Transform API response to our output format
109
+ const posts = result.elements || [];
110
+ const total = result.paging?.total || 0;
111
+
112
+ return {
113
+ posts: posts.map(post => ({
114
+ id: post.id,
115
+ author: post.author,
116
+ commentary: post.commentary || '',
117
+ visibility: post.visibility,
118
+ createdAt: post.created?.time ? new Date(post.created.time).toISOString() : new Date().toISOString(),
119
+ lifecycleState: post.lifecycleState || 'PUBLISHED'
120
+ })),
121
+ count: posts.length,
122
+ offset: validated.offset,
123
+ hasMore: validated.offset + posts.length < total
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Delete a LinkedIn post
129
+ * @param {import('./types').DeletePostInput} input
130
+ * @returns {Promise<import('./types').DeletePostOutput>}
131
+ */
132
+ async function linkedin_delete_post(input) {
133
+ // Validate input
134
+ const validated = schemas.DeletePostInputSchema.parse(input);
135
+
136
+ const api = getAPIClient();
137
+ await api.deletePost(validated.postUrn);
138
+
139
+ return {
140
+ postUrn: validated.postUrn,
141
+ message: 'Post deleted successfully',
142
+ success: true
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Get OAuth authorization URL for user to visit
148
+ * @returns {Promise<import('./types').GetAuthUrlOutput>}
149
+ */
150
+ async function linkedin_get_auth_url() {
151
+ const state = crypto.randomBytes(16).toString('hex');
152
+
153
+ const params = new URLSearchParams({
154
+ response_type: 'code',
155
+ client_id: process.env.LINKEDIN_CLIENT_ID,
156
+ redirect_uri: process.env.LINKEDIN_REDIRECT_URI,
157
+ scope: 'openid profile email w_member_social',
158
+ state
159
+ });
160
+
161
+ const authUrl = `https://www.linkedin.com/oauth/v2/authorization?${params.toString()}`;
162
+
163
+ return {
164
+ authUrl,
165
+ state,
166
+ instructions: 'Visit this URL in your browser, authorize the app, then copy the authorization code from the callback URL'
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Exchange authorization code for access token
172
+ * @param {import('./types').ExchangeCodeInput} input
173
+ * @returns {Promise<import('./types').ExchangeCodeOutput>}
174
+ */
175
+ async function linkedin_exchange_code(input) {
176
+ // Validate input
177
+ const validated = schemas.ExchangeCodeInputSchema.parse(input);
178
+
179
+ const tokenResponse = await LinkedInAPI.exchangeAuthCode({
180
+ code: validated.authorizationCode,
181
+ clientId: process.env.LINKEDIN_CLIENT_ID,
182
+ clientSecret: process.env.LINKEDIN_CLIENT_SECRET,
183
+ redirectUri: process.env.LINKEDIN_REDIRECT_URI
184
+ });
185
+
186
+ // Get user info to extract person ID
187
+ const tempAPI = new LinkedInAPI({
188
+ accessToken: tokenResponse.access_token,
189
+ apiVersion: process.env.LINKEDIN_API_VERSION,
190
+ personId: 'temp' // Will be replaced
191
+ });
192
+
193
+ const userInfo = await tempAPI.getUserInfo();
194
+ const personUrn = `urn:li:person:${userInfo.sub}`;
195
+
196
+ return {
197
+ accessToken: tokenResponse.access_token,
198
+ expiresIn: tokenResponse.expires_in,
199
+ personUrn,
200
+ message: `Success! Save these to your .env file:\nLINKEDIN_ACCESS_TOKEN=${tokenResponse.access_token}\nLINKEDIN_PERSON_ID=${userInfo.sub}`
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Get current user's profile information
206
+ * @returns {Promise<import('./types').GetUserInfoOutput>}
207
+ */
208
+ async function linkedin_get_user_info() {
209
+ const api = getAPIClient();
210
+ const userInfo = await api.getUserInfo();
211
+
212
+ return {
213
+ personUrn: `urn:li:person:${userInfo.sub}`,
214
+ name: userInfo.name,
215
+ email: userInfo.email,
216
+ pictureUrl: userInfo.picture
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Update an existing LinkedIn post
222
+ * @param {import('./types').UpdatePostInput} input
223
+ * @returns {Promise<import('./types').UpdatePostOutput>}
224
+ */
225
+ async function linkedin_update_post(input) {
226
+ // Validate input
227
+ const validated = schemas.UpdatePostInputSchema.parse(input);
228
+
229
+ const api = getAPIClient();
230
+
231
+ const updateData = {};
232
+ if (validated.commentary) {
233
+ updateData.commentary = validated.commentary;
234
+ }
235
+ if (validated.contentCallToActionLabel) {
236
+ updateData.contentCallToActionLabel = validated.contentCallToActionLabel;
237
+ }
238
+ if (validated.contentLandingPage) {
239
+ updateData.contentLandingPage = validated.contentLandingPage;
240
+ }
241
+
242
+ await api.updatePost(validated.postUrn, updateData);
243
+
244
+ return {
245
+ postUrn: validated.postUrn,
246
+ message: 'Post updated successfully',
247
+ success: true
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Get MIME type from file extension
253
+ * @param {string} filePath - Path to the file
254
+ * @returns {string} MIME type
255
+ */
256
+ function getMimeType(filePath) {
257
+ const ext = path.extname(filePath).toLowerCase();
258
+ const mimeTypes = {
259
+ '.png': 'image/png',
260
+ '.jpg': 'image/jpeg',
261
+ '.jpeg': 'image/jpeg',
262
+ '.gif': 'image/gif'
263
+ };
264
+ return mimeTypes[ext] || 'application/octet-stream';
265
+ }
266
+
267
+ /**
268
+ * Get MIME type for documents
269
+ * @param {string} filePath - Path to the document
270
+ * @returns {string} MIME type
271
+ */
272
+ function getDocumentMimeType(filePath) {
273
+ const ext = path.extname(filePath).toLowerCase();
274
+ const mimeTypes = {
275
+ '.pdf': 'application/pdf',
276
+ '.doc': 'application/msword',
277
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
278
+ '.ppt': 'application/vnd.ms-powerpoint',
279
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
280
+ };
281
+ return mimeTypes[ext] || 'application/octet-stream';
282
+ }
283
+
284
+ /**
285
+ * Get MIME type for videos
286
+ * @param {string} filePath - Path to the video
287
+ * @returns {string} MIME type
288
+ */
289
+ function getVideoMimeType(filePath) {
290
+ const ext = path.extname(filePath).toLowerCase();
291
+ const mimeTypes = {
292
+ '.mp4': 'video/mp4',
293
+ '.mov': 'video/quicktime',
294
+ '.avi': 'video/x-msvideo',
295
+ '.wmv': 'video/x-ms-wmv',
296
+ '.webm': 'video/webm',
297
+ '.mkv': 'video/x-matroska',
298
+ '.m4v': 'video/x-m4v',
299
+ '.flv': 'video/x-flv'
300
+ };
301
+ return mimeTypes[ext] || 'video/mp4';
302
+ }
303
+
304
+ /**
305
+ * Validate video file extension
306
+ * @param {string} filePath - Path to the video
307
+ * @returns {boolean} True if valid extension
308
+ */
309
+ function isValidVideoType(filePath) {
310
+ const ext = path.extname(filePath).toLowerCase();
311
+ const validExtensions = ['.mp4', '.mov', '.avi', '.wmv', '.webm', '.mkv', '.m4v', '.flv'];
312
+ return validExtensions.includes(ext);
313
+ }
314
+
315
+ /**
316
+ * Validate image file extension
317
+ * @param {string} filePath - Path to the image
318
+ * @returns {boolean} True if valid extension
319
+ */
320
+ function isValidImageType(filePath) {
321
+ const ext = path.extname(filePath).toLowerCase();
322
+ const validExtensions = ['.png', '.jpg', '.jpeg', '.gif'];
323
+ return validExtensions.includes(ext);
324
+ }
325
+
326
+ /**
327
+ * Validate document file extension
328
+ * @param {string} filePath - Path to the document
329
+ * @returns {boolean} True if valid extension
330
+ */
331
+ function isValidDocumentType(filePath) {
332
+ const ext = path.extname(filePath).toLowerCase();
333
+ const validExtensions = ['.pdf', '.doc', '.docx', '.ppt', '.pptx'];
334
+ return validExtensions.includes(ext);
335
+ }
336
+
337
+ /**
338
+ * Create a post with an uploaded image
339
+ * @param {import('./types').CreatePostWithImageInput} input
340
+ * @returns {Promise<import('./types').CreatePostWithImageOutput>}
341
+ */
342
+ async function linkedin_create_post_with_image(input) {
343
+ // Validate input
344
+ const validated = schemas.CreatePostWithImageInputSchema.parse(input);
345
+
346
+ // Verify file exists and is readable
347
+ if (!fs.existsSync(validated.imagePath)) {
348
+ throw new Error(`Image file not found: ${validated.imagePath}`);
349
+ }
350
+
351
+ const api = getAPIClient();
352
+
353
+ // Step 1: Initialize upload to get upload URL
354
+ const { uploadUrl, imageUrn } = await api.initializeImageUpload();
355
+
356
+ // Step 2: Read image and upload binary
357
+ const imageBuffer = fs.readFileSync(validated.imagePath);
358
+ const contentType = getMimeType(validated.imagePath);
359
+ await api.uploadImageBinary(uploadUrl, imageBuffer, contentType);
360
+
361
+ // Step 3: Create post with the uploaded image
362
+ const postData = {
363
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
364
+ commentary: validated.commentary,
365
+ visibility: validated.visibility,
366
+ distribution: {
367
+ feedDistribution: 'MAIN_FEED'
368
+ },
369
+ content: {
370
+ media: {
371
+ id: imageUrn,
372
+ ...(validated.altText && { altText: validated.altText })
373
+ }
374
+ },
375
+ lifecycleState: 'PUBLISHED'
376
+ };
377
+
378
+ const result = await api.createPost(postData);
379
+
380
+ return {
381
+ postUrn: result.postUrn,
382
+ imageUrn: imageUrn,
383
+ message: 'Post with image created successfully',
384
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Refresh an expired access token using a refresh token
390
+ * @param {Object} input
391
+ * @param {string} input.refreshToken - The refresh token
392
+ * @returns {Promise<import('./types').RefreshTokenOutput>}
393
+ */
394
+ async function linkedin_refresh_token(input) {
395
+ const { refreshToken } = input;
396
+
397
+ if (!refreshToken) {
398
+ throw new Error('Refresh token is required');
399
+ }
400
+
401
+ const tokenResponse = await LinkedInAPI.refreshAccessToken({
402
+ refreshToken,
403
+ clientId: process.env.LINKEDIN_CLIENT_ID,
404
+ clientSecret: process.env.LINKEDIN_CLIENT_SECRET
405
+ });
406
+
407
+ return {
408
+ accessToken: tokenResponse.access_token,
409
+ expiresIn: tokenResponse.expires_in,
410
+ message: `Token refreshed! Expires in ${Math.floor(tokenResponse.expires_in / 86400)} days.\nUpdate your .env:\nLINKEDIN_ACCESS_TOKEN=${tokenResponse.access_token}`
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Add a comment to a LinkedIn post
416
+ * @param {import('./types').AddCommentInput} input
417
+ * @returns {Promise<import('./types').AddCommentOutput>}
418
+ */
419
+ async function linkedin_add_comment(input) {
420
+ // Validate input
421
+ const validated = schemas.AddCommentInputSchema.parse(input);
422
+
423
+ const api = getAPIClient();
424
+ const result = await api.addComment(validated.postUrn, validated.text);
425
+
426
+ return {
427
+ commentUrn: result.commentUrn,
428
+ postUrn: validated.postUrn,
429
+ message: 'Comment added successfully',
430
+ success: true
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Add a reaction to a LinkedIn post
436
+ * @param {import('./types').AddReactionInput} input
437
+ * @returns {Promise<import('./types').AddReactionOutput>}
438
+ */
439
+ async function linkedin_add_reaction(input) {
440
+ // Validate input
441
+ const validated = schemas.AddReactionInputSchema.parse(input);
442
+
443
+ const api = getAPIClient();
444
+ await api.addReaction(validated.postUrn, validated.reactionType);
445
+
446
+ return {
447
+ postUrn: validated.postUrn,
448
+ reactionType: validated.reactionType,
449
+ message: `Reaction ${validated.reactionType} added successfully`,
450
+ success: true
451
+ };
452
+ }
453
+
454
+ // ============================================================================
455
+ // Scheduling Tools
456
+ // ============================================================================
457
+
458
+ /**
459
+ * Schedule a LinkedIn post for future publication
460
+ * @param {import('./types').SchedulePostInput} input
461
+ * @returns {Promise<import('./types').SchedulePostOutput>}
462
+ */
463
+ async function linkedin_schedule_post(input) {
464
+ // Validate input (includes future time check)
465
+ const validated = schemas.SchedulePostInputSchema.parse(input);
466
+
467
+ const db = getDatabase();
468
+
469
+ const scheduledPost = db.addScheduledPost({
470
+ commentary: validated.commentary,
471
+ scheduledTime: validated.scheduledTime,
472
+ url: validated.url || null,
473
+ visibility: validated.visibility
474
+ });
475
+
476
+ const scheduledDate = new Date(validated.scheduledTime);
477
+ const formattedTime = scheduledDate.toLocaleString('en-US', {
478
+ weekday: 'short',
479
+ month: 'short',
480
+ day: 'numeric',
481
+ hour: 'numeric',
482
+ minute: '2-digit',
483
+ timeZoneName: 'short'
484
+ });
485
+
486
+ return {
487
+ postId: scheduledPost.id,
488
+ scheduledTime: scheduledPost.scheduledTime,
489
+ status: scheduledPost.status,
490
+ message: `Post scheduled for ${formattedTime}`
491
+ };
492
+ }
493
+
494
+ /**
495
+ * List scheduled posts, optionally filtered by status
496
+ * @param {import('./types').ListScheduledPostsInput} input
497
+ * @returns {Promise<import('./types').ListScheduledPostsOutput>}
498
+ */
499
+ async function linkedin_list_scheduled_posts(input = {}) {
500
+ // Validate input with defaults
501
+ const validated = schemas.ListScheduledPostsInputSchema.parse(input);
502
+
503
+ const db = getDatabase();
504
+ const posts = db.getScheduledPosts(validated.status || null, validated.limit);
505
+
506
+ const statusMsg = validated.status
507
+ ? `${validated.status} posts`
508
+ : 'all scheduled posts';
509
+
510
+ return {
511
+ posts,
512
+ count: posts.length,
513
+ message: `Found ${posts.length} ${statusMsg}`
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Cancel a scheduled post (must be pending)
519
+ * @param {import('./types').CancelScheduledPostInput} input
520
+ * @returns {Promise<import('./types').CancelScheduledPostOutput>}
521
+ */
522
+ async function linkedin_cancel_scheduled_post(input) {
523
+ // Validate input
524
+ const validated = schemas.CancelScheduledPostInputSchema.parse(input);
525
+
526
+ const db = getDatabase();
527
+ const cancelledPost = db.cancelPost(validated.postId);
528
+
529
+ if (!cancelledPost) {
530
+ throw new Error(`Post not found or not in pending status: ${validated.postId}`);
531
+ }
532
+
533
+ return {
534
+ postId: cancelledPost.id,
535
+ status: 'cancelled',
536
+ message: 'Scheduled post cancelled successfully',
537
+ success: true
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Create a LinkedIn poll post
543
+ * @param {import('./types').CreatePollInput} input
544
+ * @returns {Promise<import('./types').CreatePollOutput>}
545
+ */
546
+ async function linkedin_create_poll(input) {
547
+ // Validate input
548
+ const validated = schemas.CreatePollInputSchema.parse(input);
549
+
550
+ const api = getAPIClient();
551
+
552
+ const postData = {
553
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
554
+ commentary: validated.commentary || '',
555
+ visibility: validated.visibility,
556
+ distribution: {
557
+ feedDistribution: 'MAIN_FEED'
558
+ },
559
+ lifecycleState: 'PUBLISHED',
560
+ content: {
561
+ poll: {
562
+ question: validated.question,
563
+ options: validated.options.map(opt => ({ text: opt.text })),
564
+ settings: {
565
+ duration: validated.duration,
566
+ voteSelectionType: 'SINGLE_VOTE',
567
+ isVoterVisibleToAuthor: true
568
+ }
569
+ }
570
+ }
571
+ };
572
+
573
+ const result = await api.createPost(postData);
574
+
575
+ return {
576
+ postUrn: result.postUrn,
577
+ message: 'Poll created successfully',
578
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`,
579
+ pollQuestion: validated.question,
580
+ optionCount: validated.options.length,
581
+ duration: validated.duration
582
+ };
583
+ }
584
+
585
+ /**
586
+ * Create a LinkedIn post with an uploaded document
587
+ * @param {import('./types').CreatePostWithDocumentInput} input
588
+ * @returns {Promise<import('./types').CreatePostWithDocumentOutput>}
589
+ */
590
+ async function linkedin_create_post_with_document(input) {
591
+ // Validate input
592
+ const validated = schemas.CreatePostWithDocumentInputSchema.parse(input);
593
+
594
+ // Verify file exists and is readable
595
+ if (!fs.existsSync(validated.documentPath)) {
596
+ throw new Error(`Document file not found: ${validated.documentPath}`);
597
+ }
598
+
599
+ // Validate file type
600
+ if (!isValidDocumentType(validated.documentPath)) {
601
+ throw new Error('Invalid document type. Supported formats: PDF, DOC, DOCX, PPT, PPTX');
602
+ }
603
+
604
+ // Check file size (max 100 MB)
605
+ const stats = fs.statSync(validated.documentPath);
606
+ const maxSize = 100 * 1024 * 1024; // 100 MB
607
+ if (stats.size > maxSize) {
608
+ throw new Error('Document file size exceeds 100 MB limit');
609
+ }
610
+
611
+ const api = getAPIClient();
612
+
613
+ // Step 1: Initialize upload to get upload URL
614
+ const { uploadUrl, documentUrn } = await api.initializeDocumentUpload();
615
+
616
+ // Step 2: Read document and upload binary
617
+ const documentBuffer = fs.readFileSync(validated.documentPath);
618
+ const contentType = getDocumentMimeType(validated.documentPath);
619
+ await api.uploadDocumentBinary(uploadUrl, documentBuffer, contentType);
620
+
621
+ // Step 3: Create post with the uploaded document
622
+ const documentTitle = validated.title || path.basename(validated.documentPath);
623
+
624
+ const postData = {
625
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
626
+ commentary: validated.commentary,
627
+ visibility: validated.visibility,
628
+ distribution: {
629
+ feedDistribution: 'MAIN_FEED'
630
+ },
631
+ content: {
632
+ media: {
633
+ id: documentUrn,
634
+ title: documentTitle
635
+ }
636
+ },
637
+ lifecycleState: 'PUBLISHED'
638
+ };
639
+
640
+ const result = await api.createPost(postData);
641
+
642
+ return {
643
+ postUrn: result.postUrn,
644
+ documentUrn: documentUrn,
645
+ message: 'Post with document created successfully',
646
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`
647
+ };
648
+ }
649
+
650
+ /**
651
+ * Get details of a single scheduled post
652
+ * @param {import('./types').GetScheduledPostInput} input
653
+ * @returns {Promise<import('./types').GetScheduledPostOutput>}
654
+ */
655
+ async function linkedin_get_scheduled_post(input) {
656
+ // Validate input
657
+ const validated = schemas.GetScheduledPostInputSchema.parse(input);
658
+
659
+ const db = getDatabase();
660
+ const post = db.getScheduledPost(validated.postId);
661
+
662
+ if (!post) {
663
+ throw new Error(`Scheduled post not found: ${validated.postId}`);
664
+ }
665
+
666
+ let statusMessage;
667
+ switch (post.status) {
668
+ case 'pending':
669
+ statusMessage = `Scheduled for ${new Date(post.scheduledTime).toLocaleString()}`;
670
+ break;
671
+ case 'published':
672
+ statusMessage = `Published at ${new Date(post.publishedAt).toLocaleString()}`;
673
+ break;
674
+ case 'failed':
675
+ statusMessage = `Failed: ${post.errorMessage} (${post.retryCount} attempts)`;
676
+ break;
677
+ case 'cancelled':
678
+ statusMessage = 'Cancelled';
679
+ break;
680
+ default:
681
+ statusMessage = `Status: ${post.status}`;
682
+ }
683
+
684
+ return {
685
+ post,
686
+ message: statusMessage
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Create a LinkedIn post with an uploaded video
692
+ * @param {import('./types').CreatePostWithVideoInput} input
693
+ * @returns {Promise<import('./types').CreatePostWithVideoOutput>}
694
+ */
695
+ async function linkedin_create_post_with_video(input) {
696
+ // Validate input
697
+ const validated = schemas.CreatePostWithVideoInputSchema.parse(input);
698
+
699
+ // Verify file exists and is readable
700
+ if (!fs.existsSync(validated.videoPath)) {
701
+ throw new Error(`Video file not found: ${validated.videoPath}`);
702
+ }
703
+
704
+ // Validate file type
705
+ if (!isValidVideoType(validated.videoPath)) {
706
+ throw new Error('Invalid video type. Supported formats: MP4, MOV, AVI, WMV, WebM, MKV, M4V, FLV');
707
+ }
708
+
709
+ // Check file size (max 200 MB for personal accounts, 5 GB for company pages)
710
+ const stats = fs.statSync(validated.videoPath);
711
+ const maxSize = 200 * 1024 * 1024; // 200 MB
712
+ if (stats.size > maxSize) {
713
+ throw new Error('Video file size exceeds 200 MB limit for personal accounts');
714
+ }
715
+
716
+ const api = getAPIClient();
717
+
718
+ // Step 1: Initialize upload to get upload URL
719
+ const { uploadUrl, videoUrn } = await api.initializeVideoUpload(stats.size);
720
+
721
+ // Step 2: Read video and upload binary
722
+ const videoBuffer = fs.readFileSync(validated.videoPath);
723
+ const contentType = getVideoMimeType(validated.videoPath);
724
+ const { etag } = await api.uploadVideoBinary(uploadUrl, videoBuffer, contentType);
725
+
726
+ // Step 3: Finalize upload
727
+ await api.finalizeVideoUpload(videoUrn, uploadUrl, etag);
728
+
729
+ // Step 4: Create post with the uploaded video
730
+ const videoTitle = validated.title || path.basename(validated.videoPath);
731
+
732
+ const postData = {
733
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
734
+ commentary: validated.commentary,
735
+ visibility: validated.visibility,
736
+ distribution: {
737
+ feedDistribution: 'MAIN_FEED'
738
+ },
739
+ content: {
740
+ media: {
741
+ id: videoUrn,
742
+ title: videoTitle
743
+ }
744
+ },
745
+ lifecycleState: 'PUBLISHED'
746
+ };
747
+
748
+ const result = await api.createPost(postData);
749
+
750
+ return {
751
+ postUrn: result.postUrn,
752
+ videoUrn: videoUrn,
753
+ message: 'Post with video created successfully',
754
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`
755
+ };
756
+ }
757
+
758
+ /**
759
+ * Create a LinkedIn post with multiple images
760
+ * @param {import('./types').CreatePostWithMultiImagesInput} input
761
+ * @returns {Promise<import('./types').CreatePostWithMultiImagesOutput>}
762
+ */
763
+ async function linkedin_create_post_with_multi_images(input) {
764
+ // Validate input
765
+ const validated = schemas.CreatePostWithMultiImagesInputSchema.parse(input);
766
+
767
+ // Verify all files exist and are valid image types
768
+ for (const imagePath of validated.imagePaths) {
769
+ if (!fs.existsSync(imagePath)) {
770
+ throw new Error(`Image file not found: ${imagePath}`);
771
+ }
772
+ if (!isValidImageType(imagePath)) {
773
+ throw new Error(`Invalid image type: ${imagePath}. Supported formats: PNG, JPG, JPEG, GIF`);
774
+ }
775
+ }
776
+
777
+ const api = getAPIClient();
778
+
779
+ // Step 1: Initialize uploads for all images
780
+ const uploadInfos = await api.initializeMultiImageUpload(validated.imagePaths.length);
781
+
782
+ // Step 2: Upload all images
783
+ const imageUrns = [];
784
+ for (let i = 0; i < validated.imagePaths.length; i++) {
785
+ const imagePath = validated.imagePaths[i];
786
+ const { uploadUrl, imageUrn } = uploadInfos[i];
787
+
788
+ const imageBuffer = fs.readFileSync(imagePath);
789
+ const contentType = getMimeType(imagePath);
790
+ await api.uploadImageBinary(uploadUrl, imageBuffer, contentType);
791
+
792
+ imageUrns.push(imageUrn);
793
+ }
794
+
795
+ // Step 3: Build multi-image content
796
+ const images = imageUrns.map((urn, index) => {
797
+ const imageObj = { id: urn };
798
+ if (validated.altTexts && validated.altTexts[index]) {
799
+ imageObj.altText = validated.altTexts[index];
800
+ }
801
+ return imageObj;
802
+ });
803
+
804
+ // Step 4: Create post with multi-image content
805
+ const postData = {
806
+ author: `urn:li:person:${process.env.LINKEDIN_PERSON_ID}`,
807
+ commentary: validated.commentary,
808
+ visibility: validated.visibility,
809
+ distribution: {
810
+ feedDistribution: 'MAIN_FEED'
811
+ },
812
+ content: {
813
+ multiImage: {
814
+ images
815
+ }
816
+ },
817
+ lifecycleState: 'PUBLISHED'
818
+ };
819
+
820
+ const result = await api.createPost(postData);
821
+
822
+ return {
823
+ postUrn: result.postUrn,
824
+ imageUrns: imageUrns,
825
+ message: `Post with ${imageUrns.length} images created successfully`,
826
+ url: `https://www.linkedin.com/feed/update/${result.postUrn}`
827
+ };
828
+ }
829
+
830
+ module.exports = {
831
+ linkedin_create_post,
832
+ linkedin_create_post_with_link,
833
+ linkedin_get_my_posts,
834
+ linkedin_delete_post,
835
+ linkedin_get_auth_url,
836
+ linkedin_exchange_code,
837
+ linkedin_get_user_info,
838
+ linkedin_update_post,
839
+ linkedin_create_post_with_image,
840
+ linkedin_refresh_token,
841
+ linkedin_add_comment,
842
+ linkedin_add_reaction,
843
+ // Scheduling tools
844
+ linkedin_schedule_post,
845
+ linkedin_list_scheduled_posts,
846
+ linkedin_cancel_scheduled_post,
847
+ linkedin_get_scheduled_post,
848
+ // Rich media tools
849
+ linkedin_create_poll,
850
+ linkedin_create_post_with_document,
851
+ linkedin_create_post_with_video,
852
+ linkedin_create_post_with_multi_images
853
+ };