diff --git a/.threads-captions.sample.json b/.threads-captions.sample.json
index 9c528ca..bcb45a9 100644
--- a/.threads-captions.sample.json
+++ b/.threads-captions.sample.json
@@ -1,5 +1,6 @@
{
- "template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}", "{hostname}"],
+ "template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}"],
+ "replyTemplate": ["관악산 일출 보러 가기", "{url}"],
"testPrefix": "[테스트] ",
"thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 },
"messages": {
diff --git a/docs/threads-automation.md b/docs/threads-automation.md
index eb7c651..a057fd3 100644
--- a/docs/threads-automation.md
+++ b/docs/threads-automation.md
@@ -135,3 +135,32 @@ pm2 save # 변경된 목록 저장
촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 일출+5분의 촬영 시간을 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다.
정지: `pm2 stop sunrise-threads`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요.
+# 본문과 링크 댓글
+
+`.threads-captions.json`의 `template`은 사진 본문, `replyTemplate`은 자동 댓글입니다.
+두 항목 모두 문자열 배열이며 기존 날짜·날씨·`{url}` 치환자를 사용할 수 있습니다.
+기본 본문에는 링크가 없고 댓글에는 사이트 링크가 들어갑니다.
+기존 파일에 `replyTemplate`이 없으면 이전 동작을 유지하므로 새 샘플을 복사하거나 해당 항목을 추가하세요.
+
+```sh
+cd /opt/sunrise
+git pull --ff-only
+cp .threads-captions.sample.json .threads-captions.json
+npm run threads:captions
+npm run build
+pm2 restart sunrise-threads
+```
+
+복사하면 사용자 지정 문구도 샘플로 교체됩니다. 이후 문구만 편집할 때는 빌드나 재시작이 필요 없습니다.
+사이트 서버가 HTML을 메모리에 보관한다면 사이트 프로세스도 재시작하세요.
+
+본문 게시 성공 → 촬영 이미지 두 장 삭제 → 링크 댓글 게시 순서로 실행됩니다.
+댓글은 `reply_to_id`와 `link_attachment`를 사용합니다. 링크 대상은 설정의 `siteUrl` origin입니다.
+사이트 HTML의 Open Graph 메타데이터와 `/og-sunrise.jpg`가 미리보기 카드에 사용됩니다.
+이 대표 이미지는 사이트의 정적 자산이며 매일 생성·삭제하는 촬영 사진과 별개입니다.
+카드 표시 여부와 캐시 갱신 시점은 Threads가 결정합니다.
+
+댓글 문구는 본문 게시 기록에 저장됩니다. 댓글 생성 실패는 다음 작업 실행 때 재시도하며
+본문을 다시 게시하지 않습니다. 과거 기록에 댓글 문구가 없으면 소급 게시하지 않습니다.
+별도 `<본문기록>.reply.json`에 `publishing` 상태가 남으면 중복 방지를 위해 자동 재게시하지 않습니다.
+로그와 실제 Threads 댓글을 확인한 뒤 복구해야 합니다. `published` 상태에는 댓글 ID가 기록됩니다.
diff --git a/index.html b/index.html
index 0edec66..2a481a0 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,14 @@
해보러가기
+
+
+
+
+
+
+
+
diff --git a/public/og-sunrise.jpg b/public/og-sunrise.jpg
new file mode 100644
index 0000000..f506d8b
Binary files /dev/null and b/public/og-sunrise.jpg differ
diff --git a/server/threads/captions.ts b/server/threads/captions.ts
index a23cad5..2454d43 100644
--- a/server/threads/captions.ts
+++ b/server/threads/captions.ts
@@ -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;
@@ -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 };
}
diff --git a/server/threads/previewCaptions.ts b/server/threads/previewCaptions.ts
index 8ca816a..5d42834 100644
--- a/server/threads/previewCaptions.ts
+++ b/server/threads/previewCaptions.ts
@@ -12,7 +12,8 @@ const samples: Record = {
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}`);
}
diff --git a/server/threads/publisher.ts b/server/threads/publisher.ts
index 65c119e..61db32c 100644
--- a/server/threads/publisher.ts
+++ b/server/threads/publisher.ts
@@ -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, request: typeof fetch = fetch,
+ wait: (ms: number) => Promise = 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,
request: typeof fetch, wait: (ms: number) => Promise) {
await waitForMedia(token, id, request, wait);
diff --git a/server/threads/reply.ts b/server/threads/reply.ts
new file mode 100644
index 0000000..b458515
--- /dev/null
+++ b/server/threads/reply.ts
@@ -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,
+ 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}`);
+}
diff --git a/server/threads/run.ts b/server/threads/run.ts
index ec0631e..c74e5a5 100644
--- a/server/threads/run.ts
+++ b/server/threads/run.ts
@@ -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, immediate: boolean) {
diff --git a/tests/threads-captions.test.ts b/tests/threads-captions.test.ts
index 7875f52..b1856dc 100644
--- a/tests/threads-captions.test.ts
+++ b/tests/threads-captions.test.ts
@@ -7,7 +7,8 @@ test('default post shows sunrise time rather than the five-minute-later capture
const config = loadCaptions(new URL('../.threads-captions.sample.json', import.meta.url));
const result = renderCaption(config, { weather: { ...sample, cloudCover: 90 }, capturedAt: '2026-11-01T21:58:00Z',
sunrise: '2026-11-01T21:53:00Z', url: 'https://sunrise.horoli.kr', test: false });
- assert.equal(result.text, '오늘의 관악산 일출이에요\n2026.11.02 · 일출 06:53\n날씨: 흐림\nsunrise.horoli.kr');
+ assert.equal(result.text, '오늘의 관악산 일출이에요\n2026.11.02 · 일출 06:53\n날씨: 흐림');
+ assert.equal(result.replyText, '관악산 일출 보러 가기\nhttps://sunrise.horoli.kr');
});
test('caption weather uses precipitation first and does not assume missing data means clear', () => {
const config = loadCaptions();
diff --git a/tests/threads-reply.test.ts b/tests/threads-reply.test.ts
new file mode 100644
index 0000000..81c71a5
--- /dev/null
+++ b/tests/threads-reply.test.ts
@@ -0,0 +1,52 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { replyToPublished } from '../server/threads/reply.ts';
+
+test('confirmed reply is recorded separately and skipped on restart', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'threads-reply-'));
+ const path = join(dir, 'record.json');
+ let calls = 0;
+ try {
+ const original = JSON.stringify({ status: 'published', postId: 'parent', imagesDeletedAt: 'now',
+ replyText: 'Link', replyUrl: 'https://example.com' });
+ await writeFile(path, original);
+ const publish = async (_token: string, post: string, text: string, url: string, claim: (id: string) => Promise) => {
+ calls++;
+ assert.equal(post, 'parent');
+ assert.equal(text, 'Link');
+ assert.equal(url, 'https://example.com');
+ await claim('container');
+ return 'reply-id';
+ };
+ await replyToPublished(path, async () => 'token', publish);
+ await replyToPublished(path, async () => 'token', publish);
+ assert.equal(calls, 1);
+ assert.equal(await readFile(path, 'utf8'), original);
+ assert.equal(JSON.parse(await readFile(`${path}.reply.json`, 'utf8')).replyId, 'reply-id');
+ } finally { await rm(dir, { recursive: true, force: true }); }
+});
+
+test('reply waits for image cleanup, and a lost publish response is never sent twice', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'threads-reply-'));
+ const path = join(dir, 'record.json');
+ const record = { status: 'published', postId: 'parent', replyText: 'Link', replyUrl: 'https://example.com' };
+ let calls = 0;
+ const publish = async (_token: string, _post: string, _text: string, _url: string, claim: (id: string) => Promise) => {
+ calls++;
+ await claim('container');
+ throw new Error('Connection lost');
+ };
+ try {
+ await writeFile(path, JSON.stringify(record));
+ await replyToPublished(path, async () => 'token', publish);
+ assert.equal(calls, 0);
+ await writeFile(path, JSON.stringify({ ...record, imagesDeletedAt: 'now' }));
+ await assert.rejects(replyToPublished(path, async () => 'token', publish), /Connection lost/);
+ await replyToPublished(path, async () => 'token', publish);
+ assert.equal(calls, 1);
+ assert.equal(JSON.parse(await readFile(`${path}.reply.json`, 'utf8')).status, 'publishing');
+ } finally { await rm(dir, { recursive: true, force: true }); }
+});
diff --git a/tests/threads.test.ts b/tests/threads.test.ts
index f2f9f01..04b1a7a 100644
--- a/tests/threads.test.ts
+++ b/tests/threads.test.ts
@@ -2,7 +2,25 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateSun } from '../src/astronomy/sunCalculator.ts';
import { observer } from '../src/config/observer.ts';
-import { dailySchedule, publishImage, publishCarousel } from '../server/threads/publisher.ts';
+import { dailySchedule, publishImage, publishCarousel, publishReply } from '../server/threads/publisher.ts';
+
+test('reply targets the published parent and attaches a preview link, claiming before publish', async () => {
+ const replies = [{ id: 'reply-container' }, { status: 'FINISHED' }, { id: 'reply' }];
+ let claimed = false;
+ const id = await publishReply('token', 'parent-post', 'Visit us', 'https://sunrise.horoli.kr', async () => {
+ claimed = true;
+ }, async (url, init) => {
+ const params = init?.body as URLSearchParams;
+ if (String(url).endsWith('/me/threads')) {
+ assert.equal(params.get('reply_to_id'), 'parent-post');
+ assert.equal(params.get('link_attachment'), 'https://sunrise.horoli.kr');
+ assert.equal(params.get('media_type'), 'TEXT');
+ }
+ if (String(url).endsWith('/me/threads_publish')) assert.ok(claimed);
+ return new Response(JSON.stringify(replies.shift()));
+ }, async () => {});
+ assert.equal(id, 'reply');
+});
test('schedule uses the Korean date and existing SunCalc sunrise', () => {
const now = new Date('2026-11-01T18:00:00Z');