diff --git a/.planning/phases/01-linkedin-video-upload/01-01-PLAN.md b/.planning/phases/01-linkedin-video-upload/01-01-PLAN.md new file mode 100644 index 0000000..b032b34 --- /dev/null +++ b/.planning/phases/01-linkedin-video-upload/01-01-PLAN.md @@ -0,0 +1,162 @@ +--- +phase: 01-linkedin-video-upload +plan: 01 +type: execute +depends_on: [] +files_modified: + - src/clients/linkedin.ts +--- + + +Implement the LinkedIn Videos API upload flow inside src/clients/linkedin.ts. + +Purpose: The existing linkedin client only supports text posts. LinkedIn video upload +requires a 4-step REST API flow that differs from the v2 UGC posts API. This plan +implements the complete upload pipeline as two exported functions. + +Output: Two new exported functions — `uploadVideo()` and `createVideoPost()` — that +handle downloading a video from a public URL, chunking it, uploading it to LinkedIn, +and publishing a post with the video attached. + + + +~/.claude/get-shit-done/workflows/execute-plan.md +./summary.md + + + +@src/clients/linkedin.ts + +**Established patterns in this file:** +- `resolveToken(args, customer)` handles both multi-tenant (Redis/OAuth) and env-var auth +- `linkedinRequest(endpoint, token, method, body)` wraps fetch for `/v2` API calls +- All exported functions accept `{ account?: string }` and optional `Customer` +- `createToolAudit(customer.id, 'linkedin:action')` wraps calls for audit logging +- Errors bubble as thrown `Error` instances (not wrapped in result objects) + +**LinkedIn Videos API facts (different from v2):** +- Base: `https://api.linkedin.com/rest` (not `/v2`) +- Required extra header: `LinkedIn-Version: 202501` +- Init: `POST /rest/videos?action=initializeUpload` +- Chunk upload: PUT to each `uploadUrl` from init response, `Content-Type: application/octet-stream` +- ETag collected from each PUT response's `ETag` header +- Finalize: `POST /rest/videos?action=finalizeUpload` +- Poll: `GET /rest/videos/{encodedVideoUrn}` until `status === 'AVAILABLE'` +- Post: `POST /rest/posts` (not `/v2/ugcPosts`) — post ID returned in `x-restli-id` response header +- Chunk size: 4 MB (4,194,304 bytes); LinkedIn enforces min 2 MB except last chunk + + + + + + Task 1: Add linkedinRestRequest helper and uploadVideo() function + src/clients/linkedin.ts + +Add a private `linkedinRestRequest` helper (mirrors `linkedinRequest` but uses +`https://api.linkedin.com/rest` base and adds `LinkedIn-Version: 202501` header). +Responses from init/finalize return 200 with JSON body; PUT chunk responses may return +200 or 204; always read ETag from the response header, not the body. + +Then add `uploadVideo(videoUrl, ownerUrn, accessToken): Promise` (returns video URN): + +Step 1 — Download: fetch the videoUrl, read as ArrayBuffer, get byte length. + Use AbortSignal.timeout(120000) for large files. + +Step 2 — Initialize upload: + POST /rest/videos?action=initializeUpload + Body: { initializeUploadRequest: { owner: ownerUrn, fileSizeBytes: N, uploadCaptions: false, uploadThumbnail: false } } + Extract from response.value: uploadInstructions (array), video (URN string), uploadToken. + +Step 3 — Upload chunks: + Chunk size: 4,194,304 bytes. For each uploadInstruction, slice the buffer from + firstByte to lastByte+1 and PUT to uploadUrl with Content-Type: application/octet-stream. + Collect the ETag response header from each PUT (strip surrounding quotes if present). + Use AbortSignal.timeout(60000) per chunk. + +Step 4 — Finalize: + POST /rest/videos?action=finalizeUpload + Body: { finalizeUploadRequest: { video: videoUrn, uploadToken, uploadedPartIds: [etag1, etag2, ...] } } + +Step 5 — Poll until AVAILABLE: + GET /rest/videos/{encodeURIComponent(videoUrn)} + Poll every 3 seconds, timeout after 90 seconds total, throw if status is 'FAILED'. + Return videoUrn when status === 'AVAILABLE'. + + npx tsc --noEmit runs without errors after adding the function + uploadVideo is exported-ready (used by Task 2), tsc clean + + + + Task 2: Add createVideoPost() exported function + src/clients/linkedin.ts + +Add and export: + +``` +createVideoPost( + args: { video_url: string; text: string; visibility?: 'PUBLIC' | 'CONNECTIONS'; account?: string }, + customer?: Customer +): Promise<{ success: boolean; post_id: string; url: string }> +``` + +Implementation: +1. Resolve token with existing `resolveToken(args, customer)`. +2. Get profile with existing `getProfile(args, customer)` to build `authorUrn`. +3. Call `uploadVideo(args.video_url, authorUrn, accessToken)` → videoUrn. +4. Create post via POST /rest/posts (NOT /v2/ugcPosts — that endpoint does not support + the video content field): + Body: + { + author: authorUrn, + commentary: args.text, + visibility: args.visibility ?? 'PUBLIC', + distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] }, + content: { media: { id: videoUrn } }, + lifecycleState: 'PUBLISHED', + isReshareDisabledByAuthor: false + } +5. The POST /rest/posts returns 201; the post URN is in the `x-restli-id` response header + (not the JSON body). Read it with `res.headers.get('x-restli-id')`. +6. Wrap in createToolAudit (same pattern as createPost — log text.slice(0,100) + video_url). +7. Return { success: true, post_id, url: `https://www.linkedin.com/feed/update/${post_id}` }. + +Do NOT call linkedinRestRequest for the POST /rest/posts step — write an inline fetch +since that call needs to read the 201 response header before res.json() would consume it. +Throw a descriptive error if status is not 201. + + npx tsc --noEmit passes; grep -n 'createVideoPost' src/clients/linkedin.ts shows the export + createVideoPost is exported, tsc clean, audit logging included + + + + + +Before declaring plan complete: +- [ ] `npx tsc --noEmit` passes with zero errors +- [ ] `grep -n 'uploadVideo\|createVideoPost\|linkedinRestRequest' src/clients/linkedin.ts` shows all three symbols +- [ ] No existing functions (`getProfile`, `createPost`, `searchConnections`, `sendMessage`) are modified + + + +- uploadVideo() implements all 5 steps: download, init, chunk-PUT with ETag collection, finalize, poll +- createVideoPost() uses /rest/posts (not /v2/ugcPosts) and reads post ID from x-restli-id header +- Chunk size is 4,194,304 bytes +- Poll timeout is 90 seconds with 3-second intervals +- TypeScript compiles clean +- Audit logging matches existing createPost pattern + + + +After completion, create `.planning/phases/01-linkedin-video-upload/01-01-SUMMARY.md`: + +# Phase 01 Plan 01: LinkedIn Video Upload Client Summary + +**[one-liner of what shipped]** + +## Accomplishments +## Files Created/Modified +## Decisions Made +## Issues Encountered +## Next Step +Ready for 01-02-PLAN.md + diff --git a/.planning/phases/01-linkedin-video-upload/01-02-PLAN.md b/.planning/phases/01-linkedin-video-upload/01-02-PLAN.md new file mode 100644 index 0000000..7d7edb2 --- /dev/null +++ b/.planning/phases/01-linkedin-video-upload/01-02-PLAN.md @@ -0,0 +1,177 @@ +--- +phase: 01-linkedin-video-upload +plan: 02 +type: execute +depends_on: ["01-01"] +files_modified: + - src/tools.ts + - src/index.ts +--- + + +Wire createVideoPost() into the MCP tool layer and REST API. + +Purpose: The upload client from Plan 01 is only callable internally. This plan exposes +it as a named MCP tool (`linkedin_upload_video`) and a REST endpoint +(`POST /api/linkedin/video`) so Claude and external callers can use it. + +Output: A fully registered MCP tool + REST route that accepts video_url, text, visibility, +and account, and returns { success, post_id, url } — matching the pattern of +linkedin_create_post. + + + +~/.claude/get-shit-done/workflows/execute-plan.md +./summary.md +~/.claude/get-shit-done/references/checkpoints.md + + + +@src/tools.ts +@src/index.ts +@src/clients/linkedin.ts + +**Established patterns to follow exactly:** + +tools.ts tool definition (copy linkedin_create_post structure, lines ~256-274): + { name, description, inputSchema: { type: 'object', properties: {...}, required: [...] } } + +tools.ts import (line 7): + import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, ... } from './clients/linkedin.js'; + → add createVideoPost to this import + +tools.ts handler (lines ~778-787, inside handleToolCall switch): + case 'linkedin_upload_video': + result = await createLinkedInVideoPost({ ... }, customer); + break; + +index.ts REST route (lines ~747-756, follow app.post('/api/linkedin/post') pattern): + app.post('/api/linkedin/video', requireAuth, async (req, res) => { ... }) + → calls handleToolCall('linkedin_upload_video', { video_url, text, visibility, account }) + +**Tool input schema fields:** + video_url: { type: 'string', description: 'Publicly accessible URL of the video file to upload' } — required + text: { type: 'string', description: 'Post caption / commentary text' } — required + visibility: { type: 'string', enum: ['PUBLIC','CONNECTIONS'], description: 'Post visibility. Default: PUBLIC' } + account: { type: 'string', description: 'Which LinkedIn account to use (default: "default")' } + + + + + + Task 1: Register linkedin_upload_video MCP tool in tools.ts + src/tools.ts + +1. Add `createVideoPost as createLinkedInVideoPost` to the existing linkedin import on line 7. + Keep all other imports on that line unchanged. + +2. In the `tools` array, insert a new tool definition immediately after the + `linkedin_create_post` entry (around line 274). Use this exact structure: + { + 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'], + }, + } + +3. In handleToolCall switch, add the handler case immediately after 'linkedin_create_post': + 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; + + npx tsc --noEmit passes; grep -n 'linkedin_upload_video' src/tools.ts shows both the definition and the case handler + Tool appears in tools array and switch statement, tsc clean + + + + Task 2: Add POST /api/linkedin/video REST route in index.ts + src/index.ts + +Insert immediately after the `app.post('/api/linkedin/post', ...)` block (around line 756): + +app.post('/api/linkedin/video', requireAuth, async (req, res) => { + try { + const { video_url, text, visibility, account } = req.body; + const result = await handleToolCall('linkedin_upload_video', { video_url, text, visibility, account }); + res.json(result); + } catch (err) { + res.status(500).json({ error: String(err) }); + } +}); + +Do NOT add input validation beyond what handleToolCall already does — the tool's +required fields check ('video_url', 'text') already throws if missing. + + npx tsc --noEmit passes; grep -n '/api/linkedin/video' src/index.ts shows the new route + Route registered, tsc clean, follows exact pattern of adjacent routes + + + + + Full linkedin_upload_video tool: client (uploadVideo + createVideoPost), MCP tool definition, + REST route POST /api/linkedin/video. The server fetches the video from video_url, uploads + it to LinkedIn in 4MB chunks, waits up to 90s for LinkedIn to process it, then publishes + the post and returns { success, post_id, url }. + + + 1. Build: npm run build — must complete without errors + 2. Start server: npm start (or node dist/index.js) in a separate terminal + 3. Test the MCP tool via Claude by saying: + "Upload the SquareMCP LinkedIn video to LinkedIn with text [your caption]" + and providing the video_url pointing to a publicly accessible MP4 + (or use the rendered out/squaremcp-linkedin.mp4 hosted somewhere accessible) + 4. Confirm the response contains { success: true, post_id: "urn:li:share:...", url: "https://www.linkedin.com/feed/update/..." } + 5. Visit the returned URL in a browser and confirm the video post appears with correct caption + 6. Check the audit log (if Redis is running) shows the linkedin:createVideoPost entry + + Type "approved" if the post appeared on LinkedIn with video, or describe the error to fix + + + + + +Before declaring plan complete: +- [ ] `npm run build` (full TypeScript build) succeeds with zero errors +- [ ] `grep -n 'linkedin_upload_video' src/tools.ts` returns two matches (definition + case) +- [ ] `grep -n '/api/linkedin/video' src/index.ts` returns one match +- [ ] `grep -n 'createLinkedInVideoPost' src/tools.ts` returns two matches (import + call) + + + +- linkedin_upload_video appears in MCP tool list (visible to Claude at http://localhost:3000/mcp) +- POST /api/linkedin/video route returns { success, post_id, url } on valid input +- Error from missing video_url or text propagates as 500 with { error: "..." } +- Build is clean — no TypeScript errors in any file +- Phase complete: LinkedIn video upload fully wired from URL → LinkedIn post + + + +After completion, create `.planning/phases/01-linkedin-video-upload/01-02-SUMMARY.md`: + +# Phase 01 Plan 02: Tool + Route Wiring Summary + +**[one-liner of what shipped]** + +## Accomplishments +## Files Created/Modified +## Decisions Made +## Issues Encountered +## Next Step +Phase complete — linkedin_upload_video tool is live. +