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:
@@ -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(
|
||||
args: { keywords?: string; account?: string },
|
||||
_customer?: Customer
|
||||
|
||||
Reference in New Issue
Block a user