Files
sunrise/server/threads/publisher.ts
T

81 lines
4.2 KiB
TypeScript

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, captureAt: sunriseCaptureTime(sunrise), prepareAt: new Date(+sunrise - THREADS.prepareMs) };
}
export function sunriseCaptureTime(sunrise: Date): Date {
return new Date(+sunrise + THREADS.captureDelayMs);
}
export async function threadsRequest(token: string, path: string, params: Record<string, string>, 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<void>, request: typeof fetch = fetch,
wait: (ms: number) => Promise<unknown> = 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<void>, request: typeof fetch = fetch,
wait: (ms: number) => Promise<unknown> = 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<void>,
request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
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<unknown>) {
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');
}