← back to Marketing Command Center

modules/linkedin/index.js

333 lines

'use strict';
// linkedin — LinkedIn composer + drafts + gated posting for the Marketing
// Command Center. DRAFT-FIRST (DTD verdict A 2026-06-13): compose with DW B2B
// best-practice templates, save drafts, copy/deep-link to post manually TODAY.
// The official LinkedIn API adapter (Community Management API — w_member_social
// for a personal profile, w_organization_social for the DW Company Page) is
// Phase 2: POSTING never auto-fires — it requires a connected token + confirm +
// approve, exactly like the social/channels gates.
//
// LinkedIn's User Agreement PROHIBITS third-party/browser automation, so there
// is intentionally NO scraping/headless posting here — the official API is the
// only programmatic path, and it stays human-approved.
const fs = require('fs');
const path = require('path');

const STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-drafts.json');
const readDrafts = () => { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; } };
const writeDrafts = a => { fs.mkdirSync(path.dirname(STORE), { recursive: true }); fs.writeFileSync(STORE, JSON.stringify(a, null, 2)); };
const env = k => (process.env[k] || '').trim();
// DW standing rule — "Wallpaper" is banned; "Wallcovering(s)" only.
const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
let _seq = 0; const newId = () => 'li' + Date.now().toString(36) + ((_seq = (_seq + 1) % 1000).toString(36));

const TEMPLATES = [
  { id: 'reveal', name: 'Project reveal', body: 'Before → after: {room} transformed with {product}.\n\nThe brief: {goal}. The move: {why}.\n\nWhat would you have specified?' },
  { id: 'insight', name: 'Trade insight', body: 'One thing most {audience} get wrong about {topic}:\n\n{insight}\n\nHere’s how we approach it at Designer Wallcoverings.' },
  { id: 'feature', name: 'Product feature', body: 'Material spotlight: {product}.\n\nWhy it works: {benefit1}; {benefit2}; {benefit3}.\n\nSpecifying for a project? Let’s talk.' },
  { id: 'lesson', name: 'Lesson from an install', body: 'A lesson from a recent install:\n\n{lesson}\n\nSmall detail, big difference.' },
  { id: 'testimonial', name: 'Specifier testimonial', body: '“{quote}”\n\nThat’s from {role} on {project}, after specifying {product}.\n\nNothing means more than a designer trusting us on a project. Thank you, {name}.' },
];

// 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')));

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'));
  }
  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 || '', id: urn } }; // blank title, not the literal media-kind word
  } 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: { ...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 */ }
  return { ok: r.ok, status: r.status, id, error: r.ok ? null : ((body && body.message) || `HTTP ${r.status}`) };
}

// ── Network-feed harvest + reshare + download (TK-10504) ─────────────────────
// LinkedIn has NO feed-read API, so the "Network feed" is populated by a MANUAL,
// human-triggered openclaw harvest of Steve's OWN home feed (scripts/linkedin-
// feed-harvest.mjs) writing data/linkedin-feed.json. This is a research aid — it
// only READS the feed; every outward action below stays gated.
const { spawn } = require('child_process');
const FEED_STORE   = path.join(__dirname, '..', '..', 'data', 'linkedin-feed.json');
const ASSETS_DIR   = path.join(__dirname, '..', '..', 'data', 'assets');
const ASSETS_STORE = path.join(__dirname, '..', '..', 'data', 'assets.json');
const readFeed = () => { try { return JSON.parse(fs.readFileSync(FEED_STORE, 'utf8')); } catch { return { posts: [], harvestedAt: null }; } };

// ── Curated feed (TK-10504) — the WORKING populate path ──────────────────────
// LinkedIn's feed can't be scraped (see harvester header), so the panel is
// populated by pasting post URLs. Each becomes a card rendered via LinkedIn's
// OFFICIAL embed iframe (sanctioned, TOS-clean) + the same gated reshare/download.
const CURATED_STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-feed-curated.json');
const readCurated  = () => { try { return JSON.parse(fs.readFileSync(CURATED_STORE, 'utf8')); } catch { return { items: [] }; } };
const writeCurated = o => { fs.mkdirSync(path.dirname(CURATED_STORE), { recursive: true }); fs.writeFileSync(CURATED_STORE, JSON.stringify(o, null, 2)); };

// Extract urn:li:(activity|ugcPost|share):<id> from any LinkedIn post URL shape:
//   /posts/<slug>-activity-7xxxx-yyyy   /feed/update/urn:li:activity:7xxxx   (url-encoded too)
function urnFromUrl(url) {
  const raw = String(url || '').trim();
  let dec = raw; try { dec = decodeURIComponent(raw); } catch { /* keep raw */ }
  for (const s of [raw, dec]) {
    let m = s.match(/urn:li:(activity|ugcPost|share):(\d{6,})/i);
    if (m) return `urn:li:${m[1].toLowerCase() === 'ugcpost' ? 'ugcPost' : m[1].toLowerCase()}:${m[2]}`;
  }
  const m = dec.match(/activity[:\-](\d{6,})/i);
  return m ? `urn:li:activity:${m[1]}` : null;
}

