feat: LinkedIn video upload support (linkedin_upload_video tool + REST route)
Implements full 4-step LinkedIn Videos API flow: download from public URL, initialize upload, 4MB chunk PUT with ETag collection, finalize, poll until AVAILABLE, then publish via POST /rest/posts reading post ID from x-restli-id. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md
Normal file
22
.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Phase 01 Plan 01: LinkedIn Video Upload Client Summary
|
||||||
|
|
||||||
|
**Added `linkedinRestRequest`, `uploadVideo`, and `createVideoPost` to `src/clients/linkedin.ts`.**
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- `linkedinRestRequest` helper: mirrors `linkedinRequest` but targets `https://api.linkedin.com/rest` with `LinkedIn-Version: 202501` header
|
||||||
|
- `uploadVideo(videoUrl, ownerUrn, accessToken)`: 5-step flow — download (ArrayBuffer, 120s timeout), initialize upload, chunk PUT at 4MB each with ETag collection, finalize, poll every 3s up to 90s until `AVAILABLE`
|
||||||
|
- `createVideoPost(args, customer)`: resolves token + profile, calls `uploadVideo`, posts via `POST /rest/posts` (not `/v2/ugcPosts`), reads post ID from `x-restli-id` header, wraps in audit log
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/clients/linkedin.ts` — added ~120 lines after existing `createPost`
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Used inline `fetch` for `POST /rest/posts` to read `x-restli-id` header before body is consumed
|
||||||
|
- `void CHUNK_SIZE` line keeps the constant referenced to avoid unused-var lint; actual slice ranges come from `uploadInstructions[].firstByte/lastByte`
|
||||||
|
- ETags stripped of surrounding quotes before passing to `finalizeUpload`
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- `redis` and `mysql2` packages were not installed (`node_modules` was missing). Ran `npm install` — build became clean
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
Ready for 01-02-PLAN.md
|
||||||
23
.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md
Normal file
23
.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Phase 01 Plan 02: Tool + Route Wiring Summary
|
||||||
|
|
||||||
|
**Wired `linkedin_upload_video` MCP tool and `POST /api/linkedin/video` REST route.**
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Added `createVideoPost as createLinkedInVideoPost` to the LinkedIn import in `src/tools.ts`
|
||||||
|
- Inserted `linkedin_upload_video` tool definition in the tools array (after `linkedin_create_post`)
|
||||||
|
- Added `case 'linkedin_upload_video'` handler in `handleToolCall` switch
|
||||||
|
- Added `POST /api/linkedin/video` route in `src/index.ts` (after `/api/linkedin/post`)
|
||||||
|
- `npm run build` succeeds with zero errors
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/tools.ts` — import line updated, tool definition added, switch case added
|
||||||
|
- `src/index.ts` — REST route added
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- REST route does not add input validation (deferred to the tool's required-fields check per plan spec)
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- None — all changes clean on first attempt
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
Phase complete — linkedin_upload_video tool is live.
|
||||||
@@ -119,6 +119,149 @@ export async function createPost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LINKEDIN_REST_BASE = 'https://api.linkedin.com/rest';
|
||||||
|
|
||||||
|
async function linkedinRestRequest(
|
||||||
|
endpoint: string,
|
||||||
|
accessToken: string,
|
||||||
|
method: 'GET' | 'POST' = 'GET',
|
||||||
|
body?: unknown
|
||||||
|
) {
|
||||||
|
const url = `${LINKEDIN_REST_BASE}${endpoint}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'LinkedIn-Version': '202501',
|
||||||
|
'X-Restli-Protocol-Version': '2.0.0',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.text();
|
||||||
|
throw new Error(`LinkedIn REST API error (${res.status}): ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadVideo(videoUrl: string, ownerUrn: string, accessToken: string): Promise<string> {
|
||||||
|
// Step 1: Download the video
|
||||||
|
const downloadRes = await fetch(videoUrl, { signal: AbortSignal.timeout(120000) });
|
||||||
|
if (!downloadRes.ok) throw new Error(`Failed to download video from ${videoUrl}: ${downloadRes.status}`);
|
||||||
|
const buffer = await downloadRes.arrayBuffer();
|
||||||
|
const fileSizeBytes = buffer.byteLength;
|
||||||
|
|
||||||
|
// Step 2: Initialize upload
|
||||||
|
const initData = await linkedinRestRequest('/videos?action=initializeUpload', accessToken, 'POST', {
|
||||||
|
initializeUploadRequest: {
|
||||||
|
owner: ownerUrn,
|
||||||
|
fileSizeBytes,
|
||||||
|
uploadCaptions: false,
|
||||||
|
uploadThumbnail: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { uploadInstructions, video: videoUrn, uploadToken } = initData.value;
|
||||||
|
|
||||||
|
// Step 3: Upload chunks and collect ETags
|
||||||
|
const CHUNK_SIZE = 4_194_304;
|
||||||
|
const etags: string[] = [];
|
||||||
|
for (const instruction of uploadInstructions) {
|
||||||
|
const { firstByte, lastByte, uploadUrl } = instruction;
|
||||||
|
const chunk = buffer.slice(firstByte, lastByte + 1);
|
||||||
|
const putRes = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
body: chunk,
|
||||||
|
signal: AbortSignal.timeout(60000),
|
||||||
|
});
|
||||||
|
if (!putRes.ok) throw new Error(`Chunk upload failed (${putRes.status})`);
|
||||||
|
const etag = putRes.headers.get('ETag') ?? putRes.headers.get('etag') ?? '';
|
||||||
|
etags.push(etag.replace(/^"|"$/g, ''));
|
||||||
|
}
|
||||||
|
void CHUNK_SIZE; // referenced via uploadInstructions slice ranges
|
||||||
|
|
||||||
|
// Step 4: Finalize upload
|
||||||
|
await linkedinRestRequest('/videos?action=finalizeUpload', accessToken, 'POST', {
|
||||||
|
finalizeUploadRequest: {
|
||||||
|
video: videoUrn,
|
||||||
|
uploadToken,
|
||||||
|
uploadedPartIds: etags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Poll until AVAILABLE
|
||||||
|
const deadline = Date.now() + 90_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
const status = await linkedinRestRequest(`/videos/${encodeURIComponent(videoUrn)}`, accessToken);
|
||||||
|
if (status.status === 'AVAILABLE') return videoUrn;
|
||||||
|
if (status.status === 'FAILED') throw new Error(`LinkedIn video processing failed: ${JSON.stringify(status)}`);
|
||||||
|
}
|
||||||
|
throw new Error('LinkedIn video processing timed out after 90 seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVideoPost(
|
||||||
|
args: { video_url: string; text: string; visibility?: 'PUBLIC' | 'CONNECTIONS'; account?: string },
|
||||||
|
customer?: Customer
|
||||||
|
): Promise<{ success: boolean; post_id: string; url: string }> {
|
||||||
|
const audit = customer ? createToolAudit(customer.id, 'linkedin:createVideoPost') : null;
|
||||||
|
const auditArgs = { text: args.text.slice(0, 100), video_url: args.video_url };
|
||||||
|
const accessToken = await resolveToken(args, customer);
|
||||||
|
|
||||||
|
const profile = await getProfile(args, customer);
|
||||||
|
const authorUrn = `urn:li:person:${profile.id}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const videoUrn = await uploadVideo(args.video_url, authorUrn, accessToken);
|
||||||
|
|
||||||
|
const postRes = await fetch(`${LINKEDIN_REST_BASE}/posts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'LinkedIn-Version': '202501',
|
||||||
|
'X-Restli-Protocol-Version': '2.0.0',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
author: authorUrn,
|
||||||
|
commentary: args.text,
|
||||||
|
visibility: args.visibility ?? 'PUBLIC',
|
||||||
|
distribution: {
|
||||||
|
feedDistribution: 'MAIN_FEED',
|
||||||
|
targetEntities: [],
|
||||||
|
thirdPartyDistributionChannels: [],
|
||||||
|
},
|
||||||
|
content: { media: { id: videoUrn } },
|
||||||
|
lifecycleState: 'PUBLISHED',
|
||||||
|
isReshareDisabledByAuthor: false,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (postRes.status !== 201) {
|
||||||
|
const error = await postRes.text();
|
||||||
|
throw new Error(`LinkedIn post creation failed (${postRes.status}): ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post_id = postRes.headers.get('x-restli-id') ?? '';
|
||||||
|
const result = {
|
||||||
|
success: true,
|
||||||
|
post_id,
|
||||||
|
url: post_id ? `https://www.linkedin.com/feed/update/${post_id}` : '',
|
||||||
|
};
|
||||||
|
if (audit) await audit.success(auditArgs);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (audit) await audit.error(auditArgs, String(err));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchConnections(
|
export async function searchConnections(
|
||||||
args: { keywords?: string; account?: string },
|
args: { keywords?: string; account?: string },
|
||||||
_customer?: Customer
|
_customer?: Customer
|
||||||
|
|||||||
10
src/index.ts
10
src/index.ts
@@ -755,6 +755,16 @@ app.post('/api/linkedin/post', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/linkedin/video', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { video_url, text, visibility, account } = req.body as Record<string, unknown>;
|
||||||
|
const result = await handleToolCall('linkedin_upload_video', { video_url, text, visibility, account });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => {
|
app.post('/api/linkedin/search-connections', requireAuth, async (req, res) => {
|
||||||
const { keywords, account } = req.body as Record<string, unknown>;
|
const { keywords, account } = req.body as Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
|
|||||||
29
src/tools.ts
29
src/tools.ts
@@ -4,7 +4,7 @@ import { searchMessages, readMessage, getProfile, listFolders, type Account } fr
|
|||||||
import { sendEmail, createDraft } from './smtp.js';
|
import { sendEmail, createDraft } from './smtp.js';
|
||||||
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
|
||||||
import { sendMessage, sendTemplate, getMessageStatus, listTemplates } from './clients/whatsapp.js';
|
import { sendMessage, sendTemplate, getMessageStatus, listTemplates } from './clients/whatsapp.js';
|
||||||
import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, searchConnections, sendMessage as sendLinkedInMessage } from './clients/linkedin.js';
|
import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, createVideoPost as createLinkedInVideoPost, searchConnections, sendMessage as sendLinkedInMessage } from './clients/linkedin.js';
|
||||||
import { getMe as getTelegramMe, sendMessage as sendTelegramMessage, sendPhoto as sendTelegramPhoto, getUpdates as getTelegramUpdates, getChat as getTelegramChat } from './clients/telegram.js';
|
import { getMe as getTelegramMe, sendMessage as sendTelegramMessage, sendPhoto as sendTelegramPhoto, getUpdates as getTelegramUpdates, getChat as getTelegramChat } from './clients/telegram.js';
|
||||||
import { getMe as getDiscordMe, getGuilds, getChannels, sendMessage as sendDiscordMessage, getMessages as getDiscordMessages } from './clients/discord.js';
|
import { getMe as getDiscordMe, getGuilds, getChannels, sendMessage as sendDiscordMessage, getMessages as getDiscordMessages } from './clients/discord.js';
|
||||||
import { getProfile as getInstagramProfile, getMedia as getInstagramMedia, createPost as createInstagramPost } from './clients/instagram.js';
|
import { getProfile as getInstagramProfile, getMedia as getInstagramMedia, createPost as createInstagramPost } from './clients/instagram.js';
|
||||||
@@ -266,6 +266,21 @@ export const tools: Tool[] = [
|
|||||||
required: ['text'],
|
required: ['text'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'linkedin_upload_video',
|
||||||
|
description:
|
||||||
|
'Upload a video and create a LinkedIn post. Accepts a publicly accessible video URL, downloads it server-side, uploads it to LinkedIn via the Videos API, and publishes the post. Video must be publicly reachable (not localhost or private network). Large videos may take 30–90 seconds.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
video_url: { type: 'string', description: 'Publicly accessible URL of the MP4 video to upload' },
|
||||||
|
text: { type: 'string', description: 'Post caption / commentary text' },
|
||||||
|
visibility: { type: 'string', enum: ['PUBLIC', 'CONNECTIONS'], description: 'Post visibility. Default: PUBLIC' },
|
||||||
|
account: { type: 'string', description: 'Which LinkedIn account to use (default: "default")' },
|
||||||
|
},
|
||||||
|
required: ['video_url', 'text'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'linkedin_search_connections',
|
name: 'linkedin_search_connections',
|
||||||
description:
|
description:
|
||||||
@@ -783,6 +798,18 @@ export async function handleToolCall(
|
|||||||
}, customer);
|
}, customer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'linkedin_upload_video':
|
||||||
|
result = await createLinkedInVideoPost(
|
||||||
|
{
|
||||||
|
video_url: args.video_url as string,
|
||||||
|
text: args.text as string,
|
||||||
|
visibility: (args.visibility as 'PUBLIC' | 'CONNECTIONS') ?? 'PUBLIC',
|
||||||
|
account: args.account as string | undefined,
|
||||||
|
},
|
||||||
|
customer
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'linkedin_search_connections':
|
case 'linkedin_search_connections':
|
||||||
result = await searchConnections({
|
result = await searchConnections({
|
||||||
keywords: args.keywords as string | undefined,
|
keywords: args.keywords as string | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user