Add automatic Threads link replies and sunrise preview card

This commit is contained in:
2026-11-02 04:46:17 +09:00
parent 39374cea05
commit 586e59608e
12 changed files with 168 additions and 8 deletions
+2 -1
View File
@@ -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();
+52
View File
@@ -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<void>) => {
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<void>) => {
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 }); }
});
+19 -1
View File
@@ -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');