[object Object]

← back to Marketing Command Center

linkedin(mcc): incorporate native image/video posting into the command center

ef6776b61c4bd52a63f780226d7e161e43d46301 · 2026-07-30 14:52:15 -0700 · Steve

Port the linkedin-api CLI's media flow (initializeUpload → PUT part(s) →
finalizeUpload → post-with-URN) into the MCC LinkedIn adapter so it posts
text + native image/video (was text-only). /publish accepts {video|image|url}
(local path or http URL, e.g. the reels /reels/<file>.mp4); still fully gated
(confirm+approve+connected). channels POSTABILITY: linkedin mediaTypes now
include video. Verified: staged path returns the media ref, posts nothing
until a token lands.

Files touched

Diff

commit ef6776b61c4bd52a63f780226d7e161e43d46301
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 14:52:15 2026 -0700

    linkedin(mcc): incorporate native image/video posting into the command center
    
    Port the linkedin-api CLI's media flow (initializeUpload → PUT part(s) →
    finalizeUpload → post-with-URN) into the MCC LinkedIn adapter so it posts
    text + native image/video (was text-only). /publish accepts {video|image|url}
    (local path or http URL, e.g. the reels /reels/<file>.mp4); still fully gated
    (confirm+approve+connected). channels POSTABILITY: linkedin mediaTypes now
    include video. Verified: staged path returns the media ref, posts nothing
    until a token lands.
---
 modules/channels/index.js |  4 +--
 modules/linkedin/index.js | 83 +++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 75 insertions(+), 12 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 6c202ad..b7ad9f2 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -251,8 +251,8 @@ const POSTABILITY = {
                postableNote: 'Private-only (SELF_ONLY) and video-only until TikTok App Review — cannot post publicly or post an image.' },
   facebook:  { postable: false, mediaTypes: ['image', 'video'],
                postableNote: 'Blocked: page posting needs pages_manage_posts, which requires Meta App Review — API posts fail until approved.' },
-  linkedin:  { postable: false, mediaTypes: ['image', 'text'],
-               postableNote: 'Blocked: company-page posting needs a dedicated Community-Management app; the current token is unscoped/invalid.' },
+  linkedin:  { postable: false, mediaTypes: ['image', 'text', 'video'],
+               postableNote: 'Adapter supports text + native image/video (initializeUpload→PUT→finalize→post). Company-Page posting still needs Community-Management API approval (w_organization_social, Case CAS-11523819); personal-profile posting works today with a w_member_social token. Set LINKEDIN_ACCESS_TOKEN + org/author URN to flip postable.' },
 };
 
 // ── Per-platform connection status (what's wired vs what Steve must authorize) ──
diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
index 6e7228c..22988c8 100644
--- a/modules/linkedin/index.js
+++ b/modules/linkedin/index.js
@@ -32,17 +32,76 @@ const TEMPLATES = [
 // Connected only when a token + an author/org URN are set (Phase 2).
 const liConfigured = () => !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_AUTHOR_URN') || env('LINKEDIN_ORG_URN')));
 
-async function liPost({ text }) {
+const LI_VER = '202401';
+const liHeaders = token => ({ Authorization: `Bearer ${token}`, 'LinkedIn-Version': LI_VER, 'X-Restli-Protocol-Version': '2.0.0' });
+
+// Resolve media bytes from either a local filesystem path or an http(s) URL
+// (e.g. the reels app's /reels/<file>.mp4). Returns a Buffer.
+async function mediaBytes(ref) {
+  if (/^https?:\/\//i.test(ref)) {
+    const r = await fetch(ref);
+    if (!r.ok) throw new Error(`fetch media ${r.status} for ${ref}`);
+    return Buffer.from(await r.arrayBuffer());
+  }
+  return fs.readFileSync(ref);
+}
+
+// Native image/video upload, ported from the linkedin-api CLI (post.py):
+// initializeUpload → PUT part(s) → (video) finalizeUpload → returns the media URN.
+async function liUploadMedia({ token, author, kind, ref }) {
+  const bytes = await mediaBytes(ref);
+  const init = kind === 'video'
+    ? { initializeUploadRequest: { owner: author, fileSizeBytes: bytes.length, uploadCaptions: false, uploadThumbnail: false } }
+    : { initializeUploadRequest: { owner: author } };
+  const ir = await fetch(`https://api.linkedin.com/rest/${kind}s?action=initializeUpload`, {
+    method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' }, body: JSON.stringify(init),
+  });
+  if (!ir.ok) throw new Error(`initializeUpload ${ir.status}: ${(await ir.text()).slice(0, 200)}`);
+  const v = (await ir.json()).value;
+  const urn = v[kind];
+  if (kind === 'image') {
+    const pr = await fetch(v.uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: bytes });
+    if (!pr.ok) throw new Error(`image PUT ${pr.status}`);
+    return urn;
+  }
+  // video → PUT each byte-range part, collect ETags, then finalize
+  const etags = [];
+  for (const ins of v.uploadInstructions) {
+    const first = Number(ins.firstByte), last = Number(ins.lastByte);
+    const pr = await fetch(ins.uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: bytes.subarray(first, last + 1) });
+    if (!pr.ok) throw new Error(`video PUT part ${pr.status}`);
+    etags.push(pr.headers.get('etag') || pr.headers.get('ETag'));
+  }
+  const fr = await fetch('https://api.linkedin.com/rest/videos?action=finalizeUpload', {
+    method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' },
+    body: JSON.stringify({ finalizeUploadRequest: { video: urn, uploadToken: v.uploadToken || '', uploadedPartIds: etags } }),
+  });
+  if (!fr.ok) throw new Error(`finalizeUpload ${fr.status}`);
+  return urn;
+}
+
+// Post text, or text + native image/video, or a link share, to the connected
+// author (org page or personal). media = { video } | { image } | { url } where
+// video/image is a local path or http(s) URL; title optional.
+async function liPost({ text, media }) {
   const token = env('LINKEDIN_ACCESS_TOKEN');
   const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'); // urn:li:organization:… or urn:li:person:…
+  let content = null;
+  if (media && (media.video || media.image)) {
+    const kind = media.video ? 'video' : 'image';
+    const urn = await liUploadMedia({ token, author, kind, ref: media.video || media.image });
+    content = { media: { title: media.title || (kind === 'video' ? 'video' : 'image'), id: urn } };
+  } else if (media && media.url) {
+    content = { article: { source: media.url, title: media.title || '', description: media.description || '' } };
+  }
+  const post = {
+    author, commentary: text, visibility: 'PUBLIC',
+    distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
+    lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
+  };
+  if (content) post.content = content;
   const r = await fetch('https://api.linkedin.com/rest/posts', {
-    method: 'POST',
-    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' },
-    body: JSON.stringify({
-      author, commentary: text, visibility: 'PUBLIC',
-      distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
-      lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
-    }),
+    method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' }, body: JSON.stringify(post),
   });
   const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
   let body = null; try { body = await r.json(); } catch { /* may be empty */ }
@@ -78,16 +137,20 @@ module.exports = {
     });
 
     // Gated publish — NEVER auto-fires. Stages unless connected + confirm + approve.
+    // Optional media: { video } | { image } | { url } (video/image = local path or
+    // http(s) URL, e.g. the reels app's /reels/<file>.mp4); title/description optional.
     router.post('/publish', async (req, res) => {
       const d = req.body || {};
       const text = deWallpaper(String(d.text || '').slice(0, 3000));
       if (!text.trim()) return res.status(400).json({ error: 'empty post' });
       if (d.confirm !== true) return res.status(400).json({ error: 'A live post requires confirm:true.' });
       if (d.approved !== true) return res.status(400).json({ error: 'This post is not approved. Set approved:true to clear it for live posting.' });
+      // normalize a media reference from either d.media or flat d.video/d.image/d.url
+      const media = d.media || (d.video ? { video: d.video, title: d.title } : d.image ? { image: d.image, title: d.title } : d.url ? { url: d.url, title: d.title, description: d.description } : null);
       if (!liConfigured()) {
-        return res.json({ ok: true, staged: true, posted: false, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN (Phase 2) to post live. Nothing was sent.' });
+        return res.json({ ok: true, staged: true, posted: false, media: media || null, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN (Phase 2) to post live. Nothing was sent.' });
       }
-      try { const r = await liPost({ text }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, error: r.error }); }
+      try { const r = await liPost({ text, media }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, error: r.error }); }
       catch (e) { return res.status(502).json({ error: e.message }); }
     });
   },

← 42fc9c3 auto-save: 2026-07-30T14:48:53 (1 files) — docs/submissions/  ·  back to Marketing Command Center  ·  chore: lint+refactor, fix channels postLinkedIn video routin 60ffd0e →