@zhin.js/plugin-rss 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/agent/tools/rss_list_subscriptions.ts +38 -0
  3. package/agent/tools/rss_preview_feed.ts +22 -0
  4. package/commands/rss-add/[url].js +59 -0
  5. package/commands/rss-add/[url].ts +61 -0
  6. package/commands/rss-check/[[url]].js +42 -0
  7. package/commands/rss-check/[[url]].ts +41 -0
  8. package/commands/rss-list.js +28 -0
  9. package/commands/rss-list.ts +31 -0
  10. package/commands/rss-preview/[url].js +22 -0
  11. package/commands/rss-preview/[url].ts +19 -0
  12. package/commands/rss-remove/[url].js +34 -0
  13. package/commands/rss-remove/[url].ts +36 -0
  14. package/lib/channel.d.ts +16 -0
  15. package/lib/channel.d.ts.map +1 -0
  16. package/lib/channel.js +42 -0
  17. package/lib/channel.js.map +1 -0
  18. package/lib/db-store.d.ts +16 -0
  19. package/lib/db-store.d.ts.map +1 -0
  20. package/lib/db-store.js +40 -0
  21. package/lib/db-store.js.map +1 -0
  22. package/lib/feed.d.ts +29 -0
  23. package/lib/feed.d.ts.map +1 -0
  24. package/lib/feed.js +89 -0
  25. package/lib/feed.js.map +1 -0
  26. package/lib/index.d.ts +6 -1
  27. package/lib/index.d.ts.map +1 -1
  28. package/lib/index.js +4 -492
  29. package/lib/index.js.map +1 -1
  30. package/lib/memory-store.d.ts +30 -0
  31. package/lib/memory-store.d.ts.map +1 -0
  32. package/lib/memory-store.js +61 -0
  33. package/lib/memory-store.js.map +1 -0
  34. package/lib/poll.d.ts +37 -0
  35. package/lib/poll.d.ts.map +1 -0
  36. package/lib/poll.js +154 -0
  37. package/lib/poll.js.map +1 -0
  38. package/lib/rss-agent-deps.d.ts +15 -0
  39. package/lib/rss-agent-deps.d.ts.map +1 -0
  40. package/lib/rss-agent-deps.js +10 -0
  41. package/lib/rss-agent-deps.js.map +1 -0
  42. package/package.json +46 -13
  43. package/plugin.js +93 -0
  44. package/schema.json +30 -0
  45. package/src/channel.ts +69 -0
  46. package/src/db-store.ts +59 -0
  47. package/src/feed.ts +106 -0
  48. package/src/index.ts +20 -556
  49. package/src/memory-store.ts +88 -0
  50. package/src/poll.ts +171 -0
  51. package/src/rss-agent-deps.ts +15 -0
  52. /package/{skills/rss/SKILL.md → agent/skills/rss.md} +0 -0
