Add automatic Threads link replies and sunrise preview card
This commit is contained in:
@@ -6,6 +6,7 @@ import { THREADS } from '../../src/constants/threads.ts';
|
||||
type Condition = typeof THREADS.captionConditions[number];
|
||||
export interface Captions {
|
||||
template: string[];
|
||||
replyTemplate?: string[];
|
||||
testPrefix: string;
|
||||
thresholds: { fogVisibilityMeters: number; cloudyPercent: number; partlyCloudyPercent: number };
|
||||
messages: Record<Condition, string[]>;
|
||||
@@ -22,6 +23,7 @@ export function loadCaptions(path: string | URL = new URL('../../.threads-captio
|
||||
try { config = JSON.parse(text); } catch { throw new Error('Invalid JSON in .threads-captions.json'); }
|
||||
const lines = (value: unknown): value is string[] => Array.isArray(value) && value.length > 0 && value.every(line => typeof line === 'string');
|
||||
if (!config || !lines(config.template) || typeof config.testPrefix !== 'string'
|
||||
|| (config.replyTemplate !== undefined && !lines(config.replyTemplate))
|
||||
|| !THREADS.captionConditions.every(key => lines(config.messages?.[key]))) throw new Error('Invalid caption template or weather messages');
|
||||
const t = config.thresholds;
|
||||
if (!t || !Number.isFinite(t.fogVisibilityMeters) || t.fogVisibilityMeters < 0
|
||||
@@ -53,10 +55,15 @@ export function renderCaption(config: Captions, capture: {
|
||||
dateDots: kstFields(date).date.replaceAll('-', '.'), hostname: new URL(capture.url).hostname,
|
||||
time: kstClock(date).slice(0, 5), sunrise: kstClock(new Date(capture.sunrise)).slice(0, 5), url: capture.url,
|
||||
};
|
||||
const text = (capture.test ? config.testPrefix : '') + config.template.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const expand = (lines: string[]) => lines.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
if (!(key in values)) throw new Error(`Unknown caption placeholder: ${key}`);
|
||||
return values[key];
|
||||
});
|
||||
const text = (capture.test ? config.testPrefix : '') + expand(config.template);
|
||||
const replyText = config.replyTemplate ? expand(config.replyTemplate) : undefined;
|
||||
if (!text.trim() || [...text].length > THREADS.captionMaxLength) throw new Error('Caption must contain 1–500 characters');
|
||||
return { text, condition };
|
||||
if (replyText !== undefined && (!replyText.trim() || [...replyText].length > THREADS.captionMaxLength)) {
|
||||
throw new Error('Reply must contain 1–500 characters');
|
||||
}
|
||||
return { text, condition, replyText };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ const samples: Record<string, WeatherSample | null> = {
|
||||
rain: { ...base, rain: 1 }, snow: { ...base, snowfall: 1 }, unknown: null,
|
||||
};
|
||||
for (const [condition, weather] of Object.entries(samples)) {
|
||||
const { text } = renderCaption(config, { weather, capturedAt: new Date().toISOString(),
|
||||
const { text, replyText } = renderCaption(config, { weather, capturedAt: new Date().toISOString(),
|
||||
sunrise: dailySchedule(new Date()).sunrise.toISOString(), url: new URL(site.siteUrl).origin, test: false });
|
||||
console.log(`\n[${condition}]\n${text}`);
|
||||
if (replyText) console.log(`\n[댓글]\n${replyText}`);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,16 @@ export async function publishCarousel(token: string, imageUrls: string[], text:
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { publishReply } from './publisher.ts';
|
||||
|
||||
/** A separate exclusive claim prevents concurrent or ambiguous reply retries. */
|
||||
export async function replyToPublished(recordPath: string, getToken: () => Promise<string>,
|
||||
publish: typeof publishReply = publishReply) {
|
||||
const record = JSON.parse(await readFile(recordPath, 'utf8'));
|
||||
if (record.status !== 'published' || !record.postId || !record.imagesDeletedAt || !record.replyText) return;
|
||||
const replyPath = `${recordPath}.reply.json`;
|
||||
try {
|
||||
const existing = JSON.parse(await readFile(replyPath, 'utf8'));
|
||||
if (existing.status !== 'published') console.warn(`Reply outcome unknown; inspect ${replyPath} before retrying`);
|
||||
return;
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
const token = await getToken();
|
||||
const replyId = await publish(token, record.postId, record.replyText, record.replyUrl, async containerId => {
|
||||
await writeFile(replyPath, JSON.stringify({ status: 'publishing', postId: record.postId, containerId }, null, 2),
|
||||
{ flag: 'wx', mode: 0o600 });
|
||||
});
|
||||
const temporary = `${replyPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify({ status: 'published', postId: record.postId, replyId }, null, 2), { mode: 0o600 });
|
||||
await rename(temporary, replyPath);
|
||||
console.log(`Threads reply published: ${replyId}`);
|
||||
}
|
||||
+10
-2
@@ -8,6 +8,7 @@ import { kstClock, kstFields } from '../../src/ui/time.ts';
|
||||
import { dailySchedule, publishCarousel, threadsRequest, sunriseCaptureTime } from './publisher.ts';
|
||||
import { loadThreadsConfig } from './config.ts';
|
||||
import { cleanupPublished } from './cleanup.ts';
|
||||
import { replyToPublished } from './reply.ts';
|
||||
import { loadCaptions, renderCaption } from './captions.ts';
|
||||
import { WEATHER } from '../../src/constants/weather.ts';
|
||||
import type { WeatherSample } from '../../src/weather/weather.ts';
|
||||
@@ -69,10 +70,14 @@ async function main() {
|
||||
for (const name of await readdir(stateDir)) {
|
||||
if (/^(?:\d{4}-\d{2}-\d{2}|test-\d{4}-\d{2}-\d{2}-[\da-f-]+)\.json$/.test(name)) {
|
||||
await cleanupPublished(join(stateDir, name), imageDir);
|
||||
// An older failed reply must not prevent today's time-sensitive capture.
|
||||
try { await replyToPublished(join(stateDir, name), accessToken); }
|
||||
catch (error) { console.warn(`Pending reply ${name}: ${error instanceof Error ? error.message : 'failed'}`); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dryRun && await readJson(recordPath)) {
|
||||
await replyToPublished(recordPath, accessToken);
|
||||
console.log(`${schedule.date}: publication already recorded; inspect the record if status is publishing`);
|
||||
return;
|
||||
}
|
||||
@@ -89,9 +94,10 @@ async function main() {
|
||||
throw new Error('Saved capture is not a two-angle capture; archive the old capture record before running again');
|
||||
}
|
||||
if (!dryRun && !savedCapture) await saveJson(capturePath, captured);
|
||||
const { text, condition } = renderCaption(loadCaptions(), { weather: captured.weather ?? null,
|
||||
const { text, condition, replyText } = renderCaption(loadCaptions(), { weather: captured.weather ?? null,
|
||||
capturedAt, sunrise: captured.sunrise, url: siteUrl.origin, test: postNow });
|
||||
console.log(`Caption (${condition}):\n${text}`);
|
||||
if (replyText) console.log(`Reply:\n${replyText}`);
|
||||
if (dryRun) { console.log('Dry run: no Threads API calls or publication records'); return; }
|
||||
// Authentication/network outages must not lose the time-sensitive capture.
|
||||
const token = await accessToken();
|
||||
@@ -102,7 +108,8 @@ async function main() {
|
||||
if (!check.ok || !remoteImage.equals(await readFile(imagePath))) throw new Error('Public image URL does not serve the captured image');
|
||||
}
|
||||
const record = { date: schedule.date, mode: postNow ? 'test' : 'scheduled', sunrise: captured.sunrise,
|
||||
capturedAt, images, weather: captured.weather ?? null, condition, text, status: 'publishing' };
|
||||
capturedAt, images, weather: captured.weather ?? null, condition, text, replyText,
|
||||
replyUrl: siteUrl.origin, status: 'publishing' };
|
||||
const postId = await publishCarousel(token, images.map((image: { imageUrl: string }) => image.imageUrl), text, async containerId => {
|
||||
// Atomic claim survives restarts and blocks concurrent/ambiguous publication retries.
|
||||
await writeFile(recordPath, JSON.stringify({ ...record, containerId }, null, 2), { flag: 'wx', mode: 0o600 });
|
||||
@@ -112,6 +119,7 @@ async function main() {
|
||||
console.log(`Threads published: ${postId}`);
|
||||
await cleanupPublished(recordPath, imageDir);
|
||||
console.log('Deleted both source images after publication');
|
||||
await replyToPublished(recordPath, accessToken);
|
||||
}
|
||||
|
||||
async function captureSunrise(schedule: ReturnType<typeof dailySchedule>, immediate: boolean) {
|
||||
|
||||
Reference in New Issue
Block a user