← 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] : []
}