Skip to content

Commit 43a9fd2

Browse files
authored
Merge pull request #53 from shin902/feat/rss-feed-polling
feat: RSSフィードの自動配信機能を追加
2 parents bfaed12 + 196024c commit 43a9fd2

8 files changed

Lines changed: 1068 additions & 1 deletion

File tree

.claude/skills/setup-rss/SKILL.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
name: setup-rss
3+
description: Configure RSS feed polling for NanoClaw channels. Adds rss.channels section to nanoclaw.yaml with per-channel feed URLs, and restarts the service. Triggers on "setup rss", "add rss", "rss feed", or "rss 配信".
4+
---
5+
6+
# RSS Feed Setup
7+
8+
Add RSS feed polling to NanoClaw so new articles are automatically delivered to configured channels every 15 minutes.
9+
10+
## 1. Check Current Configuration
11+
12+
Read `nanoclaw.yaml` to see if an `rss` section already exists:
13+
14+
```bash
15+
cat nanoclaw.yaml
16+
```
17+
18+
If the file doesn't exist, create it with just the `rss` section. If it already has a `providers` section, add the `rss` section alongside it.
19+
20+
## 2. Ask User for Configuration
21+
22+
Use `AskUserQuestion` to ask:
23+
24+
1. **Which channel ID(s)** should receive RSS updates? (e.g., `dc:1234567890`)
25+
2. **Which feed URLs?** — one or more RSS/Atom feed URLs to monitor.
26+
27+
## 3. Add RSS Section to nanoclaw.yaml
28+
29+
Add or update the `rss` section in `nanoclaw.yaml`. The structure is:
30+
31+
```yaml
32+
rss:
33+
channels:
34+
- jid: "dc:1234567890"
35+
feeds:
36+
- url: "https://example.com/feed.xml"
37+
name: "Example Blog"
38+
- url: "https://another.example/rss"
39+
```
40+
41+
- `jid`: The channel JID (must match a registered group in NanoClaw)
42+
- `feeds[].url`: RSS/Atom feed URL (required)
43+
- `feeds[].name`: Display name for the feed (optional, used in messages)
44+
45+
Use the Edit tool to insert the `rss` section. If `nanoclaw.yaml` already has content, add `rss` at the same level as `providers`.
46+
47+
## 4. Verify Channel Registration
48+
49+
Ensure the JIDs specified in the RSS config correspond to registered groups:
50+
51+
```bash
52+
sqlite3 store/messages.db "SELECT jid, name, folder FROM registered_groups"
53+
```
54+
55+
If a JID is not registered, RSS items for that channel will be skipped with a warning log. Register the channel first using the NanoClaw interface.
56+
57+
## 5. Restart NanoClaw
58+
59+
```bash
60+
# macOS
61+
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
62+
63+
# Linux
64+
systemctl --user restart nanoclaw
65+
66+
# Or manual restart
67+
npm run dev
68+
```
69+
70+
The RSS poller starts automatically on boot and checks for new articles every 15 minutes.
71+
72+
## 6. Verify It Works
73+
74+
Check the logs for RSS poller startup:
75+
76+
```bash
77+
tail -f logs/nanoclaw.log | grep -i rss
78+
```
79+
80+
You should see: `RSS poller started (15-minute interval)` and `RSS config loaded from nanoclaw.yaml`.
81+
82+
## Notes
83+
84+
- RSS items are tracked in the `rss_seen_items` SQLite table — restarts do not cause duplicate deliveries.
85+
- The `guid` element is used as the unique item identifier, falling back to the article URL.
86+
- Feed fetch failures are logged as warnings and do not crash the poller.
87+
- To add or remove feeds, edit `nanoclaw.yaml` and restart. Config is re-read on each poll cycle.

package-lock.json

