@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/poll.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Feed poll helpers: detect new items; optional OutboundHost push to subscribers.
3
+ */
4
+ import { fetchFeed, type FeedItem, type RssConfig } from './feed.js';
5
+ import { getRssSeen, getRssSubs } from './db-store.js';
6
+
7
+ export type RssOutboundPush = (input: {
8
+ readonly adapterName: string;
9
+ readonly endpointKey: string;
10
+ readonly channelType: string;
11
+ readonly channelId: string;
12
+ readonly content: string;
13
+ }) => Promise<void>;
14
+
15
+ let _outboundPush: RssOutboundPush | null = null;
16
+
17
+ export function setRssOutboundPush(push: RssOutboundPush | null): void {
18
+ _outboundPush = push;
19
+ }
20
+
21
+ export function getRssOutboundPush(): RssOutboundPush | null {
22
+ return _outboundPush;
23
+ }
24
+
25
+ function ts(): string {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: number): string {
30
+ const lines = [`${feedTitle} 有 ${items.length} 条新内容:`];
31
+ for (const item of items.slice(0, maxItems)) {
32
+ lines.push('');
33
+ lines.push(`${item.title}`);
34
+ if (item.summary) lines.push(item.summary);
35
+ if (item.link) lines.push(item.link);
36
+ }
37
+ if (items.length > maxItems) {
38
+ lines.push(`\n...还有 ${items.length - maxItems} 条,请查看源站`);
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+
43
+ export async function markItemsSeen(feedUrl: string, items: FeedItem[]): Promise<void> {
44
+ const Seen = getRssSeen();
45
+ if (!Seen) return;
46
+ for (const item of items) {
47
+ const dup = await Seen.select().where({ feed_url: feedUrl, item_guid: item.guid });
48
+ if (dup.length === 0) {
49
+ await Seen.insert({
50
+ feed_url: feedUrl,
51
+ item_guid: item.guid,
52
+ item_title: item.title,
53
+ seen_at: ts(),
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ export interface CheckResult {
60
+ readonly text: string;
61
+ readonly totalNew: number;
62
+ readonly pushed: number;
63
+ }
64
+
65
+ async function pushToSubscribers(feedUrl: string, content: string): Promise<number> {
66
+ const push = _outboundPush;
67
+ const Subs = getRssSubs();
68
+ if (!push || !Subs) return 0;
69
+ const subscribers = await Subs.select().where({ url: feedUrl });
70
+ let pushed = 0;
71
+ for (const sub of subscribers) {
72
+ const adapterName = String(sub.adapter_name ?? '');
73
+ const channelId = String(sub.channel_id ?? '');
74
+ if (!adapterName || !channelId) continue;
75
+ try {
76
+ await push({
77
+ adapterName,
78
+ endpointKey: String(sub.endpoint_id ?? adapterName),
79
+ channelType: String(sub.channel_type ?? 'private'),
80
+ channelId,
81
+ content,
82
+ });
83
+ pushed += 1;
84
+ } catch {
85
+ // OutboundHost already logs; continue other subscribers.
86
+ }
87
+ }
88
+ return pushed;
89
+ }
90
+
91
+ /**
92
+ * Check subscribed feeds for new items and mark them seen.
93
+ * When OutboundHost is wired, pushes formatted text to each subscriber channel.
94
+ */
95
+ export async function checkSubscriptions(options: {
96
+ urls: readonly string[];
97
+ config: RssConfig;
98
+ }): Promise<CheckResult> {
99
+ const Seen = getRssSeen();
100
+ const Subs = getRssSubs();
101
+ if (!Seen || !Subs) {
102
+ return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
103
+ }
104
+
105
+ const parts: string[] = [];
106
+ let totalNew = 0;
107
+ let pushed = 0;
108
+
109
+ for (const url of options.urls) {
110
+ try {
111
+ const { title, items } = await fetchFeed(url, options.config.timeout);
112
+ const seenRows = await Seen.select().where({ feed_url: url });
113
+ const seenGuids = new Set(seenRows.map((r) => String(r.item_guid ?? '')));
114
+ const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid));
115
+ if (newItems.length === 0) {
116
+ parts.push(`${title || url}: 无新内容`);
117
+ continue;
118
+ }
119
+ await markItemsSeen(url, newItems);
120
+ totalNew += newItems.length;
121
+ const body = formatNewItems(title || url, newItems, options.config.maxItems);
122
+ parts.push(body);
123
+ pushed += await pushToSubscribers(url, body);
124
+ } catch (e) {
125
+ parts.push(`${url}: 检查失败 — ${(e as Error).message}`);
126
+ }
127
+ }
128
+
129
+ if (parts.length === 0) return { text: '没有可检查的订阅', totalNew: 0, pushed: 0 };
130
+ const pushNote = _outboundPush
131
+ ? (pushed > 0 ? `,已推送 ${pushed} 个会话` : ',无出站推送')
132
+ : '(未装配 OutboundHost,仅本地标记)';
133
+ const header = totalNew > 0
134
+ ? `检查完成,共 ${totalNew} 条新内容${pushNote}`
135
+ : '检查完成,暂无新内容';
136
+ return { text: `${header}\n\n${parts.join('\n\n')}`, totalNew, pushed };
137
+ }
138
+
139
+ /**
140
+ * Periodically clean seen records older than 7 days.
141
+ */
142
+ export async function cleanOldSeen(): Promise<void> {
143
+ const Seen = getRssSeen();
144
+ if (!Seen) return;
145
+ try {
146
+ const cutoff = new Date();
147
+ cutoff.setDate(cutoff.getDate() - 7);
148
+ const cutoffStr = cutoff.toISOString();
149
+ const all = await Seen.select();
150
+ const expired = all.filter((r) => typeof r.seen_at === 'string' && r.seen_at < cutoffStr);
151
+ for (const row of expired) {
152
+ // 表结构没有 id 列,按业务键 (feed_url, item_guid) 删除。
153
+ await Seen.delete().where({ feed_url: row.feed_url, item_guid: row.item_guid });
154
+ }
155
+ } catch (e) {
156
+ // Cleanup failure must not break the main flow, but it must be visible.
157
+ console.warn(`[rss] cleanOldSeen failed: ${e instanceof Error ? e.message : String(e)}`);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Poll all unique feed URLs across subscriptions.
163
+ */
164
+ export async function pollAllFeeds(config: RssConfig): Promise<CheckResult> {
165
+ const Subs = getRssSubs();
166
+ if (!Subs) return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
167
+ const all = await Subs.select();
168
+ const urls = [...new Set(all.map((s) => String(s.url ?? '')).filter(Boolean))];
169
+ if (urls.length === 0) return { text: '暂无任何 RSS 订阅', totalNew: 0, pushed: 0 };
170
+ return checkSubscriptions({ urls, config });
171
+ }
@@ -0,0 +1,15 @@
1
+ export interface RssAgentDeps {
2
+ getSubs: () => { select: () => Promise<unknown[]> } | null;
3
+ fetchFeed: (url: string) => Promise<{ title: string; items: Array<{ title: string; link: string }> }>;
4
+ }
5
+
6
+ let deps: RssAgentDeps | null = null;
7
+
8
+ export function setRssAgentDeps(next: RssAgentDeps): void {
9
+ deps = next;
10
+ }
11
+
12
+ export function getRssAgentDeps(): RssAgentDeps {
13
+ if (!deps) throw new Error('rss agent deps not initialized');
14
+ return deps;
15
+ }
File without changes