import { setTimeout as delay } from 'node:timers/promises'; import { calculateSun } from '../../src/astronomy/sunCalculator.ts'; import { observer } from '../../src/config/observer.ts'; import { kstFields } from '../../src/ui/time.ts'; import { THREADS } from '../../src/constants/threads.ts'; export function dailySchedule(now: Date) { const sunrise = calculateSun(now, observer).sunrise; if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise for this date'); return { date: kstFields(now).date, sunrise, prepareAt: new Date(+sunrise - THREADS.prepareMs) }; } export async function threadsRequest(token: string, path: string, params: Record, method = 'GET', request: typeof fetch = fetch) { const url = new URL(path, THREADS.apiUrl); if (method === 'GET') url.search = new URLSearchParams(params).toString(); const response = await request(url, { method, headers: { Authorization: `Bearer ${token}` }, body: method === 'POST' ? new URLSearchParams(params) : undefined, signal: AbortSignal.timeout(THREADS.requestTimeoutMs), }); const data = await response.json(); // Do not log response bodies: authentication errors may echo credentials. if (!response.ok || data.error) throw new Error(`Threads API failed (HTTP ${response.status}, code ${data.error?.code ?? 'unknown'})`); return data; } export async function publishImage(token: string, imageUrl: string, text: string, beforePublish: (containerId: string) => Promise, request: typeof fetch = fetch, wait: (ms: number) => Promise = delay) { const container = await threadsRequest(token, '/me/threads', { media_type: 'IMAGE', image_url: imageUrl, text, alt_text: '관악산 정상에서 바라본 일출 시각의 3D 시뮬레이션', }, 'POST', request); if (!container.id) throw new Error('Threads returned no container ID'); return publishContainer(token, container.id, beforePublish, request, wait); } export async function publishCarousel(token: string, imageUrls: string[], text: string, beforePublish: (containerId: string) => Promise, request: typeof fetch = fetch, wait: (ms: number) => Promise = delay) { if (imageUrls.length !== THREADS.angles.length) throw new Error('Both sunrise angles are required'); const children: string[] = []; for (const [index, imageUrl] of imageUrls.entries()) { const child = await threadsRequest(token, '/me/threads', { media_type: 'IMAGE', image_url: imageUrl, is_carousel_item: 'true', alt_text: `${THREADS.angles[index].alt} · 3D 시뮬레이션`, }, 'POST', request); if (!child.id) throw new Error('Threads returned no child container ID'); await waitForMedia(token, child.id, request, wait); children.push(child.id); } const container = await threadsRequest(token, '/me/threads', { media_type: 'CAROUSEL', children: children.join(','), text, }, 'POST', request); if (!container.id) throw new Error('Threads returned no carousel ID'); return publishContainer(token, container.id, beforePublish, request, wait); } async function publishContainer(token: string, id: string, beforePublish: (id: string) => Promise, request: typeof fetch, wait: (ms: number) => Promise) { await waitForMedia(token, id, request, wait); await beforePublish(id); const post = await threadsRequest(token, '/me/threads_publish', { creation_id: id }, 'POST', request); if (!post.id) throw new Error('Threads returned no post ID; check account before retrying'); return String(post.id); } async function waitForMedia(token: string, id: string, request: typeof fetch, wait: (ms: number) => Promise) { for (let i = 0; i < THREADS.mediaPollAttempts; i++) { const media = await threadsRequest(token, `/${id}`, { fields: 'status,error_message' }, 'GET', request); if (media.status === 'FINISHED') return; if (['ERROR', 'EXPIRED', 'PUBLISHED'].includes(media.status)) throw new Error(`Threads container status: ${media.status}`); await wait(THREADS.mediaPollMs); } throw new Error('Threads media processing timed out'); }