@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/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +50 -0
- package/src/auth-server.js +223 -0
- package/src/database.js +288 -0
- package/src/index.js +654 -0
- package/src/linkedin-api.js +631 -0
- package/src/scheduler.js +201 -0
- package/src/schemas.js +629 -0
- package/src/tools.js +853 -0
- package/src/types.js +575 -0
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
|
+
};
|