// Best-effort Open Graph pull from the PUBLIC post page — gives a thumbnail,
// title, and (when the post is public) a dms.licdn.com progressive video URL the
// existing /feed/download route can save. If LinkedIn blocks it, the embed iframe
// still renders the post, so this only enriches — it never gates adding a card.
async function fetchOg(permalink) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 6000);
  try {
    const r = await fetch(permalink, { headers: { 'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)' }, redirect: 'follow', signal: ctrl.signal });
    if (!r.ok) return {};
    const html = (await r.text()).slice(0, 400000);
    const og = k => { const m = html.match(new RegExp('<meta[^>]+(?:property|name)=["\\\']og:' + k + '["\\\'][^>]+content=["\\\']([^"\\\']+)', 'i')); return m ? m[1].replace(/&amp;/g, '&') : ''; };
    const video = og('video:url') || og('video:secure_url') || og('video');
    return { title: og('title'), thumb: og('image'), videoUrl: /\.licdn\.com/i.test(video) ? video : '' };
  } catch { return {}; }
  finally { clearTimeout(timer); }
}

// Single-flight harvest job tracker — the harvester is never an auto-poster.
const harvestJob = { running: false, startedAt: null, finishedAt: null, code: null, error: null, log: '' };
function startHarvest(args) {
  if (harvestJob.running) return false;
  Object.assign(harvestJob, { running: true, startedAt: new Date().toISOString(), finishedAt: null, code: null, error: null, log: '' });
  const script = path.join(__dirname, '..', '..', 'scripts', 'linkedin-feed-harvest.mjs');
  const child = spawn(process.execPath, [script, ...args], { cwd: path.join(__dirname, '..', '..') });
  const cap = d => { harvestJob.log = (harvestJob.log + d.toString()).slice(-4000); };
  child.stdout.on('data', cap); child.stderr.on('data', cap);
  child.on('error', e => { harvestJob.error = e.message; });
  child.on('close', c => { harvestJob.running = false; harvestJob.finishedAt = new Date().toISOString(); harvestJob.code = c; });
  return true;
}

// Reshare a network post from the connected DW author via the CMA Posts API.
// reshareContext.parent references the original activity/share URN. Third-party
// reshares can be restricted by LinkedIn — the raw API result is surfaced
// honestly (no false "posted" on a permissions error).
async function liReshare({ urn, commentary }) {
  const token = env('LINKEDIN_ACCESS_TOKEN');
  const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN');
  const post = {
    author, commentary: commentary || '', visibility: 'PUBLIC',
    distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
    lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
    reshareContext: { parent: urn },
  };
  const r = await fetch('https://api.linkedin.com/rest/posts', {
    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 */ }
  return { ok: r.ok, status: r.status, id, error: r.ok ? null : ((body && body.message) || `HTTP ${r.status}`) };
}

// Download a licdn progressive-mp4 into the SHARED asset library (data/assets/*
// + a record in data/assets.json, matching the assets module's record shape so
// it appears in the Asset Library for re-cutting as original DW content).
async function downloadToAssets({ src, name }) {
  let host; try { host = new URL(src).hostname; } catch { throw new Error('bad url'); }
  if (!/\.licdn\.com$/i.test(host)) throw new Error('refusing to download from non-licdn host: ' + host);
  const r = await fetch(src, { headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'video/mp4,*/*' } });
  if (!r.ok) throw new Error(`fetch video ${r.status} (licdn URLs are signed + expire — re-harvest, then download)`);
  const buf = Buffer.from(await r.arrayBuffer());
  fs.mkdirSync(ASSETS_DIR, { recursive: true });
  const id = 'a' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36);
  const filename = id + '.mp4';
  fs.writeFileSync(path.join(ASSETS_DIR, filename), buf);
  let store = []; try { store = JSON.parse(fs.readFileSync(ASSETS_STORE, 'utf8')); } catch { store = []; }
  const asset = {
    id, name: (name || 'LinkedIn video').toString().slice(0, 120), kind: 'upload', filename,
    mime: 'video/mp4', size: buf.length, tags: ['linkedin', 'video', 'network'], created_at: new Date().toISOString(),
  };
  store.push(asset); fs.writeFileSync(ASSETS_STORE, JSON.stringify(store, null, 2));
  return { id, filename, size: buf.length, src: `/api/assets/file/${filename}` };
}

