← back to Dw Marketing Reels

scripts/tiktok-post.mjs

169 lines

// TikTok Content Posting API uploader for the reels pipeline (TK-00128).
// Ports the PROVEN FILE_UPLOAD flow from tiktok-oauth-catcher/fire-test-post.sh
// (init → PUT bytes → poll) and adds the token refresh the nightly cron needs
// (TikTok access tokens expire in ~24h; refresh tokens rotate on every use).
//
// ⚠️ AUDIT GATE (external, not code): the DW *production* TikTok app is UNAUDITED, so
// TikTok only allows SELF_ONLY (private) posts to a PRIVATE account until the audit
// passes (`unaudited_client_can_only_post_to_private_accounts`). PUBLIC_TO_EVERYONE
// will 400 until then. Default privacy = SELF_ONLY; set TIKTOK_PRIVACY=PUBLIC_TO_EVERYONE
// only AFTER TikTok approves the production app.
//
// Creds: read from process.env, falling back to the canonical ~/Projects/secrets-manager/.env
// (single source of truth — not duplicated into this repo). Rotated tokens are persisted back
// there. Never logs a token (last-4 only).
import { readFileSync, writeFileSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
const API = 'https://open.tiktokapis.com';

function parseEnv(path) {
  try {
    const out = {};
    for (const line of readFileSync(path, 'utf8').split('\n')) {
      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
      if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
    }
    return out;
  } catch { return {}; }
}

function creds() {
  const f = parseEnv(SECRETS_ENV);
  const g = k => process.env[k] || f[k] || '';
  return {
    clientKey: g('TIKTOK_CLIENT_KEY'),
    clientSecret: g('TIKTOK_CLIENT_SECRET'),
    accessToken: g('TIKTOK_ACCESS_TOKEN'),
    refreshToken: g('TIKTOK_REFRESH_TOKEN'),
  };
}

// Rewrite a KEY=value line in-place in the canonical secrets .env (rotated tokens must persist,
// or the next run's refresh_token is invalid — same class as the Constant Contact rotation gotcha).
function persistTokens({ access_token, refresh_token }) {
  let txt;
  try { txt = readFileSync(SECRETS_ENV, 'utf8'); } catch { return false; }
  // Function replacement so a `$` in a token value can't be read as a $-backreference (footgun).
  const set = (t, k, v) => t.match(new RegExp(`^${k}=.*$`, 'm'))
    ? t.replace(new RegExp(`^${k}=.*$`, 'm'), () => `${k}=${v}`)
    : t + `\n${k}=${v}\n`;
  if (access_token) txt = set(txt, 'TIKTOK_ACCESS_TOKEN', access_token);
  if (refresh_token) txt = set(txt, 'TIKTOK_REFRESH_TOKEN', refresh_token);
  writeFileSync(SECRETS_ENV, txt);
  return true;
}

// True when the client + refresh creds needed to post exist (in env or secrets .env).
export const hasTikTokCreds = () => { const c = creds(); return !!(c.clientKey && c.clientSecret && c.refreshToken); };

// Exchange the (rotating) refresh token for a fresh access token. Persists BOTH rotated values.
export async function refreshAccessToken() {
  const c = creds();
  if (!c.clientKey || !c.clientSecret || !c.refreshToken) throw new Error('missing TIKTOK_CLIENT_KEY/SECRET/REFRESH_TOKEN');
  const body = new URLSearchParams({
    client_key: c.clientKey, client_secret: c.clientSecret,
    grant_type: 'refresh_token', refresh_token: c.refreshToken,
  });
  const res = await fetch(`${API}/v2/oauth/token/`, {
    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
  });
  const j = await res.json();
  if (!res.ok || j.error || !j.access_token) throw new Error(`refresh failed: ${JSON.stringify(j).slice(0, 200)}`);
  persistTokens(j); // rotate + persist so the next nightly run has a valid refresh_token
  return { accessToken: j.access_token, expiresIn: j.expires_in };
}

const authHdr = at => ({ Authorization: `Bearer ${at}`, 'Content-Type': 'application/json; charset=UTF-8' });

// TikTok REQUIRES a creator_info query before Direct Post — it also validates the token and
// returns the privacy levels the (audited/unaudited) app is actually allowed to use.
async function creatorInfo(at) {
  const res = await fetch(`${API}/v2/post/publish/creator_info/query/`, { method: 'POST', headers: authHdr(at), body: '{}' });
  const j = await res.json();
  if (!res.ok || j.error?.code && j.error.code !== 'ok') throw new Error(`creator_info: ${JSON.stringify(j.error || j).slice(0, 200)}`);
  return j.data || {};
}

// Full post: creator_info → init FILE_UPLOAD → PUT bytes → poll. Returns { status, publishId }.
export async function postVideo({ localPath, caption, privacy = 'SELF_ONLY', accessToken }) {
  const size = statSync(localPath).size;
  const info = await creatorInfo(accessToken);
  const allowed = info.privacy_level_options || [];
  if (allowed.length && !allowed.includes(privacy)) {
    // e.g. unaudited app returns only ['SELF_ONLY'] — refuse to attempt a disallowed level (would 400).
    throw new Error(`privacy '${privacy}' not permitted by app (allowed: ${allowed.join(',') || 'none'}) — app likely unaudited`);
  }
  const init = await (await fetch(`${API}/v2/post/publish/video/init/`, {
    method: 'POST', headers: authHdr(accessToken),
    body: JSON.stringify({
      post_info: { title: (caption || '').slice(0, 2200), privacy_level: privacy, disable_comment: false, disable_duet: false, disable_stitch: false },
      source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 },
    }),
  })).json();
  const publishId = init?.data?.publish_id, uploadUrl = init?.data?.upload_url;
  if (!uploadUrl || !publishId) throw new Error(`init failed: ${JSON.stringify(init).slice(0, 200)}`);

  const put = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': 'video/mp4', 'Content-Range': `bytes 0-${size - 1}/${size}` },
    body: readFileSync(localPath),
  });
  if (!put.ok) throw new Error(`upload PUT HTTP ${put.status}`);

  // Poll (TikTok publish is async, ~30-60s).
  for (let i = 0; i < 18; i++) {
    await new Promise(r => setTimeout(r, 5000));
    const st = await (await fetch(`${API}/v2/post/publish/status/fetch/`, {
      method: 'POST', headers: authHdr(accessToken), body: JSON.stringify({ publish_id: publishId }),
    })).json();
    const s = st?.data?.status;
    if (s === 'PUBLISH_COMPLETE') return { status: 'posted', publishId };
    if (s === 'FAILED') throw new Error(`publish FAILED: ${JSON.stringify(st.data).slice(0, 200)}`);
  }
  // Timed out polling — the upload was accepted; do NOT treat as failure (would risk a re-post).
  return { status: 'posted-unverified', publishId };
}

