Note
이 글은 BotKit의 공식 튜토리얼 Building an RSS bot(영문)을 한국어로 옮긴 것입니다. 원문은 계속 업데이트되므로, 최신 내용은 위 링크에서 확인하는 편이 좋습니다.
BotKit의 시작하기 안내서를 따라 하면 몇 분 만에 봇을 띄울 수 있습니다. 다만 그 봇은 인사말에 답할 뿐, 딱히 감시할 대상은 없습니다. 이 튜토리얼은 거기서 한 걸음 더 나아갑니다. RSS나 Atom, RDF 피드를 주시하다가 새 글이 올라오면 페디버스에 게시하는 봇을 만듭니다. 그 과정에서 예약 작업, 상태 저장, 멘션에 답장하기를 다루고, 나중에는 피드가 하나에서 여럿으로 늘어났을 때 필요해지는 createInstance()와 동적 봇 그룹까지 짚어 봅니다.
1부에서는 피드 하나를 감시하는 봇 하나를 만듭니다. 2부에서는 그 봇을 인스턴스로 바꿔서, 인스턴스를 멘션하며 피드 URL을 알려주는 것만으로 피드별 봇을 하나씩 등록할 수 있게 합니다. 각 부의 마지막에는 봇을 공개 인터넷에 노출시키고, ActivityPub.Academy에서 실제 Mastodon 계정으로 팔로우하고 멘션하고 지켜보며 테스트합니다.
이 튜토리얼은 시작하기를 이미 읽었다는 전제로 진행하므로, createBot()이나 Text를 처음부터 다시 설명하지는 않습니다. 완성된 프로젝트는 BotKit 저장소의 examples/rss-bot/에서도 볼 수 있습니다.
봇 하나로 시작하기
프로젝트 준비하기
새 디렉터리를 만들고, BotKit과 함께 피드의 원본 XML을 평범한 객체로 바꿔 줄 @rowanmanning/feed-parser를 설치합니다.
Deno
mkdir rss-bot && cd rss-bot
deno add jsr:@fedify/botkit npm:@rowanmanning/feed-parser
npm
mkdir rss-bot && cd rss-bot
npm init -y
npm add @fedify/botkit @rowanmanning/feed-parser
pnpm
mkdir rss-bot && cd rss-bot
pnpm init
pnpm add @fedify/botkit @rowanmanning/feed-parser
Yarn
mkdir rss-bot && cd rss-bot
yarn init -y
yarn add @fedify/botkit @rowanmanning/feed-parser
피드 가져오고 파싱하기
@rowanmanning/feed-parser는 RSS 0.9x, RSS 2.0, RDF Site Summary 1.0(RSS 1.0), Atom 0.3/1.0 피드를 모두 같은 parseFeed() 함수, 같은 Feed/FeedItem 형태로 파싱해 줍니다. 덕분에 봇 코드 쪽에서는 지금 이 피드가 어떤 포맷인지 굳이 분기할 필요가 없습니다.
parseFeed()가 하지 않는 일도 있습니다. 바로 가져오기(fetch)입니다. 이 함수는 이미 손에 쥔 XML 문자열을 객체로 바꿔 줄 뿐, 그 문자열을 어떻게 구할지는 호출하는 쪽의 몫입니다. 봇 코드도 이 구분을 그대로 따라서, 가져오기를 전담하는 feed.ts 모듈을 두고, 이를 어떤 주기로 폴링할지는 별도로 다룹니다.
feed.ts
// 피드(RSS, Atom, RDF)를 가져오고 파싱한다. 가져오기는 이 모듈의 일이고,
// 파싱은 전적으로 @rowanmanning/feed-parser의 일이다. 이 라이브러리는 피드를
// 그저 XML 문자열로만 볼 뿐, 그 문자열이 어떻게 조달됐는지는 신경 쓰지 않는다.
import { parseFeed } from "@rowanmanning/feed-parser";
// @rowanmanning/feed-parser의 공개 엔트리포인트는 parseFeed 함수만 내보낼 뿐
// 반환값인 Feed/FeedItem 타입은 내보내지 않는다. 그래서 패키지 내부 경로를
// 직접 뒤지는 대신, 함수의 반환 타입에서 역으로 타입을 끌어온다.
export type Feed = ReturnType<typeof parseFeed>;
export type FeedItem = Feed["items"][number];
export async function fetchFeed(
url: string | URL,
signal?: AbortSignal,
): Promise<Feed> {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(
`Failed to fetch feed: ${response.status} ${response.statusText}`,
);
}
return parseFeed(await response.text());
}
맨 위의 타입 유도 코드는 자잘한 우회로입니다. @rowanmanning/feed-parser 패키지는 parseFeed 함수만 내보낼 뿐 그 함수가 반환하는 Feed, FeedItem 클래스는 내보내지 않으므로, type { Feed }를 패키지에서 직접 가져오려 하면 실패합니다. ReturnType<typeof parseFeed>는 타입을 직접 이름 붙여 가져오는 대신, 함수 시그니처로부터 TypeScript가 스스로 타입을 유추하게 만들어 이 문제를 피해 갑니다.
폴링하고 게시하기
봇 자체는 단순하게 시작합니다. 봇을 만들고, 일정 주기로 피드를 폴링하고, 새로 올라온 글을 게시하는 것뿐입니다. 식별자를 비롯한 나머지 설정은 시작하기에서 본 패턴을 그대로 따르는데, 다만 여기서 쓰는 kv 스토어와 queue는 로컬 개발용 인메모리 구현입니다. 재시작이 문제가 되는 시점에 다른 것으로 교체할 예정입니다.
bot.ts
// RSS/Atom/RDF 피드를 일정 주기로 폴링하다가 새 글이 올라오면 페디버스에
// 게시하는 봇.
//
// 실행: deno serve --allow-net --allow-env --watch bot.ts
// 또는: npx srvx serve --port 8000 --entry ./bot.ts
// 설정: ORIGIN=https://your-domain
// FEED_URL=https://example.com/feed.xml (기본값은 Hacker News)
// POLL_INTERVAL_MS=600000 (기본값은 10분)
import {
createBot,
InProcessMessageQueue,
link,
MemoryKvStore,
text,
} from "@fedify/botkit";
import { fetchFeed } from "./feed.ts";
import type { FeedItem } from "./feed.ts";
const FEED_URL = process.env.FEED_URL ?? "https://news.ycombinator.com/rss";
const ORIGIN = process.env.ORIGIN ?? "http://localhost:8000";
const rawPollIntervalMs = process.env.POLL_INTERVAL_MS;
const POLL_INTERVAL_MS = rawPollIntervalMs == null || rawPollIntervalMs === ""
? 1000 * 60 * 10
: Number(rawPollIntervalMs);
if (!Number.isFinite(POLL_INTERVAL_MS) || POLL_INTERVAL_MS <= 0) {
throw new RangeError(
`POLL_INTERVAL_MS must be a positive number of milliseconds: ${rawPollIntervalMs}`,
);
}
const bot = createBot<void>({
username: "rssbot",
name: "RSS Bot",
summary: text`I watch ${link(FEED_URL)} and post new entries here.`,
kv: new MemoryKvStore(),
queue: new InProcessMessageQueue(),
});
function itemKey(item: FeedItem): string | null {
return item.id ?? item.url;
}
const posted = new Set<string>();
let firstPoll = true;
async function poll(): Promise<void> {
const feed = await fetchFeed(FEED_URL);
const items = [...feed.items].reverse(); // 피드는 최신 글이 맨 앞에 온다
if (firstPoll) {
// 봇을 맨 처음 띄운 순간 피드에 이미 올라와 있던 글 전부를 팔로워에게
// 쏟아붓지 않기 위함이다. 이후 폴링에서 새로 나타나는 글만 "새 글"로 친다.
firstPoll = false;
for (const item of items) {
const key = itemKey(item);
if (key != null) posted.add(key);
}
console.log(`Baseline: ${posted.size} existing item(s) from ${FEED_URL}.`);
return;
}
const session = bot.getSession(ORIGIN);
let publishedCount = 0;
for (const item of items) {
const key = itemKey(item);
if (key == null || posted.has(key)) continue;
await session.publish(
text`${item.title ?? "(untitled)"}
${link(item.url ?? FEED_URL)}`,
);
posted.add(key);
publishedCount++;
}
console.log(`Posted ${publishedCount} new item(s) from ${FEED_URL}.`);
}
let polling = false;
async function pollOnce(): Promise<void> {
if (polling) return; // 이전 폴링이 아직 진행 중이면 건너뛴다
polling = true;
try {
await poll();
} catch (error) {
console.error("Failed to poll feed:", error);
} finally {
polling = false;
}
}
pollOnce();
setInterval(pollOnce, POLL_INTERVAL_MS);
export default bot;
맨 처음 폴링에서는 피드에 이미 올라와 있던 글을 기록만 할 뿐, 게시하지는 않습니다. 이 처리가 없다면 봇을 처음 실행하는 순간 피드의 현재 첫 페이지 전체가 팔로워들에게 쏟아지는데, 이는 “새 글”이 뜻해야 할 바가 전혀 아닙니다. 다음 폴링에서 새로 나타나는 글만 새 글로 칩니다.
pollOnce()는 실제 폴링 로직을 polling 플래그로 감쌉니다. 느린 요청이 다음 예약 틱과 겹치지 않도록 하기 위해서입니다. setInterval()은 콜백이 끝나기를 기다리지 않고 다음 호출을 예약하므로, 이 방어 장치가 없으면 POLL_INTERVAL_MS보다 오래 걸리는 피드 요청 때문에 폴링 두 개가 동시에 돌면서 같은 글을 중복 게시할 수 있습니다.
POLL_INTERVAL_MS는 시작 시점에 한 번 파싱하고, 유한한 양수인지 검사합니다. 값이 비어 있거나 잘못돼서 그대로 setInterval(fn, NaN)으로 흘러 들어가면 에러도 나지 않고, 그냥 이벤트 루프의 거의 매 틱마다 실행되며 피드를 두들겨 댑니다. 실행 시점에 요란하게 실패하는 편이, 실행 중에 조용히 잘못되는 것보다 낫습니다.
FEED_URL의 기본값은 Hacker News의 첫 페이지 피드입니다. RSS든 Atom이든 RDF든 어떤 피드를 써도 되지만, 테스트하는 동안에는 자주 갱신되는 피드를 쓰는 편이 좋습니다. 봇이 실제로 뭔가 게시하는 모습을 보려고 오래 기다릴 필요가 없기 때문입니다.
봇 실행하기
시작하기에서와 마찬가지로 봇을 기본 내보내기(default export)로 내보내고 실행합니다.
Deno
deno serve --allow-net --allow-env --watch bot.ts
Node.js
npx srvx serve --port 8000 --entry ./bot.ts
콘솔에는 몇 초 안에 베이스라인 개수가 찍히고, 이후 POLL_INTERVAL_MS가 지나면 Posted 0 new item(s)가 뜨거나, 그사이 피드에 뭔가 새로 올라왔다면 실제로 글이 하나 게시됩니다. 10분을 기다리지 않고 바로 게시되는 모습을 보고 싶다면 지금은 주기를 짧게 잡아 봅니다.
POLL_INTERVAL_MS=60000 deno serve --allow-net --allow-env --watch bot.ts
Tip
fedify lookup은 URL이나 핸들로 임의의 액터나 오브젝트를 조회해서 ActivityPub 표현을 출력해 줍니다. 여기서 fedify lookup http://localhost:8000/ap/actor/bot을 실행해 보면, 더 나아가기 전에 봇의 액터 문서가 제대로 나오는지 빠르게 확인할 수 있습니다.
멘션에 답장하기
게시만 하고 응답은 전혀 하지 않는 봇은, 잘 돌아가고 있어도 죽은 것처럼 느껴집니다. onMention은 봇에게 할 말을 만들어 줍니다. 지금 어떤 피드를 보고 있는지, 얼마나 자주 확인하는지 같은 것들 말이죠.
bot.ts
function formatInterval(ms: number): string {
if (ms < 60_000) {
const seconds = Math.round(ms / 1000);
return `${seconds} second${seconds === 1 ? "" : "s"}`;
}
const minutes = Math.round(ms / 60_000);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
let feedTitle: string | null = null;
bot.onMention = async (_session, message) => {
await message.reply(
text`I'm watching ${
link(feedTitle ?? FEED_URL, FEED_URL)
} and check for new posts every ${formatInterval(POLL_INTERVAL_MS)}.`,
);
};
feedTitle은 poll()이 실행될 때마다 피드 자체의 <title>에서 값을 받아 옵니다(fetchFeed()가 반환한 직후에 feedTitle = feed.title;을 추가하면 됩니다). 그래서 답장은 언제나 봇이 방금 실제로 확인한 내용을 반영할 뿐, 한 번 타이핑해 놓고 갱신되지 않는 이름을 말하지 않습니다. 첫 폴링이 끝나기 전에는 피드의 URL을 대신 출력합니다.
formatInterval()이 존재하는 이유는, POLL_INTERVAL_MS가 테스트 중에는 60000처럼 작을 수도 있고 운영 환경에서는 기본값인 10분일 수도 있어서인데, “매 600000밀리초마다”는 봇이 사람에게 할 만한 말이 아니기 때문입니다.
재시작에도 살아남기
이 버전에는 아직 버그가 하나 남아 있는데, 재시작할 때만 드러납니다. 자주 갱신되는 피드를 감시하던 봇이 피드에 새 글이 올라온 직후, 다음 예약 폴링이 그 글에 닿기 전에 재시작되면, 그 글은 영영 게시되지 않고 실패한 흔적도 전혀 남지 않습니다. firstPoll과 posted 둘 다 프로세스 메모리에만 있던 값이라 재시작하면 초기화되므로, 봇은 바로 다음 폴링을 마치 이전에 한 번도 실행된 적이 없는 것처럼 취급합니다. 그 시점에 피드가 보여 주는 것을 그대로 베이스라인으로 삼아 “이미 본 글” 집합에 편입시켜 버릴 뿐, 그 “새” 글을 게시하는 일은 결코 일어나지 않습니다. 겉으로는 잘 돌아가는 것처럼 보이는 봇이 재시작이나 배포 때마다 조용히 글을 흘리고 있을 수 있는 것입니다.
해법은 봇 자신의 상태와 폴러의 상태를 모두 디스크에 남기는 것입니다. 그것도 하나가 아니라 SQLite 데이터베이스 두 개로 나눠서요.
bot.db는 온전히 @fedify/botkit-sqlite의 몫입니다. 여기에는 BotKit이 액터의 암호화 키, 보낸 액티비티, 그 밖에 Repository가 책임지는 모든 것을 저장합니다. 봇 자신의 코드는 이 안에 무엇이 들었는지 알 필요가 없고, 알려고 해서도 안 됩니다.
app.db는 이 프로젝트 자체의 스키마입니다. 어떤 글을 이미 게시했는지를 담아서, 재시작해도 잊어버리지 않게 합니다.
@fedify/botkit-sqlite를 설치합니다.
Deno
deno add jsr:@fedify/botkit-sqlite
npm
npm add @fedify/botkit-sqlite
pnpm
pnpm add @fedify/botkit-sqlite
Yarn
yarn add @fedify/botkit-sqlite
app.db는 Node.js 내장 모듈인 node:sqlite로 직접 엽니다.
db.ts
// app.db: 피드 폴러 자체의 상태. @fedify/botkit-sqlite의 SqliteRepository가
// 전담하는 bot.db와는 분리되어 있다. 지금은 어떤 피드 항목을 이미 게시했는지만
// 추적해서, 재시작해도 이미 본 것을 잊지 않게 한다.
//
// node:sqlite의 DatabaseSync API는 완전히 동기식이다. 아래 호출 중 어느
// 것도 await가 필요 없고, 붙일 수도 없다.
import { DatabaseSync } from "node:sqlite";
export function openAppDb(path: string): DatabaseSync {
const db = new DatabaseSync(path);
db.exec(`
CREATE TABLE IF NOT EXISTS posted_items (
item_id TEXT PRIMARY KEY,
posted_at TEXT NOT NULL
)
`);
// 특정 항목에 관한 것이 아닌 상태를 위한 작은 키-값 테이블. 맨 처음
// 폴링이 일어났는지 여부는 posted_items에서 추론하는 대신 여기에 둔다.
// 그렇게 하지 않으면, 첫 폴링 시점에 피드가 마침 비어 있거나 항목들에
// 쓸 만한 id/url이 없는 경우 베이스라인이 잡혔다는 사실 자체를 영영
// 기록하지 못하게 된다.
db.exec(`
CREATE TABLE IF NOT EXISTS app_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
return db;
}
export function isBaselined(db: DatabaseSync): boolean {
return (
db.prepare("SELECT 1 FROM app_meta WHERE key = 'baselined'").get() != null
);
}
export function markBaselined(db: DatabaseSync): void {
db.prepare(
"INSERT OR IGNORE INTO app_meta (key, value) VALUES ('baselined', '1')",
).run();
}
export function isPosted(db: DatabaseSync, itemId: string): boolean {
return (
db.prepare("SELECT 1 FROM posted_items WHERE item_id = ?").get(
itemId,
) != null
);
}
export function markPosted(db: DatabaseSync, itemId: string): void {
db.prepare(
"INSERT OR IGNORE INTO posted_items (item_id, posted_at) VALUES (?, ?)",
).run(itemId, new Date().toISOString());
}
Note
node:sqlite가 실험적 플래그에서 벗어나기 전 버전의 Node.js(22.13.0과 23.4.0 이전)에서는 시작할 때 ExperimentalWarning: SQLite is an experimental feature라는 줄이 뜹니다. 정상이니 신경 쓰지 않아도 되고, 모듈은 그대로 잘 동작합니다.
isBaselined()를 posted_items와 별도로 두는 데는 이유가 있습니다. “첫 폴링이 일어났는가”라는 질문의 답을 posted_items의 행 개수로 대신하려 하면 거의 항상은 맞아떨어지지만, 딱 하나 예외가 있습니다. 바로 그 첫 폴링에서 피드가 마침 항목을 하나도 반환하지 않거나, 쓸 만한 id/url이 없는 항목만 반환하는 경우입니다. app_meta는 그 시점에 표시할 수 있었던 항목이 몇 개였는지와 무관하게, “이 이정표에 도달했는가” 자체를 독립적으로 추적합니다.
bot.ts는 이제 두 데이터베이스를 모두 만들고, posted/firstPoll을 영속화된 대응물로 바꿉니다.
bot.ts
// RSS/Atom/RDF 피드를 일정 주기로 폴링하다가 새 글이 올라오면 페디버스에
// 게시하는 봇.
//
// 실행: deno serve --allow-net --allow-env --allow-read=./data --allow-write=./data --watch bot.ts
// 또는: npx srvx serve --port 8000 --entry ./bot.ts
// 설정: ORIGIN=https://your-domain
// FEED_URL=https://example.com/feed.xml (기본값은 Hacker News)
// POLL_INTERVAL_MS=600000 (기본값은 10분)
import {
createBot,
InProcessMessageQueue,
link,
MemoryKvStore,
text,
} from "@fedify/botkit";
import { SqliteRepository } from "@fedify/botkit-sqlite";
import { mkdirSync } from "node:fs";
import { fetchFeed } from "./feed.ts";
import type { FeedItem } from "./feed.ts";
import {
isBaselined,
isPosted,
markBaselined,
markPosted,
openAppDb,
} from "./db.ts";
mkdirSync("./data", { recursive: true });
const FEED_URL = process.env.FEED_URL ?? "https://news.ycombinator.com/rss";
const ORIGIN = process.env.ORIGIN ?? "http://localhost:8000";
// POLL_INTERVAL_MS를 환경 변수에서 파싱하고 검증하는 부분은 앞서와 똑같으므로
// 여기서는 생략한다.
const bot = createBot<void>({
username: "rssbot",
name: "RSS Bot",
summary: text`I watch ${link(FEED_URL)} and post new entries here.`,
kv: new MemoryKvStore(),
queue: new InProcessMessageQueue(),
repository: new SqliteRepository({ path: "./data/bot.db" }),
});
const appDb = openAppDb("./data/app.db");
function itemKey(item: FeedItem): string | null {
return item.id ?? item.url;
}
let feedTitle: string | null = null;
async function poll(): Promise<void> {
const feed = await fetchFeed(FEED_URL);
feedTitle = feed.title;
const items = [...feed.items].reverse(); // 피드는 최신 글이 맨 앞에 온다
// 이 봇이 태어나서 처음 실행되는 순간 피드의 현재 첫 페이지 전체를
// 팔로워에게 쏟아붓지 않기 위함이다. 이후 폴링에서 새로 나타나는 글만
// "새 글"로 친다. 이 값은 app.db에 남기 때문에, 재시작할 때마다가 아니라
// 통틀어 딱 한 번만 일어난다.
const isFirstEverPoll = !isBaselined(appDb);
const session = bot.getSession(ORIGIN);
let publishedCount = 0;
for (const item of items) {
const key = itemKey(item);
if (key == null || isPosted(appDb, key)) continue;
if (!isFirstEverPoll) {
await session.publish(
text`${item.title ?? "(untitled)"}
${link(item.url ?? FEED_URL)}`,
);
publishedCount++;
}
markPosted(appDb, key);
}
if (isFirstEverPoll) markBaselined(appDb);
console.log(
isFirstEverPoll
? `Baseline: ${items.length} existing item(s) from ${FEED_URL}.`
: `Posted ${publishedCount} new item(s) from ${FEED_URL}.`,
);
}
두 데이터베이스 파일 모두 어딘가 담길 곳이 필요합니다. Deno의 권한 모델을 생각하면 프로젝트 루트에 흩어놓지 않고 전용 디렉터리 하나에 모아 두는 편이 좋습니다. mkdirSync("./data", ...)가 그 디렉터리를 만들어 주므로, 프로세스는 파일시스템 전체가 아니라 이 경로 하나에만 읽기·쓰기 권한을 가지면 됩니다.
이제 더 넓어진 권한으로 봇을 다시 실행합니다.
Deno
deno serve --allow-net --allow-env --allow-read=./data --allow-write=./data --watch bot.ts
Node.js
npx srvx serve --port 8000 --entry ./bot.ts
새 글이 올라온 직후에 예전과 같은 방식으로 재시작해 봅니다. 이번에는 그 글이 베이스라인 속으로 사라지는 대신, 다음 폴링에 그대로 나타납니다.
실제로 공개하기
지금까지 봇은 localhost에서만 동작하면 됐습니다. 다른 페디버스 서버가 이 봇에 닿으려면 공개 주소가 필요합니다.
터널링 서비스를 쓰면 아직 내 서버가 없어도 “공개 주소” 문제의 절반을 해결할 수 있습니다. 이런 서비스들은 봇 앞에서 L7 리버스 프록시처럼 동작하므로, behindProxy를 켜서 이들이 붙이는 X-Forwarded-* 헤더를 신뢰하게 하고, 로컬 개발과 터널링된 실행이 같은 코드를 쓸 수 있도록 환경 변수로 읽어들입니다.
import { createBot } from "@fedify/botkit";
const BEHIND_PROXY = process.env.BEHIND_PROXY?.trim()?.toLowerCase() ===
"true";
const bot = createBot<void>({
// 나머지 옵션은 지면상 생략
behindProxy: BEHIND_PROXY,
});
그다음 터널을 띄웁니다. 이 튜토리얼에서는 fedify tunnel을 쭉 사용하지만, 이미 다른 서비스를 써 왔다면 아래 중 무엇을 써도 같은 방식으로 동작합니다.
fedify tunnel
fedify tunnel 8000
ngrok
ngrok http 8000
Tailscale Funnel
tailscale funnel 8000
Cloudflare Tunnel
cloudflared tunnel --url http://localhost:8000
fedify tunnel은 준비가 끝나면 공개 호스트명을 출력합니다.
✔ Your local server at 8000 is now publicly accessible:
https://c4d3933be87bc2.lhr.life/
Press ^C to close the tunnel.
이번에는 ORIGIN은 그 주소로, BEHIND_PROXY는 true로 맞춰서 봇을 다시 실행합니다.[1]
Deno
ORIGIN=https://c4d3933be87bc2.lhr.life BEHIND_PROXY=true \
deno serve --allow-net --allow-env --allow-read=./data --allow-write=./data --watch bot.ts
Node.js
ORIGIN=https://c4d3933be87bc2.lhr.life BEHIND_PROXY=true \
npx srvx serve --port 8000 --entry ./bot.ts
브라우저로 그 주소에 들어가 보면 봇 자신의 프로필 페이지가 나타납니다.

ActivityPub.Academy로 테스트하기
ActivityPub.Academy는 몇 초 만에 임시 Mastodon 계정을 하나 내주는데, 바로 이런 용도, 즉 봇을 팔로우하고 멘션하면서 어떤 답이 돌아오는지 지켜보기에 딱 맞습니다. 계정은 하루 뒤에 지워지므로 테스트가 끝난 뒤 따로 정리할 것도 없습니다.
가입한 다음, 봇의 페디버스 핸들로 검색합니다. @[email protected]이고, 자신의 터널 호스트명으로 바꿔 넣으면 됩니다.

검색 결과를 열고 Follow를 누릅니다.


그다음 멘션합니다.
Hey @[email protected], what are you up to?
답장은 몇 초 안에 도착합니다.

다음번에 봇의 폴링이 정말로 새로운 글을 잡아내면, 다른 여느 게시물과 마찬가지로 타임라인에 나타납니다.

봇 여럿, 인스턴스 하나
피드 하나로 아이디어는 확인했습니다. 하지만 FEED_URL 하나를 하드코딩하는 방식으로는 여러 피드를 동시에 돌리거나, 재배포 없이 피드를 추가하거나, 갈수록 시끄러워지는 타임라인 하나를 다 같이 쓰는 대신 피드마다 자기만의 페디버스 정체성을 갖게 하기는 어렵습니다. Instance는 바로 이럴 때를 위해 BotKit이 마련해 둔 조각입니다. 서버 하나가 여러 봇을 호스팅하되, 각 봇은 저마다 액터와 팔로워, 인박스를 가지면서도 같은 키-값 스토어, 큐, 리포지토리를 밑에서 함께 씁니다.
createBot()이 사라지는 것은 아닙니다. 딱 하나의 액터로만 존재하면 되는 봇이라면 굳이 바꿀 이유가 없습니다. 이제부터는 rssbot을, 등록된 피드마다 봇 하나씩을 호스팅하는 인스턴스로 바꾸고, 여기에 누군가 URL과 함께 멘션했을 때 피드를 추가하는 일만 하는 작은 등록 담당 봇을 하나 더합니다.
순진한 식별자, 그리고 그것이 깨지는 이유
피드별 봇은 저마다 자기만의 식별자가 필요합니다. 봇이 페디버스에 연합되는 순간 액터 URI에 새겨지는 내부 이름 말이죠. 가장 먼저 떠오르는 생각은 피드 자체에서, 이를테면 호스트명에서 하나를 뽑아내는 것입니다. https://xkcd.com/rss.xml을 xkcd-com으로 바꾸는 식으로요. 이 문자열은 마침 사용자명으로도 나쁘지 않습니다. 사람이 봇을 찾으려고 실제로 입력하는, @xkcd-com@your-domain의 사람이 읽을 수 있는 절반이니까요.
문제는 이걸 둘 다에 쓰려는 데서 생깁니다. 식별자는 봇이 한 번 연합되고 나면 영원히 고정돼 있어야 합니다. 원격 서버들이 캐시해 둔 액터 URI 안에 그 식별자를 그대로 담고 있어서, 나중에 바꾸면 모든 팔로워 관계와 다른 서버가 들고 있는 모든 참조가 고아가 돼 버립니다. 그런데 피드의 호스트명에는 그런 종류의 영속성이 없습니다. 사이트는 옮겨 다닙니다. 블로그가 도메인을 하나에서 다른 곳으로 옮기고도 여전히 같은 피드, 같은 팔로워를 가진 같은 피드일 수 있는데, 이미 옛 식별자를 갖고 있는 모든 이를 깨뜨리지 않으면서 식별자만 새 이름에 맞게 바꿀 방법은 없습니다. 사용자명으로는 더할 나위 없이 좋은 성질, 즉 기억하기 쉽고 봇의 실체와 맞닿아 있다는 성질이야말로, 식별자로는 나쁜 선택이 되게 만드는 바로 그 성질입니다.
해법은 이 둘을 떼어 놓는 것입니다. 식별자는 등록 시점에 한 번 배정되고 두 번 다시 손대지 않는, 의미 없는 문자열입니다. 슬러그는 피드의 URL에서 뽑아낸 것으로, 언제든 다시 계산해도 되고, “사람이 이 봇을 찾으려고 뭐라고 입력하는가”라는 질문에만 쓰일 뿐 “이 봇의 액터 URI에는 무엇이 들어 있는가”라는 질문에는 결코 쓰이지 않습니다.
feeds 테이블
app.db는 이 구분을 담을 feeds 테이블을 새로 얻고, posted_items는 봇 하나의 이력을 추적하는 대신 피드별로 범위가 좁혀집니다.
db.ts
// app.db: 피드 폴러 자체의 상태. @fedify/botkit-sqlite의 SqliteRepository가
// 전담하는 bot.db와는 분리되어 있다.
//
// node:sqlite의 DatabaseSync API는 완전히 동기식이다. 아래 호출 중 어느
// 것도 await가 필요 없고, 붙일 수도 없다.
//
// 피드의 identifier는 의미 없이 고정된 키다. 한 번 연합되고 나면 액터 URI에
// 새겨지므로, (피드의 URL이나 제목처럼) 바뀔 수 있는 것으로부터는 절대
// 유도해서는 안 된다. slug는 핸들에서 사람이 읽는 부분으로, mapUsername을
// 거쳐 identifier와 서로 오간다.
import { DatabaseSync } from "node:sqlite";
export interface FeedRow {
readonly identifier: string;
readonly url: string;
readonly slug: string;
readonly title: string | null;
readonly baselined: boolean;
}
function toFeedRow(row: Record<string, unknown>): FeedRow {
return {
identifier: row.identifier as string,
url: row.url as string,
slug: row.slug as string,
title: row.title as string | null,
baselined: (row.baselined as number) !== 0,
};
}
export function openAppDb(path: string): DatabaseSync {
const db = new DatabaseSync(path);
db.exec(`
CREATE TABLE IF NOT EXISTS feeds (
identifier TEXT PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
title TEXT,
baselined INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
`);
migrateLegacyPostedItems(db);
db.exec(`
CREATE TABLE IF NOT EXISTS posted_items (
feed_identifier TEXT NOT NULL,
item_id TEXT NOT NULL,
posted_at TEXT NOT NULL,
PRIMARY KEY (feed_identifier, item_id)
)
`);
return db;
}
// 1부의 app.db에는 posted_items(item_id, posted_at)만 있었고, 어느 피드에
// 속한 항목인지 하는 개념 자체가 없었다. 피드가 하나뿐이었으니 당연했다.
// openAppDb()의 CREATE TABLE IF NOT EXISTS가 옛 테이블을 그대로 놔둘 것이므로,
// 그 행들을 (그 글을 게시했을 수 있는 유일한 식별자인) "bot" 식별자 아래로
// 옮겨서 이어받는다.
function migrateLegacyPostedItems(db: DatabaseSync): void {
const columns = db.prepare("PRAGMA table_info(posted_items)").all() as {
readonly name: string;
}[];
const isLegacy = columns.length > 0 &&
!columns.some((column) => column.name === "feed_identifier");
if (!isLegacy) return;
db.exec("ALTER TABLE posted_items RENAME TO posted_items_legacy");
db.exec(`
CREATE TABLE posted_items (
feed_identifier TEXT NOT NULL,
item_id TEXT NOT NULL,
posted_at TEXT NOT NULL,
PRIMARY KEY (feed_identifier, item_id)
)
`);
db.exec(`
INSERT INTO posted_items (feed_identifier, item_id, posted_at)
SELECT 'bot', item_id, posted_at FROM posted_items_legacy
`);
db.exec("DROP TABLE posted_items_legacy");
}
function slugify(url: string): string {
return new URL(url).hostname.toLowerCase().replace(/\./g, "-");
}
export function getFeedByIdentifier(
db: DatabaseSync,
identifier: string,
): FeedRow | undefined {
const row = db.prepare("SELECT * FROM feeds WHERE identifier = ?").get(
identifier,
);
return row == null ? undefined : toFeedRow(row);
}
export function getFeedBySlug(
db: DatabaseSync,
slug: string,
): FeedRow | undefined {
const row = db.prepare("SELECT * FROM feeds WHERE slug = ?").get(slug);
return row == null ? undefined : toFeedRow(row);
}
export function getFeedByUrl(
db: DatabaseSync,
url: string,
): FeedRow | undefined {
const row = db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
return row == null ? undefined : toFeedRow(row);
}
export function listFeeds(db: DatabaseSync): readonly FeedRow[] {
const rows = db.prepare("SELECT * FROM feeds").all();
return rows.map(toFeedRow);
}
// 기존 단일 봇 배포의 피드를 시작 시점에 딱 한 번 이 테이블의 행으로
// 이어받는 데 쓴다. identifier가 기본 키이므로 첫 실행 이후로는 매번 아무
// 일도 하지 않는다.
export function seedFeed(
db: DatabaseSync,
feed: { identifier: string; url: string; slug: string },
): void {
db.prepare(
"INSERT OR IGNORE INTO feeds (identifier, url, slug, created_at) VALUES (?, ?, ?, ?)",
).run(feed.identifier, feed.url, feed.slug, new Date().toISOString());
migrateLegacyBaselined(db, feed.identifier);
}
// 1부의 app.db는 "첫 폴링이 일어났는가"를 별도의 app_meta 테이블에
// 추적했다. 물어볼 대상이 하나뿐이었으니 그럴 만했다. 이 피드의 행이
// (바로 위에서) 생기고 나면 그 플래그를 이 행으로 옮기고 app_meta는
// 지운다. app_meta가 더 이상 확인할 대상이 아니게 되는 첫 실행 이후로는
// 매번 아무 일도 하지 않는다.
function migrateLegacyBaselined(db: DatabaseSync, identifier: string): void {
const hasAppMeta = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'app_meta'",
).get() != null;
if (!hasAppMeta) return;
const wasBaselined = db.prepare(
"SELECT 1 FROM app_meta WHERE key = 'baselined'",
).get() != null;
if (wasBaselined) markFeedBaselined(db, identifier);
db.exec("DROP TABLE app_meta");
}
// 새로 멘션된 피드 URL을 새로운, 의미 없는 식별자로 등록한다. 식별자는
// URL이나 제목처럼 나중에 바뀔 수 있는 것으로부터는 절대 유도하지 않는다.
// slug(사람이 읽는 핸들)는 유도하며, 이미 쓰이고 있는 것과 충돌하면 숫자
// 접미사를 붙인다.
export function addFeed(db: DatabaseSync, url: string): FeedRow {
const baseSlug = slugify(url);
let slug = baseSlug;
for (let suffix = 2; getFeedBySlug(db, slug) != null; suffix++) {
slug = `${baseSlug}-${suffix}`;
}
const identifier = `feed_${
crypto.randomUUID().replace(/-/g, "").slice(0, 12)
}`;
db.prepare(
"INSERT INTO feeds (identifier, url, slug, created_at) VALUES (?, ?, ?, ?)",
).run(identifier, url, slug, new Date().toISOString());
return { identifier, url, slug, title: null, baselined: false };
}
export function updateFeedTitle(
db: DatabaseSync,
identifier: string,
title: string,
): void {
db.prepare("UPDATE feeds SET title = ? WHERE identifier = ?").run(
title,
identifier,
);
}
export function markFeedBaselined(db: DatabaseSync, identifier: string): void {
db.prepare("UPDATE feeds SET baselined = 1 WHERE identifier = ?").run(
identifier,
);
}
export function isPosted(
db: DatabaseSync,
feedIdentifier: string,
itemId: string,
): boolean {
return (
db.prepare(
"SELECT 1 FROM posted_items WHERE feed_identifier = ? AND item_id = ?",
).get(feedIdentifier, itemId) != null
);
}
export function markPosted(
db: DatabaseSync,
feedIdentifier: string,
itemId: string,
): void {
db.prepare(
"INSERT OR IGNORE INTO posted_items (feed_identifier, item_id, posted_at) VALUES (?, ?, ?)",
).run(feedIdentifier, itemId, new Date().toISOString());
}
identifier와 slug 둘 다 UNIQUE 제약을 갖지만, 충돌 시 다시 계산해서 재시도하는 것은 slug뿐입니다. identifier는 addFeed()가 한 번 생성하면 그대로 놔둡니다. baselined는 1부에서는 별도의 app_meta 테이블에 있으면서 앱 전체가 폴링을 한 번이라도 한 적 있는지를 추적했지만, 이제 첫 폴링 여부가 앱 전체가 아니라 특정 피드 하나에 관한 사실이 되면서 feeds의 평범한 칼럼 하나로 합쳐집니다.
slugify()는 피드 호스트명을 소문자로 바꾸고 점을 하이픈으로 바꿉니다. news.ycombinator.com은 news-ycombinator-com이 됩니다. 그리고 그 슬러그가 이미 쓰이고 있으면 addFeed()가 숫자 접미사(-2, -3, …)를 붙입니다. 반면 identifier는 crypto.randomUUID()에서 생성한, feed_로 시작하는 짧은 무작위 문자열로, 피드에 관한 그 무엇으로부터도 유도되지 않습니다.
위의 두 migrateLegacy* 함수가 존재하는 이유는, 1부에서 쓰던 기존 app.db에는 여전히 옛 posted_items(item_id, posted_at) 테이블과 별도의 app_meta 키-값 테이블이 있고, CREATE TABLE IF NOT EXISTS는 이 둘 중 어느 것도 건드리지 않기 때문입니다. 이미 존재하는 테이블은 그냥 그대로 두므로, 옛 posted_items는 이 파일의 나머지 부분이 기대하는 feed_identifier 칼럼이 없는 채로 남고, isPosted()를 처음 호출하는 순간 no such column: feed_identifier로 실패하고 말 것입니다. migrateLegacyPostedItems()는 openAppDb()가 뭔가를 만들기 전에 그 모양을 확인해서 발견하면 마이그레이션하고, migrateLegacyBaselined()는 app_meta의 baselined 플래그에 대해 같은 일을 하되, 그 플래그가 속할 피드 행이 실제로 존재하게 된 다음 seedFeed()에서 호출됩니다. 둘 다 실행 전에 옛 모양인지부터 확인하므로, 기존 app.db가 아예 없는 새 설치라면 그냥 건너뛰고, 이미 마이그레이션된 것이라면 그 이후 실행마다 건너뜁니다. posted_items에는 이미 feed_identifier가 있고, app_meta는 이미 사라졌으니까요.
봇에서 인스턴스로
bot.ts는 이제 봇 하나가 아니라 여럿을 호스팅하므로 instance.ts가 됩니다.
instance.ts
import {
createInstance,
InProcessMessageQueue,
MemoryKvStore,
} from "@fedify/botkit";
import { SqliteRepository } from "@fedify/botkit-sqlite";
import { mkdirSync } from "node:fs";
import { openAppDb, seedFeed } from "./db.ts";
// FEED_URL, BEHIND_PROXY는 앞서와 같은 방식으로 환경 변수에서 읽어 온다.
// 여기서는 생략한다.
mkdirSync("./data", { recursive: true });
const instance = createInstance<void>({
kv: new MemoryKvStore(),
queue: new InProcessMessageQueue(),
repository: new SqliteRepository({ path: "./data/bot.db" }),
behindProxy: BEHIND_PROXY,
// 1부의 봇은 identifier를 명시적으로 지정한 적이 없으므로, createBot()이
// 기본값으로 "bot"을 붙였다. "rssbot"은 어디까지나 사용자명이었을 뿐이다.
// (아래의 legacyObjectUris가 아니라) 바로 이 identifier를 그대로 재사용하는
// 것이 액터의 URI와 키, 팔로워 관계를 그대로 지켜 주는 열쇠다.
// legacyObjectUris는 원격 서버가 이 봇이 인스턴스로 옮겨 오기 전부터
// 캐시해 두고 있을 수 있는, 개별 오브젝트(게시물, 팔로우) URI의 *옛*
// 형식만 다시 써 준다.
legacyObjectUris: { identifier: "bot" },
});
const appDb = openAppDb("./data/app.db");
// 원래 피드를 봇의 실제(기본) identifier와 기존 사용자명 그대로 여기 행으로
// 이어받는다. 그래서 이 피드는 이제부터 등록되는 다른 모든 피드와 똑같은
// 동적 봇 그룹이 처리한다. 첫 실행 이후로는 아무 일도 하지 않는다.
seedFeed(appDb, { identifier: "bot", url: FEED_URL, slug: "rssbot" });
createInstance()는 createBot()이 쓰던 것과 같은 인프라 옵션들, 즉 kv, queue, repository, behindProxy를 그대로 받습니다. 새로 등장한 것은 legacyObjectUris입니다. 1부의 rssbot은 identifier 옵션을 명시적으로 지정한 적이 없으므로 createBot()이 기본값으로 "bot"을 붙였습니다. "rssbot"은 어디까지나 사용자명이었을 뿐입니다. legacyObjectUris가 아니라 바로 이 같은 identifier를 재사용하는 것이, 이제 서버에 봇 하나만 있는 게 아니라 여럿 중 하나가 된 지금도 원래 봇의 액터 URI와 암호화 키, 팔로워 관계를 온전히 지켜 주는 열쇠입니다. legacyObjectUris는 좀 더 좁은 범위, 즉 createInstance()로 옮기기 전에 게시된 개별 오브젝트 URI(게시물과 팔로우)의 옛 형식을 다룹니다. 원격 서버가 여전히 그 형식을 캐시하고 있다가 나중에 Accept나 Like로 돌려보낼 수 있기 때문입니다.
seedFeed()는 이 같은 봇을 평범한 행 하나로 이어받습니다. 같은 identifier, 같은 URL, 슬러그로는 같은 사용자명을 씁니다. INSERT OR IGNORE이므로 첫 실행 이후로는 매번 아무 일도 하지 않고, 이제부터 원래 봇은 나중에 등록되는 어떤 피드와도 똑같은 동적 봇 그룹이 처리하며, 다른 어디에도 별도의 특수 처리는 없습니다.
이게 가능하려면 미리 이뤄져야 할 마이그레이션이 있는데, 이 코드 자체가 하는 일은 아닙니다. BotKit은 이미 createBot() 배포의 리포지토리 데이터를 createInstance()가 기대하는 구조로 마이그레이션해 주지만, 그 배포가 createBot() 아래에서 BotKit 0.5.0 이상으로 처음 실행될 때 딱 한 번만 그렇습니다. 여기까지 왔다면 1부의 봇은 그저 실행된 것만으로 이미 그 과정을 마쳤을 것입니다. 만약 0.5.0에서 아예 실행된 적이 없는 배포를 옮기는 중이라면, 먼저 createBot()으로 한 번 실행하거나 리포지토리의 migrate() 메서드를 직접 호출하세요. 자세한 내용은 단일 봇 배포 마이그레이션하기를 참고하세요. createInstance()로 바꾸기 전후로 액터의 URI에 fedify lookup을 직접 돌려 보는 것도 해 볼 만합니다. 공개 키와 WebFinger 핸들이 완전히 똑같이 나와야 합니다.
인스턴스 하나에 피드 여럿
// 동적 봇 그룹: feeds 테이블의 행 하나마다 봇 하나씩을, 필요할 때마다
// 해석한다. 피드가 추가될 때마다 명령형으로 createBot()을 호출하는 대신,
// "피드마다 봇 하나"를 사전에 한 번의 등록으로 바꿔 주는 것이 바로 이
// 부분이다. 아래의 registryBot.onMention을 참고할 것.
const feedBots = instance.createBot(
(_ctx, identifier) => {
const feed = getFeedByIdentifier(appDb, identifier);
if (feed == null) return null;
return { username: feed.slug, name: feed.title ?? feed.url };
},
{
mapUsername(_ctx, username) {
const feed = getFeedBySlug(appDb, username);
return feed?.identifier ?? null;
},
},
);
feedBots.onMention = async (session, message) => {
const feed = getFeedByIdentifier(appDb, session.bot.identifier);
if (feed == null) return;
await message.reply(
text`I'm watching ${
link(feed.title ?? feed.url, feed.url)
} and check for new posts every ${formatInterval(POLL_INTERVAL_MS)}.`,
);
};
instance.createBot()에 고정된 식별자 대신 함수를 넘기면 BotGroup이 만들어집니다. 사전에 선언해 두는 대신 필요할 때마다 해석되는 봇들의 집합인데, BotKit 자체 문서에서 지역별 날씨 봇을 만들 때 쓰는 것과 같은 동적 봇 패턴입니다. 여기 디스패처가 하는 일도 같은 종류의 조회입니다. 식별자가 주어지면 feeds에서 대응하는 행을 찾고, 없으면 null을 반환해서 BotKit에게 이 식별자는 애초에 피드 봇이 아니라고 알려 줍니다. mapUsername은 그 반대 방향으로, identifier가 아니라 slug로 같은 조회를 수행합니다. 그래서 @xkcd-com@your-domain과, 무작위로 생성된 feed_a1b2c3d4e5f6을 식별자로 가진 액터가 양쪽 방향 모두에서 같은 봇으로 해석됩니다.
BotGroup은 자기만의 단일한 식별자를 갖지 않으므로, 이를 통해 게시하려면 어느 봇인지 말해 줘야 합니다. 1부에서 쓰던 bot.getSession(origin) 대신 feedBots.getSession(origin, identifier)를 씁니다. 폴링은 feeds의 모든 행을 돌면서, 피드마다 정확히 그렇게 한 번씩 합니다.
const FETCH_TIMEOUT_MS = 30_000;
async function pollFeed(feed: FeedRow): Promise<void> {
const parsed = await fetchFeed(
feed.url,
AbortSignal.timeout(FETCH_TIMEOUT_MS),
);
if (parsed.title != null && parsed.title !== feed.title) {
updateFeedTitle(appDb, feed.identifier, parsed.title);
}
const items = [...parsed.items].reverse(); // 피드는 최신 글이 맨 앞에 온다
// 이 피드가 처음 폴링되는 순간 현재 첫 페이지 전체를 팔로워에게
// 쏟아붓지 않기 위함이다. 이후 폴링에서 새로 나타나는 글만 "새 글"로 친다.
const isFirstEverPoll = !feed.baselined;
const session = await feedBots.getSession(ORIGIN, feed.identifier);
let publishedCount = 0;
for (const item of items) {
const key = itemKey(item);
if (key == null || isPosted(appDb, feed.identifier, key)) continue;
if (!isFirstEverPoll) {
await session.publish(
text`${item.title ?? "(untitled)"}
${link(item.url ?? feed.url)}`,
);
publishedCount++;
}
markPosted(appDb, feed.identifier, key);
}
if (isFirstEverPoll) markFeedBaselined(appDb, feed.identifier);
console.log(
isFirstEverPoll
? `Baseline: ${items.length} existing item(s) from ${feed.url}.`
: `Posted ${publishedCount} new item(s) from ${feed.url}.`,
);
}
async function pollAll(): Promise<void> {
for (const feed of listFeeds(appDb)) {
try {
await pollFeed(feed);
} catch (error) {
console.error(`Failed to poll feed ${feed.url}:`, error);
}
}
}
여기서는 식별자와 “이미 게시했음” 상태가 어디서 오는지를 빼면 1부의 폴링 로직과 다를 게 없습니다. 바깥 스코프에서 붙잡아 온 변수 대신 feed.identifier를 쓰고, 하나로 공유하던 Set 대신 그 identifier로 범위를 좁힌 isPosted/markPosted를 쓰고, fetchFeed()에 명시적인 AbortSignal.timeout()을 건다는 점이 다를 뿐입니다.
pollAll()은 피드를 하나씩 순서대로 기다리고, pollAllOnce()의 polling 가드 덕분에 현재 주기가 끝나기 전까지는 어떤 나중 틱도 새 주기를 시작할 수 없습니다.
이 타임아웃이 없다면, 연결은 받아 놓고 응답은 영영 하지 않는 서버 하나 때문에 그 서버가 물려 있는 피드뿐 아니라 인스턴스의 모든 피드가 영원히 멈춰 버릴 것입니다. 타임아웃이 있으면 이는 그저 평범한 피드별 실패, 이를테면 요청 시간 초과나 피드가 갑자기 이상한 XML을 반환하는 것과 다를 바 없어지고, pollAll()의 try/catch가 이를 똑같은 방식으로 처리합니다. 로그를 남기고 다음 피드로 넘어가는 식으로요.
멘션으로 피드 등록하기
지금까지 그룹에 봇을 하나 더한다는 것은 결국 feeds에 행을 하나 넣는다는 뜻이었습니다. feedBots의 디스패처는 그 테이블에 나타나는 식별자라면 무엇이든, 시작할 때부터 있던 것이든 방금 삽입된 것이든 상관없이 이미 알아서 해석해 주므로, 일단 피드가 등록되고 나면 instance.createBot()을 다시 호출할 필요가 전혀 없습니다. 바로 이것이 정적 봇 그룹 대신 동적 봇 그룹을 쓰는 이유입니다. 등록이 배포가 아니라 데이터베이스 쓰기 한 번으로 끝나는 것 말이죠.
작은 정적 봇 registry가 그 쓰기를 위한 대문 역할을 합니다.
function extractUrl(input: string): string | null {
const match = input.match(/https?:\/\/\S+/)?.[0];
return match != null && URL.canParse(match) ? match : null;
}
// 정적 봇: 등록 창구. 피드 URL과 함께 이 봇을 멘션하면 feeds 테이블에
// 행이 하나 추가된다. feedBots의 디스패처는 그 새 행을, 다음번에 그
// identifier가 해석될 때 알아서 집어 든다.
//
// 이 코드는 누가 멘션을 보냈는지 확인하지 않고, 아래의 폴링 루프가 가져오기
// 전에 그 URL을 검증하지도 않는다. 실제 배포에 필요한 것은 튜토리얼의
// "더 해볼 만한 것들" 절을 참고할 것.
const registryBot = instance.createBot("registry", {
username: "registry",
name: "Feed Registry",
summary: text`Mention me with a feed URL to register a new feed bot.`,
});
registryBot.onMention = async (_session, message) => {
const url = extractUrl(message.text);
if (url == null) {
await message.reply(text`Please include a feed URL in your mention.`);
return;
}
const existing = getFeedByUrl(appDb, url);
if (existing != null) {
await message.reply(
text`Already watching that feed: ${
mention(`@${existing.slug}@${new URL(ORIGIN).host}`)
}.`,
);
return;
}
const feed = addFeed(appDb, url);
await message.reply(
text`Registered! Give it a few minutes, then look for ${
mention(`@${feed.slug}@${new URL(ORIGIN).host}`)
}.`,
);
};
extractUrl()은 멘션 텍스트 안에서 URL처럼 생기고 URL.canParse()를 통과하는 첫 번째 것을 찾습니다. 잘린 http://%처럼 형식이 잘못된 것은 걸러 내는데, 그렇지 않으면 addFeed()의 new URL() 호출까지 그대로 흘러 들어가 예외를 던지게 됩니다. 이미 감시 중인 URL을 등록하려 하면 feeds.url의 UNIQUE 제약에 부딪혀 죽는 대신 답장으로 알려 줍니다.
두 답장 모두 새 봇의 핸들을 mention()으로 만들지, 템플릿에 그대로 끼워 넣은 평범한 @${slug}@${host} 문자열로 만들지 않습니다. 평범한 텍스트로 적은 핸들은 그냥 텍스트일 뿐입니다. 링크로 렌더링되지도 않고, mention()이 붙여 주는 Mention 태그가 없으니 실제 멘션처럼 동작하지도 않습니다. mention()은 사람이 검색하듯 그 핸들을 실제로 찾아보고, 그 조회가 실패할 때만 평범한 텍스트로 물러나므로, 새 봇의 액터를 어쩌다 아직 해석할 수 없는 상황이라도 답장이 깨지는 대신 우아하게 낮은 수준으로 대응합니다.
registryBot.onMention이 하지 않는 일도 있습니다. 누가 묻고 있는지 확인하지 않고, pollFeed()에 곧 넘길 그 URL이 실제로 가져오기에 안전한 곳을 가리키는지도 확인하지 않습니다. 둘 다 의도적으로 남겨 둔 빈틈이고, 둘 다 여기가 아니라 아래 더 해볼 만한 것들에서 다룹니다.
두 런타임에서 인스턴스 검증하기
1부의 첫 실행과 같은 모양입니다. 다만 이제 단일 봇이 아니라 인스턴스를 호스팅하는 파일을 대상으로 합니다.
Deno
deno serve --allow-net --allow-env --allow-read=./data --allow-write=./data --watch instance.ts
Node.js
npx srvx serve --port 8000 --entry ./instance.ts
앞서와 같은 방식으로 터널을 열고, ActivityPub.Academy의 계정에서 피드 URL과 함께 등록 봇을 멘션합니다. 스킴을 포함한 전체 URL을 그대로 입력하세요. extractUrl()은 http://나 https:// 링크만 매칭하는데, Mastodon은 자동 링크된 URL을 보여 줄 때 스킴을 감춰서 표시하므로, 아래에는 스킴이 보이지 않는 것입니다.
Hey @registry, please watch https://xkcd.com/rss.xml

답장의 핸들은 mention()으로 만들어졌으므로, 그 답장을 이루는 실제 Note 객체에는 제대로 된 Mention 태그가 붙어 있고, 새 봇에게 직접 주소가 지정되어 있습니다. 그럴듯하게 보이도록 형식만 갖춘 게 아니라요. 위 스크린샷에서 ActivityPub.Academy는 이를 클릭 가능한 링크로 렌더링하지 않지만, 그 밑에 있는 데이터 자체는 정확합니다. 등록 봇 자신의 웹 페이지는 같은 답장을, 링크가 온전히 살아 있는 채로 보여 줍니다.

1~2분 뒤, 새 봇이 자기 슬러그로 나타납니다. 이 봇과 이관된 원래 봇을 둘 다 팔로우해 보면, 이 둘이 서로의 별칭이 아니라 진짜로 별개인 액터임을 확인할 수 있습니다.

새 봇을 직접 멘션하면, 등록 봇이 아니라 feedBots.onMention이 답합니다.

여기까지의 모든 과정은 Node.js에서도 똑같이 동작합니다. instance.ts를 두 번째 호스트명 뒤에서 터널링하고, 같은 방식으로 피드를 등록하면 같은 핸들이 WebFinger를 통해 그대로 해석됩니다.
서버에 배포하기
터미널이 아니라 프로세스 매니저 아래에서 instance.ts를 실행하는 것은 직접 호스팅하기의 systemd·Caddy 구성을 거의 그대로 따릅니다. 런타임 설치, botkit 사용자 만들기, Caddy 설정, 서비스 활성화까지 전체 과정은 그 문서에 있습니다. 이 봇에 특유한 것 두 가지만 짚고 넘어가면 되는데, 둘 다 examples/rss-bot/deploy/의 systemd 유닛과 Caddyfile에 이미 반영되어 있습니다.
ExecStart는 이 튜토리얼에서 쭉 써 온 것과 같은, 범위를 좁힌 Deno 권한(--allow-net --allow-env --allow-read=./data --allow-write=./data)을 쓰지, 직접 호스팅하기의 범용 -A를 쓰지 않습니다. 그리고 ProtectSystem=strict가 ReadWritePaths에 나열되지 않은 한 작업 디렉터리 전체를 읽기 전용으로 만들어 버리는데, 이 봇은 bot.db와 app.db를 ./data 아래에 쓰므로 유닛에 다음을 추가합니다.
ReadWritePaths=/opt/botkit/data
그 경로는 서비스가 시작되기 전에 botkit 사용자 소유로 이미 존재해야 합니다.
sudo mkdir -p /opt/botkit/data
sudo chown botkit:botkit /opt/botkit/data
직접 호스팅하기 자체의 mkdir -p /opt/botkit 단계 바로 다음에 실행하면 됩니다. 이걸 건너뛰면 유닛은 아예 시작조차 하지 못하고 종료 코드 226/NAMESPACE를 냅니다. systemd는 이미 존재하는 ReadWritePaths 항목만 허용 목록에 올리기 때문입니다.
Node.js에서는 이번에는 srvx가 npx를 통해서만 실행되는 게 아니라, 실제로 봇의 의존성과 함께 배포돼야 합니다. package.json에 일반 의존성으로 들어 있으므로, npm ci(또는 배포를 빌드한 패키지 매니저에 맞는 대응 명령)가 ExecStart가 호출하는 바로 그 바이너리인 /opt/botkit/node_modules/.bin/srvx를 설치해 줍니다.
Caddyfile은 그 밖의 나머지 부분에서 직접 호스팅하기를 그대로 따릅니다. ACME 연락처 이메일을 이용한 자동 HTTPS, localhost:8000으로의 리버스 프록시, 그리고 같은 기본 보안 헤더까지요.
더 해볼 만한 것들
아래 내용은 *examples/rss-bot/*에는 구현되어 있지 않습니다. 실제 배포라면 대체로 아래 순서로 손보게 될 만한 것들입니다.
- 피드가 알려 주는 갱신 힌트: RSS의 선택 사항인
<ttl>엘리먼트와 Syndication 모듈의<sy:updatePeriod>,<sy:updateFrequency>,<sy:updateBase>는 모두 피드가 봇이 모든 피드에 똑같이POLL_INTERVAL_MS를 추측해 적용하는 대신, 스스로 폴링 주기를 제안할 수 있게 해 줍니다. Atom에는 이에 대응하는 표준이 없습니다. - HTTP 조건부 요청: 매 폴링마다
If-Modified-Since나If-None-Match를 함께 보내고304 Not Modified응답을 처리하면, 전혀 바뀌지 않은 피드를 다시 파싱하는 일을 건너뛸 수 있습니다. - WebSub:
<atom:link rel="hub" href="…">를 광고하는 피드라면,setInterval()대신 웹훅으로 폴링을 기다리는 게 아니라 갱신을 직접 밀어 넣을 수 있습니다. - 피드를 등록할 수 있는 사람의 허용 목록: 지금은 URL이 담긴 멘션이 등록 봇에게 오기만 하면 누구든 그 URL을 등록할 수 있습니다.
- 멘션된 URL을 가져오기 전에 검증하기:
registryBot.onMention은extractUrl()이 찾은 것을 그대로pollFeed()의fetch()호출로 넘길 뿐,localhost나 내부 주소를 가리키는 것을 막을 장치가 전혀 없습니다. @fedify/vocab-runtime의validatePublicUrl()이 정확히 이런 용도로 존재하니, 처음부터 새로 짤 필요는 없습니다.
호스트명은 사용하는 터널링 서비스에 따라 실제로는 다르게 나옵니다. ↩︎