module.exports = {
  id: 'linkedin',
  title: 'LinkedIn',
  icon: '💼',
  mount(router) {
    router.get('/connection', (_req, res) => res.json({
      configured: liConfigured(),
      surface: env('LINKEDIN_ORG_URN') ? 'Company Page' : (env('LINKEDIN_AUTHOR_URN') ? 'Personal profile' : null),
      mode: liConfigured() ? 'live (gated)' : 'draft-only (manual post)',
    }));
    router.get('/templates', (_req, res) => res.json({ templates: TEMPLATES }));
    router.get('/drafts', (_req, res) => res.json({ drafts: readDrafts().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) }));

    router.post('/draft', (req, res) => {
      const text = deWallpaper(String((req.body && req.body.text) || '').slice(0, 3000));
      const hashtags = Array.isArray(req.body && req.body.hashtags) ? req.body.hashtags.slice(0, 8) : [];
      if (!text.trim()) return res.status(400).json({ error: 'empty post' });
      const d = { id: newId(), text, hashtags, chars: text.length, status: 'draft', created_at: new Date().toISOString() };
      const arr = readDrafts(); arr.push(d); writeDrafts(arr);
      res.json({ ok: true, draft: d });
    });

    router.delete('/draft/:id', (req, res) => {
      const arr = readDrafts(); const i = arr.findIndex(d => d.id === req.params.id);
      if (i === -1) return res.status(404).json({ error: 'not found' });
      const [x] = arr.splice(i, 1); writeDrafts(arr); res.json({ ok: true, id: x.id });
    });

    // 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, 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, 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 }); }
    });

    // ── Network feed (TK-10504) ────────────────────────────────────────────
    // Read harvested posts (video-only by default; ?all=1 for every post).
    router.get('/feed', (req, res) => {
      const f = readFeed();
      const all = req.query.all === '1';
      const posts = all ? (f.posts || []) : (f.posts || []).filter(p => p.isVideo);
      res.json({ harvestedAt: f.harvestedAt || null, total: f.total ?? (f.posts || []).length, videos: f.videos, count: posts.length, posts });
    });
    // Manually trigger an openclaw harvest of Steve's own feed (research aid).
    router.get('/feed/harvest/status', (_req, res) => res.json(harvestJob));
    router.post('/feed/harvest', (req, res) => {
      const args = [];
      if (req.body && req.body.scrolls) args.push('--scrolls=' + Math.max(1, Math.min(40, Number(req.body.scrolls) || 8)));
      if (req.body && req.body.all) args.push('--all');
      const ok = startHarvest(args);
      res.json({ ok, started: ok, running: harvestJob.running, message: ok ? 'Harvest started (openclaw real Chrome).' : 'A harvest is already running.' });
    });
    // Download a harvested video into the shared asset library (action: repurpose).
    router.post('/feed/download', async (req, res) => {
      const src = String((req.body && req.body.src) || '');
      if (!src) return res.status(400).json({ error: 'src required' });
      try { const a = await downloadToAssets({ src, name: (req.body && req.body.name) }); res.json({ ok: true, asset: a }); }
      catch (e) { res.status(502).json({ error: e.message }); }
    });
    // Gated reshare from the DW Page — NEVER auto-fires (confirm + approved + connected).
    router.post('/feed/reshare', async (req, res) => {
      const d = req.body || {};
      const urn = String(d.urn || '');
      if (!/^urn:li:/.test(urn)) return res.status(400).json({ error: 'a valid post urn is required' });
      if (d.confirm !== true) return res.status(400).json({ error: 'A live reshare requires confirm:true.' });
      if (d.approved !== true) return res.status(400).json({ error: 'This reshare is not approved. Set approved:true to clear it for live posting.' });
      if (!liConfigured()) {
        return res.json({ ok: true, staged: true, posted: false, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN to reshare live. Nothing was sent.' });
      }
      try { const r = await liReshare({ urn, commentary: deWallpaper(String(d.commentary || '').slice(0, 3000)) }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, status: r.status, error: r.error }); }
      catch (e) { return res.status(502).json({ error: e.message }); }
    });

    // ── Curated feed (the WORKING populate path) — add posts by URL ─────────
    router.get('/feed/curated', (_req, res) => {
      const c = readCurated();
      res.json({ count: (c.items || []).length, items: (c.items || []).slice().sort((a, b) => (b.addedAt || '').localeCompare(a.addedAt || '')) });
    });
    router.post('/feed/curated', async (req, res) => {
      const url = String((req.body && req.body.url) || '').trim();
      const note = deWallpaper(String((req.body && req.body.note) || '').slice(0, 600));
      const urn = urnFromUrl(url);
      if (!urn) return res.status(400).json({ error: 'Could not find a LinkedIn post id in that URL. Paste a post/activity URL (…/posts/…-activity-<id>… or …/feed/update/urn:li:activity:<id>).' });
      const c = readCurated();
      if ((c.items || []).some(it => it.urn === urn)) return res.status(409).json({ error: 'That post is already on the board.' });
      const permalink = `https://www.linkedin.com/feed/update/${urn}/`;
      const og = await fetchOg(permalink);
      const item = {
        id: 'lc' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36),
        urn, permalink, sourceUrl: url,
        embedUrl: `https://www.linkedin.com/embed/feed/update/${urn}`,
        title: og.title || '', thumb: og.thumb || '', videoUrl: og.videoUrl || '',
        note, addedAt: new Date().toISOString(),
      };
      c.items = c.items || []; c.items.push(item); writeCurated(c);
      res.json({ ok: true, item });
    });
    router.delete('/feed/curated/:id', (req, res) => {
      const c = readCurated(); const i = (c.items || []).findIndex(it => it.id === req.params.id);
      if (i === -1) return res.status(404).json({ error: 'not found' });
      const [x] = c.items.splice(i, 1); writeCurated(c); res.json({ ok: true, id: x.id });
    });
  },
};