← 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 });
}