package/src/index.ts CHANGED
@@ -1,556 +1,20 @@
1
- /**
2
- * @zhin.js/plugin-rss
3
- *
4
- * RSS/Atom 订阅推送插件
5
- *
6
- * 功能:
7
- * - 订阅任意 RSS/Atom feed
8
- * - 基于 Cron 的定时轮询(默认每 5 分钟)
9
- * - 新条目自动推送到订阅的群聊/私聊
10
- * - 支持多群订阅同一 feed(去重共享)
11
- * - 订阅管理(增删查)
12
- * - 手动检查更新
13
- * - 数据库持久化
14
- * - AI 工具集成
15
- *
16
- * 命令:
17
- * rss-add <url> 订阅 RSS 源
18
- * rss-remove <url> 取消订阅
19
- * rss-list 查看当前订阅列表
20
- * rss-check [url] 手动检查更新
21
- * rss-preview <url> 预览 feed 最新内容(不订阅)
22
- *
23
- * 配置(zhin.config.yml):
24
- * ```yaml
25
- * plugins:
26
- * - "@zhin.js/plugin-rss"
27
- * rss:
28
- * pollCron: "0/5 * * * *"
29
- * maxPerGroup: 30
30
- * maxItems: 5
31
- * timeout: 15000
32
- * ```
33
- */
34
- import { formatCompact, Adapter, Cron, MessageCommand, Schema, usePlugin, ZhinTool } from 'zhin.js';
35
- import Parser from "rss-parser";
36
-
37
- const plugin = usePlugin();
38
- const { logger, root, addCommand, addCron, useContext, onDispose, declareConfig } = plugin;
39
-
40
- // ─── 配置 ─────────────────────────────────────────────────────────────────────
41
-
42
- const config = declareConfig("rss", Schema.object({
43
- pollCron: Schema.string().default("*/5 * * * *").description("轮询频率 (Cron 表达式)"),
44
- maxPerGroup: Schema.number().default(30).min(1).max(200).description("每会话最大订阅数"),
45
- maxItems: Schema.number().default(5).min(1).max(20).description("单次推送最多条数"),
46
- timeout: Schema.number().default(15_000).min(1000).max(60000).description("拉取超时 (ms)"),
47
- }));
48
-
49
- // ─── 类型 ─────────────────────────────────────────────────────────────────────
50
-
51
- interface FeedItem {
52
- title: string;
53
- link: string;
54
- date: string;
55
- summary: string;
56
- guid: string;
57
- }
58
-
59
- // ─── Feed 解析器 ──────────────────────────────────────────────────────────────
60
-
61
- const parser = new Parser({
62
- timeout: config.timeout,
63
- headers: {
64
- "User-Agent": "ZhinBot-RSS/1.0",
65
- Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml",
66
- },
67
- });
68
-
69
- async function fetchFeed(url: string): Promise<{ title: string; items: FeedItem[] }> {
70
- const feed = await parser.parseURL(url);
71
- const items: FeedItem[] = (feed.items || []).slice(0, 20).map((item) => ({
72
- title: (item.title || "").trim(),
73
- link: (item.link || "").trim(),
74
- date: item.isoDate || item.pubDate || "",
75
- summary: stripHtml(item.contentSnippet || item.content || "").slice(0, 200),
76
- guid: item.guid || item.link || item.title || "",
77
- }));
78
- return { title: (feed.title || url).trim(), items };
79
- }
80
-
81
- function stripHtml(html: string): string {
82
- return html.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
83
- }
84
-
85
- // ─── 数据库 ───────────────────────────────────────────────────────────────────
86
-
87
- let _db: any = null;
88
-
89
- function getSubs(): any {
90
- if (!_db) {
91
- const database = root.inject("database");
92
- if (database) _db = database;
93
- }
94
- return _db?.models?.get("rss_subscriptions") ?? null;
95
- }
96
-
97
- function getSeen(): any {
98
- return _db?.models?.get("rss_seen_items") ?? null;
99
- }
100
-
101
- useContext("database", (db: any) => {
102
- _db = db;
103
- db.define("rss_subscriptions", {
104
- url: { type: "text", nullable: false },
105
- feed_title: { type: "text", default: "" },
106
- adapter_name: { type: "text", default: "" },
107
- bot_id: { type: "text", default: "" },
108
- channel_type: { type: "text", default: "group" },
109
- channel_id: { type: "text", default: "" },
110
- creator_id: { type: "text", default: "" },
111
- creator_name: { type: "text", default: "" },
112
- created_at: { type: "text", default: "" },
113
- });
114
- db.define("rss_seen_items", {
115
- feed_url: { type: "text", nullable: false },
116
- item_guid: { type: "text", nullable: false },
117
- item_title: { type: "text", default: "" },
118
- seen_at: { type: "text", default: "" },
119
- });
120
- logger.info(formatCompact( { op: "model" }));
121
- });
122
-
123
- function ts(): string {
124
- return new Date().toISOString();
125
- }
126
-
127
- // ─── 辅助:消息上下文提取 ─────────────────────────────────────────────────────
128
-
129
- function extractChannelInfo(message: any): {
130
- adapterName: string;
131
- botId: string;
132
- channelType: string;
133
- channelId: string;
134
- } {
135
- const channelType = message.$channel?.type || (message.type === "group" ? "group" : "private");
136
- const channelId = String(message.$channel?.id || message.$group?.id || message.$target?.id || message.$sender?.id || "");
137
- return {
138
- adapterName: String(message.$adapter || ""),
139
- botId: String(message.$bot || ""),
140
- channelType,
141
- channelId,
142
- };
143
- }
144
-
145
- // ─── 推送引擎 ─────────────────────────────────────────────────────────────────
146
-
147
- async function pushToChannel(
148
- adapterName: string,
149
- botId: string,
150
- channelType: string,
151
- channelId: string,
152
- content: string,
153
- ): Promise<boolean> {
154
- try {
155
- const adapter = root.inject(adapterName) as Adapter | null;
156
- if (!adapter) return false;
157
- await adapter.sendMessage({
158
- context: adapterName,
159
- bot: botId,
160
- type: channelType as "group" | "private" | "channel",
161
- id: channelId,
162
- content,
163
- });
164
- return true;
165
- } catch (e) {
166
- logger.warn(formatCompact( {
167
- op: "push",
168
- ok: false,
169
- adapter: adapterName,
170
- bot: botId,
171
- channel: `${channelType}:${channelId}`,
172
- error: (e as Error).message,
173
- }));
174
- return false;
175
- }
176
- }
177
-
178
- function formatItems(feedTitle: string, items: FeedItem[]): string {
179
- const lines = [`${feedTitle} 有 ${items.length} 条新内容:`];
180
- for (const item of items.slice(0, config.maxItems)) {
181
- lines.push("");
182
- lines.push(`${item.title}`);
183
- if (item.summary) lines.push(item.summary);
184
- if (item.link) lines.push(item.link);
185
- }
186
- if (items.length > config.maxItems) {
187
- lines.push(`\n...还有 ${items.length - config.maxItems} 条,请查看源站`);
188
- }
189
- return lines.join("\n");
190
- }
191
-
192
- // ─── 轮询逻辑 ─────────────────────────────────────────────────────────────────
193
-
194
- let polling = false;
195
-
196
- async function pollAllFeeds(): Promise<void> {
197
- const Subs = getSubs();
198
- const Seen = getSeen();
199
- if (!Subs || !Seen) return;
200
- if (polling) return;
201
- polling = true;
202
-
203
- try {
204
- const allSubs: any[] = await Subs.select();
205
- if (allSubs.length === 0) return;
206
-
207
- const urlSet = new Set(allSubs.map((s: any) => s.url));
208
-
209
- for (const url of urlSet) {
210
- try {
211
- const { title, items } = await fetchFeed(url);
212
- if (items.length === 0) continue;
213
-
214
- const seenRows: any[] = await Seen.select().where({ feed_url: url });
215
- const seenGuids = new Set(seenRows.map((r: any) => r.item_guid));
216
-
217
- const newItems = items.filter((item) => !seenGuids.has(item.guid));
218
- if (newItems.length === 0) continue;
219
-
220
- for (const item of newItems) {
221
- await Seen.insert({
222
- feed_url: url,
223
- item_guid: item.guid,
224
- item_title: item.title,
225
- seen_at: ts(),
226
- });
227
- }
228
-
229
- const message = formatItems(title, newItems);
230
- const subscribers = allSubs.filter((s: any) => s.url === url);
231
-
232
- for (const sub of subscribers) {
233
- await pushToChannel(sub.adapter_name, sub.bot_id, sub.channel_type, sub.channel_id, message);
234
- }
235
-
236
- // 更新 feed 标题
237
- if (title) {
238
- for (const sub of subscribers) {
239
- if (sub.feed_title !== title) {
240
- await Subs.update({ feed_title: title }).where({ id: sub.id });
241
- }
242
- }
243
- }
244
- } catch (e) {
245
- logger.debug(`轮询 ${url} 失败: ${(e as Error).message}`);
246
- }
247
- }
248
- } catch (e) {
249
- logger.warn(formatCompact( { op: "poll", ok: false, error: (e as Error).message }));
250
- } finally {
251
- polling = false;
252
- }
253
- }
254
-
255
- // 定期清理过老的 seen 记录(保留最近 7 天)
256
- async function cleanOldSeen(): Promise<void> {
257
- const Seen = getSeen();
258
- if (!Seen) return;
259
- try {
260
- const cutoff = new Date();
261
- cutoff.setDate(cutoff.getDate() - 7);
262
- const cutoffStr = cutoff.toISOString();
263
- const all: any[] = await Seen.select();
264
- const expired = all.filter((r: any) => r.seen_at && r.seen_at < cutoffStr);
265
- for (const row of expired) {
266
- await Seen.delete().where({ id: row.id });
267
- }
268
- } catch {
269
- // 清理失败不影响主流程
270
- }
271
- }
272
-
273
- // ─── Cron 定时任务 ────────────────────────────────────────────────────────────
274
-
275
- addCron(new Cron(config.pollCron, pollAllFeeds));
276
- addCron(new Cron("0 4 * * *", cleanOldSeen));
277
-
278
- // ─── 命令:rss-add ───────────────────────────────────────────────────────────
279
-
280
- addCommand(
281
- new MessageCommand("rss-add <url:text>")
282
- .desc("订阅 RSS", "订阅一个 RSS/Atom 源,新内容将自动推送到当前会话")
283
- .usage("rss-add <Feed URL>")
284
- .examples("rss-add https://sspai.com/feed", "rss-add https://rsshub.app/bilibili/user/dynamic/1")
285
- .action(async (message: any, result: any) => {
286
- const Subs = getSubs();
287
- if (!Subs) return "RSS 数据库尚未就绪,请稍后重试";
288
-
289
- const url = result.params.url?.trim();
290
- if (!url) return "请提供 RSS 源地址,格式:rss-add <URL>";
291
- if (!/^https?:\/\//i.test(url)) return "请提供有效的 HTTP/HTTPS 地址";
292
-
293
- const { adapterName, botId, channelType, channelId } = extractChannelInfo(message);
294
- if (!channelId) return "无法确定当前会话,请在群聊或私聊中使用";
295
-
296
- // 检查是否已订阅
297
- const existing: any[] = await Subs.select().where({
298
- url,
299
- adapter_name: adapterName,
300
- channel_type: channelType,
301
- channel_id: channelId,
302
- });
303
- if (existing.length > 0) return "当前会话已订阅该源,无需重复添加";
304
-
305
- // 检查数量限制
306
- const currentSubs: any[] = await Subs.select().where({
307
- adapter_name: adapterName,
308
- channel_type: channelType,
309
- channel_id: channelId,
310
- });
311
- if (currentSubs.length >= config.maxPerGroup) {
312
- return `当前会话订阅数已达上限 (${config.maxPerGroup}),请先取消一些`;
313
- }
314
-
315
- // 验证 feed 可达
316
- let feedTitle = "";
317
- try {
318
- const { title } = await fetchFeed(url);
319
- feedTitle = title;
320
- } catch (e) {
321
- return `无法解析该地址: ${(e as Error).message}\n请确认是有效的 RSS/Atom 源`;
322
- }
323
-
324
- await Subs.insert({
325
- url,
326
- feed_title: feedTitle,
327
- adapter_name: adapterName,
328
- bot_id: botId,
329
- channel_type: channelType,
330
- channel_id: channelId,
331
- creator_id: String(message.$sender?.id || ""),
332
- creator_name: String(message.$sender?.name || ""),
333
- created_at: ts(),
334
- });
335
-
336
- // 标记现有条目为已读,避免订阅后立即推送历史
337
- try {
338
- const Seen = getSeen();
339
- if (Seen) {
340
- const { items } = await fetchFeed(url);
341
- for (const item of items) {
342
- const dup: any[] = await Seen.select().where({ feed_url: url, item_guid: item.guid });
343
- if (dup.length === 0) {
344
- await Seen.insert({ feed_url: url, item_guid: item.guid, item_title: item.title, seen_at: ts() });
345
- }
346
- }
347
- }
348
- } catch {
349
- // 标记失败不影响订阅
350
- }
351
-
352
- return `订阅成功!\n源: ${feedTitle || url}\n新内容将自动推送到当前会话`;
353
- }),
354
- );
355
-
356
- // ─── 命令:rss-remove ────────────────────────────────────────────────────────
357
-
358
- addCommand(
359
- new MessageCommand("rss-remove <url:text>")
360
- .desc("取消订阅", "取消一个 RSS 订阅")
361
- .usage("rss-remove <Feed URL>")
362
- .examples("rss-remove https://sspai.com/feed")
363
- .action(async (message: any, result: any) => {
364
- const Subs = getSubs();
365
- if (!Subs) return "RSS 数据库尚未就绪";
366
-
367
- const url = result.params.url?.trim();
368
- if (!url) return "请提供要取消的 RSS 源地址";
369
-
370
- const { adapterName, channelType, channelId } = extractChannelInfo(message);
371
- const rows: any[] = await Subs.select().where({
372
- url,
373
- adapter_name: adapterName,
374
- channel_type: channelType,
375
- channel_id: channelId,
376
- });
377
-
378
- if (rows.length === 0) return "未找到该订阅";
379
-
380
- await Subs.delete().where({ id: rows[0].id });
381
- return `已取消订阅: ${rows[0].feed_title || url}`;
382
- }),
383
- );
384
-
385
- // ─── 命令:rss-list ──────────────────────────────────────────────────────────
386
-
387
- addCommand(
388
- new MessageCommand("rss-list")
389
- .desc("订阅列表", "查看当前会话的所有 RSS 订阅")
390
- .action(async (message: any) => {
391
- const Subs = getSubs();
392
- if (!Subs) return "RSS 数据库尚未就绪";
393
-
394
- const { adapterName, channelType, channelId } = extractChannelInfo(message);
395
- const rows: any[] = await Subs.select().where({
396
- adapter_name: adapterName,
397
- channel_type: channelType,
398
- channel_id: channelId,
399
- });
400
-
401
- if (rows.length === 0) return "当前会话没有订阅任何 RSS 源\n使用 rss-add <URL> 添加订阅";
402
-
403
- const lines = rows.map((r: any, i: number) => {
404
- const title = r.feed_title || "(未知标题)";
405
- return `${i + 1}. ${title}\n ${r.url}`;
406
- });
407
-
408
- return `当前订阅 (${rows.length}/${config.maxPerGroup}):\n${lines.join("\n")}`;
409
- }),
410
- );
411
-
412
- // ─── 命令:rss-check ─────────────────────────────────────────────────────────
413
-
414
- addCommand(
415
- new MessageCommand("rss-check [url:text]")
416
- .desc("检查更新", "手动触发检查 RSS 更新,可指定某个源")
417
- .usage("rss-check [URL]")
418
- .action(async (message: any, result: any) => {
419
- const Subs = getSubs();
420
- const Seen = getSeen();
421
- if (!Subs || !Seen) return "RSS 数据库尚未就绪";
422
-
423
- const targetUrl = result.params.url?.trim();
424
- const { adapterName, channelType, channelId } = extractChannelInfo(message);
425
-
426
- let subs: any[];
427
- if (targetUrl) {
428
- subs = await Subs.select().where({
429
- url: targetUrl,
430
- adapter_name: adapterName,
431
- channel_type: channelType,
432
- channel_id: channelId,
433
- });
434
- if (subs.length === 0) return "当前会话未订阅该源";
435
- } else {
436
- subs = await Subs.select().where({
437
- adapter_name: adapterName,
438
- channel_type: channelType,
439
- channel_id: channelId,
440
- });
441
- if (subs.length === 0) return "当前会话没有任何订阅";
442
- }
443
-
444
- const urlSet = new Set(subs.map((s: any) => s.url));
445
- let totalNew = 0;
446
-
447
- for (const url of urlSet) {
448
- try {
449
- const { title, items } = await fetchFeed(url);
450
- const seenRows: any[] = await Seen.select().where({ feed_url: url });
451
- const seenGuids = new Set(seenRows.map((r: any) => r.item_guid));
452
- const newItems = items.filter((item) => !seenGuids.has(item.guid));
453
-
454
- if (newItems.length > 0) {
455
- for (const item of newItems) {
456
- await Seen.insert({ feed_url: url, item_guid: item.guid, item_title: item.title, seen_at: ts() });
457
- }
458
- totalNew += newItems.length;
459
- const msg = formatItems(title, newItems);
460
- await pushToChannel(adapterName, subs[0].bot_id, channelType, channelId, msg);
461
- }
462
- } catch (e) {
463
- await message.$reply(`检查 ${url} 失败: ${(e as Error).message}`);
464
- }
465
- }
466
-
467
- if (totalNew === 0) return `已检查 ${urlSet.size} 个源,没有新内容`;
468
- return "";
469
- }),
470
- );
471
-
472
- // ─── 命令:rss-preview ───────────────────────────────────────────────────────
473
-
474
- addCommand(
475
- new MessageCommand("rss-preview <url:text>")
476
- .desc("预览 RSS", "预览一个 RSS 源的最新内容(不订阅)")
477
- .usage("rss-preview <URL>")
478
- .examples("rss-preview https://sspai.com/feed")
479
- .action(async (_message: any, result: any) => {
480
- const url = result.params.url?.trim();
481
- if (!url) return "请提供 RSS 源地址";
482
- if (!/^https?:\/\//i.test(url)) return "请提供有效的 HTTP/HTTPS 地址";
483
-
484
- try {
485
- const { title, items } = await fetchFeed(url);
486
- if (items.length === 0) return `${title}: 暂无内容`;
487
-
488
- const lines = [`${title} (共 ${items.length} 条)`];
489
- for (const item of items.slice(0, 5)) {
490
- lines.push("");
491
- lines.push(`${item.title}`);
492
- if (item.date) lines.push(` ${new Date(item.date).toLocaleString("zh-CN")}`);
493
- if (item.link) lines.push(` ${item.link}`);
494
- }
495
- if (items.length > 5) lines.push(`\n...还有 ${items.length - 5} 条`);
496
- return lines.join("\n");
497
- } catch (e) {
498
- return `解析失败: ${(e as Error).message}\n请确认是有效的 RSS/Atom 地址`;
499
- }
500
- }),
501
- );
502
-
503
- // ─── AI 工具 ─────────────────────────────────────────────────────────────────
504
-
505
- plugin.addTool(
506
- new ZhinTool("rss_list_subscriptions")
507
- .desc("查询当前所有 RSS 订阅")
508
- .execute(async () => {
509
- const Subs = getSubs();
510
- if (!Subs) return "RSS 数据库尚未就绪";
511
-
512
- const all: any[] = await Subs.select();
513
- if (all.length === 0) return "暂无任何 RSS 订阅";
514
-
515
- const grouped = new Map<string, any[]>();
516
- for (const sub of all) {
517
- const key = `${sub.channel_type}:${sub.channel_id}`;
518
- const list = grouped.get(key) || [];
519
- list.push(sub);
520
- grouped.set(key, list);
521
- }
522
-
523
- const lines: string[] = [];
524
- for (const [channel, subs] of grouped) {
525
- lines.push(`[${channel}]`);
526
- for (const s of subs) {
527
- lines.push(` - ${s.feed_title || s.url}`);
528
- }
529
- }
530
- return `RSS 订阅总览 (${all.length} 条):\n${lines.join("\n")}`;
531
- })
532
- .toTool(),
533
- );
534
-
535
- plugin.addTool(
536
- new ZhinTool("rss_preview_feed")
537
- .desc("预览一个 RSS 源的最新内容")
538
- .param("url", { type: "string", description: "RSS/Atom 源地址" })
539
- .execute(async (args: Record<string, any>) => {
540
- const url = args.url as string;
541
- if (!url) return "请提供 RSS 源 URL";
542
- try {
543
- const { title, items } = await fetchFeed(url);
544
- if (items.length === 0) return `${title}: 无内容`;
545
- return items
546
- .slice(0, 5)
547
- .map((item, i) => `${i + 1}. ${item.title}\n ${item.link}`)
548
- .join("\n");
549
- } catch (e) {
550
- return `解析失败: ${(e as Error).message}`;
551
- }
552
- })
553
- .toTool(),
554
- );
555
-
556
- logger.info(formatCompact( { op: "load", poll: config.pollCron, max_per_group: config.maxPerGroup }));
1
+ export {
2
+ DEFAULT_RSS_CONFIG,
3
+ fetchFeed,
4
+ formatFeedPreview,
5
+ resolveRssConfig,
6
+ stripHtml,
7
+ } from './feed.js';
8
+ export type { FeedItem, RssConfig } from './feed.js';
9
+ export {
10
+ getRssDb,
11
+ getRssSeen,
12
+ getRssSubs,
13
+ provideRssDb,
14
+ setRssDb,
15
+ ensureRssMemoryDb,
16
+ resetRssDb,
17
+ } from './db-store.js';
18
+ export { extractChannelInfo, SMOKE_CHANNEL } from './channel.js';
19
+ export { checkSubscriptions, pollAllFeeds, formatNewItems, setRssOutboundPush } from './poll.js';
20
+ export type { RssOutboundPush, CheckResult } from './poll.js';
@@ -0,0 +1,88 @@
1
+ /**
2
+ * In-memory RSS models (slice-2) until Plugin Runtime DatabaseFeature Resource lands.
3
+ *
4
+ * 模型表面与 `@zhin.js/plugin-runtime` 的 DatabaseHostModel 结构兼容:
5
+ * select 的 where 返回 PromiseLike(host 返回链式 Selection,memory 返回 Promise),
6
+ * 这样 plugin.ts 可以把 PluginDatabaseHost 直接当 RssMemoryDb 用。
7
+ */
8
+
9
+ export type RssRow = Record<string, unknown>;
10
+
11
+ export interface RssModel {
12
+ select: (...fields: string[]) => {
13
+ where: (query: Record<string, unknown>) => PromiseLike<RssRow[]>;
14
+ then: <TResult1 = RssRow[], TResult2 = never>(
15
+ onfulfilled?: ((value: RssRow[]) => TResult1 | PromiseLike<TResult1>) | null,
16
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
17
+ ) => Promise<TResult1 | TResult2>;
18
+ };
19
+ insert: (row: Record<string, unknown>) => Promise<unknown>;
20
+ delete: () => { where: (query: Record<string, unknown>) => Promise<unknown> };
21
+ update: (patch: Record<string, unknown>) => {
22
+ where: (query: Record<string, unknown>) => Promise<unknown>;
23
+ };
24
+ }
25
+
26
+ function createMemoryModel(): RssModel {
27
+ const rows: RssRow[] = [];
28
+ let nextId = 1;
29
+
30
+ function matches(row: Record<string, unknown>, query: Record<string, unknown>): boolean {
31
+ return Object.entries(query).every(([key, value]) => row[key] === value);
32
+ }
33
+
34
+ function all(): RssRow[] {
35
+ return rows.map((row) => ({ ...row }));
36
+ }
37
+
38
+ return {
39
+ select: () => {
40
+ const promise = Promise.resolve(all());
41
+ return {
42
+ where: async (query: Record<string, unknown>) =>
43
+ rows.filter((row) => matches(row, query)).map((row) => ({ ...row })),
44
+ then: (onfulfilled, onrejected) => promise.then(onfulfilled, onrejected),
45
+ };
46
+ },
47
+ insert: async (row: Record<string, unknown>) => {
48
+ const withId: RssRow = { id: String(nextId++), ...row };
49
+ rows.push(withId);
50
+ return { ...withId };
51
+ },
52
+ delete: () => ({
53
+ where: async (query: Record<string, unknown>) => {
54
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
55
+ if (matches(rows[i]!, query)) rows.splice(i, 1);
56
+ }
57
+ },
58
+ }),
59
+ update: (patch: Record<string, unknown>) => ({
60
+ where: async (query: Record<string, unknown>) => {
61
+ for (const row of rows) {
62
+ if (matches(row, query)) Object.assign(row, patch);
63
+ }
64
+ },
65
+ }),
66
+ };
67
+ }
68
+
69
+ export interface RssMemoryDb {
70
+ models: {
71
+ get: (name: string) => RssModel | undefined;
72
+ };
73
+ }
74
+
75
+ export const RSS_SUBS_TABLE = 'rss_subscriptions';
76
+ export const RSS_SEEN_TABLE = 'rss_seen_items';
77
+
78
+ export function createInMemoryRssDb(): RssMemoryDb {
79
+ const models = new Map<string, RssModel>([
80
+ [RSS_SUBS_TABLE, createMemoryModel()],
81
+ [RSS_SEEN_TABLE, createMemoryModel()],
82
+ ]);
83
+ return {
84
+ models: {
85
+ get: (name: string) => models.get(name),
86
+ },
87
+ };
88
+ }