// DRY-RUN: exercise the full pre-publish wiring — token refresh (auth plumbing) → creator_info
// (validates the access token AND returns the privacy levels the app is actually allowed to use)
// → assemble the exact init request body — but STOP before the video/init publish call. Nothing
// is uploaded and no post is created. This is the reversible, un-gated way to prove postTikTok is
// wired correctly (creds resolve, token refreshes, the API accepts our auth) without a live post.
// Returns { status:'dry-run', privacyRequested, privacyAllowed, wouldPost, initBodyPreview }.
export async function dryRunTikTok({ localPath, caption, privacy }) {
  const wantPrivacy = privacy || process.env.TIKTOK_PRIVACY || 'SELF_ONLY';
  const size = statSync(localPath).size; // fails loud if the local reel is missing — same check the real path makes
  const { accessToken } = await refreshAccessToken(); // real refresh; rotates + persists tokens (auth plumbing verified)
  const info = await creatorInfo(accessToken);        // real token validation; no post side-effect
  const allowed = info.privacy_level_options || [];
  const permitted = !allowed.length || allowed.includes(wantPrivacy);
  // The init body that WOULD be sent — proves request assembly without firing it.
  const initBodyPreview = {
    post_info: { title: (caption || '').slice(0, 2200), privacy_level: wantPrivacy, disable_comment: false, disable_duet: false, disable_stitch: false },
    source_info: { source: 'FILE_UPLOAD', video_size: size, chunk_size: size, total_chunk_count: 1 },
  };
  return {
    status: 'dry-run',
    wouldPost: permitted,
    privacyRequested: wantPrivacy,
    privacyAllowed: allowed,          // e.g. ['SELF_ONLY'] on an unaudited app — proves the audit gate is real, not guessed
    creatorNickname: info.creator_nickname || null,
    videoBytes: size,
    initBodyPreview,
    note: permitted
      ? `wiring OK — token refreshed, creator_info accepted, init body assembled; NOT posted (dry-run)`
      : `wiring OK but privacy '${wantPrivacy}' is NOT permitted (allowed: ${allowed.join(',') || 'none'}) — app likely unaudited; NOT posted (dry-run)`,
  };
}

// Orchestrator the reels pipeline calls: refresh → post (or dry-run). Returns a status object.
// TIKTOK_DRY_RUN=1 short-circuits to the no-publish wiring check above (safe, reversible, un-gated).
export async function publishReelToTikTok({ localPath, caption, privacy }) {
  if (process.env.TIKTOK_DRY_RUN === '1') return dryRunTikTok({ localPath, caption, privacy });
  const { accessToken } = await refreshAccessToken();
  return postVideo({ localPath, caption, privacy: privacy || process.env.TIKTOK_PRIVACY || 'SELF_ONLY', accessToken });
}