From c2eabd8e66e99ac3bed2e45d4a6aaba0c7560854 Mon Sep 17 00:00:00 2001 From: garfieldheron Date: Mon, 11 May 2026 12:26:42 -0400 Subject: [PATCH] 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 --- .../01-linkedin-video-upload/01-01-SUMMARY.md | 22 +++ .../01-linkedin-video-upload/01-02-SUMMARY.md | 23 +++ src/clients/linkedin.ts | 143 ++++++++++++++++++ src/index.ts | 10 ++ src/tools.ts | 29 +++- 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md create mode 100644 .planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md diff --git a/.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md b/.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md new file mode 100644 index 0000000..a0068fa --- /dev/null +++ b/.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md @@ -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 diff --git a/.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md b/.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md new file mode 100644 index 0000000..e6521a7 --- /dev/null +++ b/.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md @@ -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. diff --git a/src/clients/linkedin.ts b/src/clients/linkedin.ts index be017eb..d2d8d1e 100644 --- a/src/clients/linkedin.ts +++ b/src/clients/linkedin.ts @@ -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 { + // 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( args: { keywords?: string; account?: string }, _customer?: Customer diff --git a/src/index.ts b/src/index.ts index f7a032e..c8da27e 100644 --- a/src/index.ts +++ b/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; + 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) => { const { keywords, account } = req.body as Record; try { diff --git a/src/tools.ts b/src/tools.ts index c347b9d..2ea556e 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -4,7 +4,7 @@ import { searchMessages, readMessage, getProfile, listFolders, type Account } fr import { sendEmail, createDraft } from './smtp.js'; import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.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 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'; @@ -266,6 +266,21 @@ export const tools: Tool[] = [ 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', description: @@ -783,6 +798,18 @@ export async function handleToolCall( }, customer); 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': result = await searchConnections({ keywords: args.keywords as string | undefined,