91 lines
4.7 KiB
TypeScript
91 lines
4.7 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);
|
|
}
|
|
|
|
export async function publishReply(token: string, postId: string, text: string, linkUrl: string,
|
|
beforePublish: (id: string) => Promise<void>, request: typeof fetch = fetch,
|
|
wait: (ms: number) => Promise<unknown> = delay) {
|
|
const container = await threadsRequest(token, '/me/threads', {
|
|
media_type: 'TEXT', text, reply_to_id: postId, link_attachment: linkUrl,
|
|
}, 'POST', request);
|
|
if (!container.id) throw new Error('Threads returned no reply container 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');
|
|
}
|