← back to Dw Slideshow Gen

lib/postiz.js

134 lines

// lib/postiz.js — verified Postiz public-API client (shapes lifted from the official
// SDK + DTOs at github.com/gitroomhq/postiz-app, June 2026). Node 22 globals only.
//
//   Base:   https://api.postiz.com   (cloud).  Dashboard UI is platform.postiz.com.
//   Auth:   Authorization: <api-key>  (raw, no "Bearer"). Key from
//           Postiz → Settings → Developers → Public API.
//
// Design note: we stage carousels as type:'draft'. The public API also supports
// 'schedule'/'now', but the TikTok UPLOAD inbox-draft path is broken upstream
// (issue #1338, still OPEN — privacy_level dropped, posts stuck in QUEUE). 'draft'
// requires NO per-provider settings, lands in the Postiz dashboard for human review,
// and is reversible via DELETE — so it sidesteps the bug and keeps the publish gate.
import fs from 'node:fs'
import path from 'node:path'

const BASE = (process.env.POSTIZ_URL || 'https://api.postiz.com').replace(/\/$/, '')

export class Postiz {
  constructor(apiKey, base = BASE) {
    if (!apiKey) throw new Error('POSTIZ_API_KEY required')
    this.key = apiKey; this.base = base
  }
  async #json(p, init = {}) {
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    const MAX = 6   // retries on throttle / transient 5xx
    for (let attempt = 0; ; attempt++) {
      const res = await fetch(`${this.base}/public/v1${p}`, {
        ...init, headers: { Authorization: this.key, ...(init.headers || {}) },
      })
      const text = await res.text()
      if (res.ok) { try { return text ? JSON.parse(text) : {} } catch { return text } }
      // retry on 429 (Postiz ThrottlerException) and transient 5xx, honoring Retry-After
      const retriable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504
      if (retriable && attempt < MAX) {
        const ra = parseFloat(res.headers.get('retry-after') || '')
        const wait = Number.isFinite(ra) ? ra * 1000 : Math.min(30000, 1000 * 2 ** attempt) // exp backoff, cap 30s
        await sleep(wait)
        continue
      }
      throw new Error(`Postiz ${init.method || 'GET'} ${p} -> ${res.status}: ${text.slice(0, 300)}`)
    }
  }

  // GET connected channels: [{id, name, identifier, picture, disabled, profile}]
  integrations() { return this.#json('/integrations', { headers: { 'Content-Type': 'application/json' } }) }

  // POST multipart upload of one image -> {id, path, ...}
  async upload(filePath) {
    const buf = fs.readFileSync(filePath)
    const ext = path.extname(filePath).slice(1).toLowerCase()
    const type = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg'
    const fd = new FormData()
    fd.append('file', new Blob([buf], { type }), path.basename(filePath))
    return this.#json('/upload', { method: 'POST', body: fd })
  }

  createPost(body) {
    return this.#json('/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
  }
  listPosts(filters = {}) {
    const qs = new URLSearchParams(Object.entries(filters).filter(([, v]) => v != null)).toString()
    return this.#json(`/posts${qs ? '?' + qs : ''}`, { headers: { 'Content-Type': 'application/json' } })
  }
  deletePost(id) { return this.#json(`/posts/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) }
}

// ---- pure request-body builders (testable without a key, via --dry-run) ----
const imageRefs = media => media.map(m => ({ id: m.id, path: m.path, ...(m.alt ? { alt: m.alt } : {}) }))

// per-provider settings — the hosted API requires these even for drafts
export function settingsFor(identifier) {
  const id = identifier || ''
  if (id === 'tiktok') return {
    __type: 'tiktok', title: '', privacy_level: 'SELF_ONLY',
    duet: false, stitch: false, comment: true, autoAddMusic: 'no',
    brand_content_toggle: false, brand_organic_toggle: false, video_made_with_ai: false,
    content_posting_method: 'UPLOAD',
  }
  if (id.startsWith('instagram')) return { __type: id, post_type: 'post' }
  if (id.startsWith('facebook')) return { __type: id, post_type: 'post' }
  return { __type: id }
}

// stage ONE carousel to MANY channels in a single draft (posts[] = one entry per channel)
export function buildDraftBodyMulti({ integrations, content, media, dateISO }) {
  return {
    type: 'draft', shortLink: false, date: dateISO, tags: [],
    posts: integrations.map(i => ({
      integration: { id: i.id },
      value: [{ content, image: imageRefs(media) }],
      settings: settingsFor(i.identifier),
    })),
  }
}

export function buildScheduleBody({ integrationId, identifier, content, media, dateISO, tiktok = {} }) {
  const settings = identifier === 'tiktok'
    ? {
        __type: 'tiktok', title: '',
        privacy_level: tiktok.privacy || 'SELF_ONLY',
        duet: false, stitch: false, comment: true, autoAddMusic: 'no',
        brand_content_toggle: false, brand_organic_toggle: false, video_made_with_ai: false,
        content_posting_method: tiktok.method || 'UPLOAD',
      }
    : { __type: identifier }
  return {
    type: 'schedule', shortLink: false, date: dateISO, tags: [],
    posts: [{ integration: { id: integrationId }, value: [{ content, image: imageRefs(media) }], settings }],
  }
}

// pick the channel to post to: explicit id/name/identifier match, else first TikTok, else first enabled
export function pickIntegration(list, want) {
  const live = (list || []).filter(i => !i.disabled)
  if (want && want !== 'all') {
    const w = want.toLowerCase()
    const hit = live.find(i => i.id === want || i.identifier?.toLowerCase() === w || i.name?.toLowerCase() === w)
    if (hit) return hit
  }
  return live.find(i => i.identifier === 'tiktok') || live[0] || null
}

// pick MANY: `--channel all` -> every live channel; `a,b` -> matches; else single (pickIntegration)
export function pickIntegrations(list, want) {
  const live = (list || []).filter(i => !i.disabled)
  if (want === 'all') return live
  if (want && want.includes(',')) {
    const wants = want.split(',').map(s => s.trim().toLowerCase())
    return live.filter(i => wants.includes(i.id) || wants.includes(i.identifier?.toLowerCase()) || wants.includes(i.name?.toLowerCase()))
  }
  const one = pickIntegration(list, want)
  return one ? [one] : []
}