Lines changed: 76 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
},
2020
"dependencies": {
2121
"better-sqlite3": "^11.8.1",
22-
"discord.js": "^14.18.0",
2322
"cron-parser": "^5.5.0",
23+
"discord.js": "^14.18.0",
24+
"fast-xml-parser": "^5.7.2",
2425
"pino": "^9.6.0",
2526
"pino-pretty": "^13.0.0",
2627
"yaml": "^2.8.2",

src/db.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,15 @@ function createSchema(database: Database.Database): void {
274274
);
275275
CREATE INDEX IF NOT EXISTS idx_spawned_threads_created_at
276276
ON spawned_threads(created_at);
277+
278+
CREATE TABLE IF NOT EXISTS rss_seen_items (
279+
feed_url TEXT NOT NULL,
280+
item_id TEXT NOT NULL,
281+
seen_at TEXT NOT NULL,
282+
PRIMARY KEY (feed_url, item_id)
283+
);
284+
CREATE INDEX IF NOT EXISTS idx_rss_seen_items_seen_at
285+
ON rss_seen_items(seen_at);
277286
`);
278287

279288
// sessions テーブルのスキーマを group_folder → group_jid に移行。
@@ -470,6 +479,25 @@ function startSpawnedThreadsCleanupTimer(): void {
470479
spawnedThreadsCleanupTimer.unref();
471480
}
472481

482+
const RSS_SEEN_ITEMS_RETENTION_DAYS = 90;
483+
const RSS_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
484+
let rssCleanupTimer: ReturnType<typeof setInterval> | null = null;
485+
486+
function startRssCleanupTimer(): void {
487+
if (rssCleanupTimer) {
488+
return;
489+
}
490+
491+
rssCleanupTimer = setInterval(() => {
492+
try {
493+
cleanupRssSeenItems();
494+
} catch (error) {
495+
logger.warn({ err: error }, 'Failed to clean up rss_seen_items');
496+
}
497+
}, RSS_CLEANUP_INTERVAL_MS);
498+
rssCleanupTimer.unref();
499+
}
500+
473501
export function initDatabase(): void {
474502
const dbPath = path.join(STORE_DIR, 'messages.db');
475503
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -481,6 +509,8 @@ export function initDatabase(): void {
481509
migrateJsonState();
482510
cleanupSpawnedThreads();
483511
startSpawnedThreadsCleanupTimer();
512+
cleanupRssSeenItems();
513+
startRssCleanupTimer();
484514
}
485515

486516
/** @internal - テスト用のみ。新規のインメモリデータベースを作成します。 */
@@ -490,6 +520,11 @@ export function _initTestDatabase(): void {
490520
spawnedThreadsCleanupTimer = null;
491521
}
492522

523+
if (rssCleanupTimer) {
524+
clearInterval(rssCleanupTimer);
525+
rssCleanupTimer = null;
526+
}
527+
493528
db = new Database(':memory:');
494529
createSchema(db);
495530
}
@@ -1206,6 +1241,70 @@ export function cleanupSpawnedThreads(
12061241
return result.changes;
12071242
}
12081243

1244+
// --- RSS 既読管理 ---
1245+
1246+
export function hasSeenItem(feedUrl: string, itemId: string): boolean {
1247+
const row = db
1248+
.prepare('SELECT 1 FROM rss_seen_items WHERE feed_url = ? AND item_id = ?')
1249+
.get(feedUrl, itemId);
1250+
return row !== undefined;
1251+
}
1252+
1253+
const SQLITE_VARIABLE_LIMIT = 900;
1254+
1255+
export function getSeenItemIds(
1256+
feedUrl: string,
1257+
itemIds: string[],
1258+
): Set<string> {
1259+
if (itemIds.length === 0) return new Set();
1260+
const seen = new Set<string>();
1261+
for (let i = 0; i < itemIds.length; i += SQLITE_VARIABLE_LIMIT) {
1262+
const chunk = itemIds.slice(i, i + SQLITE_VARIABLE_LIMIT);
1263+
const placeholders = chunk.map(() => '?').join(',');
1264+
const rows = db
1265+
.prepare(
1266+
`SELECT item_id FROM rss_seen_items WHERE feed_url = ? AND item_id IN (${placeholders})`,
1267+
)
1268+
.all(feedUrl, ...chunk) as { item_id: string }[];
1269+
for (const r of rows) seen.add(r.item_id);
1270+
}
1271+
return seen;
1272+
}
1273+
1274+
export function markItemSeen(feedUrl: string, itemId: string): void {
1275+
db.prepare(
1276+
'INSERT OR IGNORE INTO rss_seen_items (feed_url, item_id, seen_at) VALUES (?, ?, ?)',
1277+
).run(feedUrl, itemId, new Date().toISOString());
1278+
}
1279+
1280+
/** @internal - テスト用のみ。seen_at を強制上書きする。 */
1281+
export function _forceRssSeenItemTimestamp(
1282+
feedUrl: string,
1283+
itemId: string,
1284+
seenAt: string,
1285+
): void {
1286+
db.prepare(
1287+
`UPDATE rss_seen_items SET seen_at = ? WHERE feed_url = ? AND item_id = ?`,
1288+
).run(seenAt, feedUrl, itemId);
1289+
}
1290+
1291+
export function cleanupRssSeenItems(
1292+
now: Date = new Date(),
1293+
retentionDays: number = RSS_SEEN_ITEMS_RETENTION_DAYS,
1294+
): number {
1295+
const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
1296+
const result = db
1297+
.prepare(`DELETE FROM rss_seen_items WHERE seen_at < ?`)
1298+
.run(cutoff.toISOString());
1299+
if (result.changes > 0) {
1300+
logger.info(
1301+
{ deletedRows: result.changes, retentionDays },
1302+
'Cleaned up stale rss_seen_items rows',
1303+
);
1304+
}
1305+
return result.changes;
1306+
}
1307+
12091308
// --- JSON マイグレーション ---
12101309

12111310
function migrateJsonState(): void {

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
loadSenderAllowlist,
6161
shouldDropMessage,
6262
} from './sender-allowlist.js';
63+
import { startRssPoller } from './rss-poller.js';
6364
import { startSchedulerLoop } from './task-scheduler.js';
6465
import { hasPrivilege, resolveGroupType } from './group-type.js';
6566
import {
@@ -1013,6 +1014,23 @@ async function main(): Promise<void> {
10131014
if (text) await channel.sendMessage(jid, text);
10141015
},
10151016
});
1017+
startRssPoller({
1018+
sendMessage: async (jid, text) => {
1019+
// pollOnce also checks this before fetching feeds (avoids network cost).
1020+
// This guard is a safety net in case sendMessage is reached via other paths.
1021+
if (!(jid in registeredGroups)) {
1022+
logger.warn({ jid }, 'RSS: jid not in registered groups, skipping');
1023+
return;
1024+
}
1025+
const channel = findChannel(channels, jid);
1026+
if (!channel) {
1027+
logger.warn({ jid }, 'RSS: no channel owns JID, skipping');
1028+
return;
1029+
}
1030+
await channel.sendMessage(jid, formatOutbound(text));
1031+
},
1032+
registeredGroups: () => registeredGroups,
1033+
});
10161034
startIpcWatcher({
10171035
sendMessage: (jid, text) => {
10181036
const channel = findChannel(channels, jid);

0 commit comments

Comments
 (0)