← 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
M modules/channels/index.jsM modules/linkedin/index.js
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 →