← back to Dw Slideshow Gen

lib/post.js

103 lines

// lib/post.js — the delivery leg. FAIL-CLOSED by design.
//
//   Default (no creds / no --confirm): assemble a local "draft package" — the 6 PNGs
//   + caption.txt + POST-INSTRUCTIONS.md, zipped. NO network call. $0.
//
//   Live Postiz draft: only attempted when ALL of (POSTIZ_API_KEY, POSTIZ_INTEGRATION_ID,
//   --confirm) are present. Even then it creates a TikTok carousel DRAFT with
//   privacy SELF_ONLY — never a public post, never auto-publish. Music + publish stay
//   manual in the TikTok app (a platform limit: the Content API can't add a trending
//   sound — and that human step is exactly where the algorithm is won).
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { readJSON, ensureDir, exists } from './util.js'
import { Postiz, buildDraftBodyMulti, buildScheduleBody, pickIntegration, pickIntegrations } from './postiz.js'

function loadManifest(renderDir) {
  const mp = path.join(renderDir, 'render-manifest.json')
  if (!exists(mp)) throw new Error(`no render-manifest.json in ${renderDir}`)
  const m = readJSON(mp)
  const pngs = (m.png_paths || []).filter(exists)
  if (!pngs.length) throw new Error('manifest lists no existing PNGs')
  return { ...m, png_paths: pngs }
}

export function packageDraft(renderDir, opts = {}) {
  const m = loadManifest(renderDir)
  const pkgDir = path.join(renderDir, 'post-package')
  ensureDir(pkgDir)
  // 1..N images, zero-padded for stable carousel order
  m.png_paths.forEach((p, i) => fs.copyFileSync(p, path.join(pkgDir, `slide_${String(i + 1).padStart(2, '0')}.png`)))
  const caption = `${m.caption || ''}\n\n${(m.hashtags || []).join(' ')}`.trim()
  fs.writeFileSync(path.join(pkgDir, 'caption.txt'), caption + '\n')
  fs.writeFileSync(path.join(pkgDir, 'POST-INSTRUCTIONS.md'), [
    `# Post: ${m.spec_id}`,
    ``,
    `**Platform:** TikTok (photo carousel) — also works for Instagram.`,
    `**Slides:** ${m.png_paths.length} × ${m.width}×${m.height}, in slide_01…order.`,
    ``,
    `## Steps (manual, by design)`,
    `1. Upload the slides as a **photo carousel**, in order.`,
    `2. Paste the caption from \`caption.txt\`.`,
    `3. **Add a trending sound** in the TikTok app — the Content API cannot, and`,
    `   the sound is what drives the algorithm. Keep a human on this step.`,
    `4. Post as a **draft (private / SELF_ONLY)** first; review; then publish.`,
    ``,
    `_Generated by dw-slideshow-gen. Render mode: ${m.render_mode}. $0._`,
  ].join('\n') + '\n')

  let zipPath = null
  if (opts.zip !== false) {
    try {
      zipPath = path.join(renderDir, `${m.spec_id}-draft.zip`)
      if (exists(zipPath)) fs.rmSync(zipPath)
      execFileSync('zip', ['-rjq', zipPath, pkgDir], { stdio: 'ignore' })
    } catch (e) { zipPath = null; process.stderr.write(`  [post] zip skipped: ${e.message}\n`) }
  }
  return { spec_id: m.spec_id, pkgDir, zipPath, slides: m.png_paths.length, caption }
}

const captionOf = m => `${m.caption || ''}\n\n${(m.hashtags || []).join(' ')}`.trim()

// Stage a carousel into Postiz. Default = a DRAFT (no settings, sidesteps the TikTok
// UPLOAD #1338 bug, reversible, human publishes from the dashboard). Network calls
// (upload + create) only fire with a key AND opts.confirm — otherwise a dry-run that
// returns the exact request body so the shape is verifiable without posting.
export async function stageToPostiz(renderDir, env, opts = {}) {
  const apiKey = env.POSTIZ_API_KEY
  if (!apiKey) return { staged: false, gated: true, reason: 'no POSTIZ_API_KEY (Settings → Developers → Public API)' }

  const m = loadManifest(renderDir)
  const content = captionOf(m)
  const dateISO = opts.schedule || new Date().toISOString()
  const client = new Postiz(apiKey)

  // pick channel(s) (read-only). --channel all|a,b -> multiple
  let chosen
  try {
    const list = await client.integrations()
    chosen = pickIntegrations(list, opts.channel)
  } catch (e) { return { staged: false, error: `integrations: ${e.message}` } }
  if (!chosen.length) return { staged: false, gated: true, reason: 'no connected channels — connect a channel in Postiz first' }

  // schedule needs a single provider's settings; draft is multi-channel (each gets its own settings)
  const mkBody = media => opts.schedule
    ? buildScheduleBody({ integrationId: chosen[0].id, identifier: chosen[0].identifier, content, media, dateISO, tiktok: opts.tiktok })
    : buildDraftBodyMulti({ integrations: chosen, content, media, dateISO })

  // dry-run: show the body with placeholder media, no upload, no post
  if (!opts.confirm) {
    const placeholder = m.png_paths.map((p, i) => ({ id: `<upload_${i + 1}>`, path: `https://uploads.postiz.com/${path.basename(p)}` }))
    return { staged: false, dryRun: true, channels: chosen, type: opts.schedule ? 'schedule' : 'draft',
      images: m.png_paths.length, body: mkBody(placeholder) }
  }

  // live: upload each PNG once (shared across channels), then create the post
  const media = []
  for (const p of m.png_paths) media.push(await client.upload(p))
  const created = await client.createPost(mkBody(media))
  const ids2 = (Array.isArray(created) ? created : [created]).map(x => x?.postId || x?.id).filter(Boolean)
  return { staged: true, type: opts.schedule ? 'schedule' : 'draft', channels: chosen, postIds: ids2, raw: created }
}