@zhin.js/plugin-rss 3.0.2 → 3.0.3

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 (51) hide show
  1. package/agent/tools/rss_list_subscriptions.ts +2 -2
  2. package/agent/tools/rss_preview_feed.ts +2 -2
  3. package/commands/rss-add/[url:string].ts +60 -0
  4. package/commands/rss-check/[url:string=].ts +40 -0
  5. package/commands/rss-list.ts +31 -0
  6. package/commands/rss-preview/[url:string].ts +18 -0
  7. package/commands/rss-remove/[url:string].ts +29 -0
  8. package/lib/channel.d.ts +16 -0
  9. package/lib/channel.d.ts.map +1 -0
  10. package/lib/channel.js +36 -0
  11. package/lib/channel.js.map +1 -0
  12. package/lib/db-store.d.ts +9 -0
  13. package/lib/db-store.d.ts.map +1 -0
  14. package/lib/db-store.js +24 -0
  15. package/lib/db-store.js.map +1 -0
  16. package/lib/feed.d.ts +29 -0
  17. package/lib/feed.d.ts.map +1 -0
  18. package/lib/feed.js +89 -0
  19. package/lib/feed.js.map +1 -0
  20. package/lib/index.d.ts +7 -0
  21. package/lib/index.d.ts.map +1 -0
  22. package/lib/index.js +5 -0
  23. package/lib/index.js.map +1 -0
  24. package/lib/memory-store.d.ts +28 -0
  25. package/lib/memory-store.d.ts.map +1 -0
  26. package/lib/memory-store.js +57 -0
  27. package/lib/memory-store.js.map +1 -0
  28. package/lib/poll.d.ts +37 -0
  29. package/lib/poll.d.ts.map +1 -0
  30. package/lib/poll.js +152 -0
  31. package/lib/poll.js.map +1 -0
  32. package/lib/rss-agent-deps.d.ts +15 -0
  33. package/lib/rss-agent-deps.d.ts.map +1 -0
  34. package/lib/rss-agent-deps.js.map +1 -0
  35. package/package.json +37 -21
  36. package/plugin.ts +103 -0
  37. package/schema.json +30 -0
  38. package/src/channel.ts +58 -0
  39. package/src/db-store.ts +42 -0
  40. package/src/feed.ts +106 -0
  41. package/src/index.ts +19 -506
  42. package/src/memory-store.ts +86 -0
  43. package/src/poll.ts +169 -0
  44. package/lib/agent/tools/rss_list_subscriptions.js +0 -32
  45. package/lib/agent/tools/rss_list_subscriptions.js.map +0 -1
  46. package/lib/agent/tools/rss_preview_feed.js +0 -25
  47. package/lib/agent/tools/rss_preview_feed.js.map +0 -1
  48. package/lib/src/index.js +0 -447
  49. package/lib/src/index.js.map +0 -1
  50. package/lib/src/rss-agent-deps.js.map +0 -1
  51. /package/lib/{src/rss-agent-deps.js → rss-agent-deps.js} +0 -0
package/src/poll.ts ADDED
@@ -0,0 +1,169 @@
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 endpointId: 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
+ endpointId: 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
+ await Seen.delete().where({ id: row.id });
153
+ }
154
+ } catch {
155
+ // Cleanup failure must not break the main flow.
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Poll all unique feed URLs across subscriptions.
161
+ */
162
+ export async function pollAllFeeds(config: RssConfig): Promise<CheckResult> {
163
+ const Subs = getRssSubs();
164
+ if (!Subs) return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
165
+ const all = await Subs.select();
166
+ const urls = [...new Set(all.map((s) => String(s.url ?? '')).filter(Boolean))];
167
+ if (urls.length === 0) return { text: '暂无任何 RSS 订阅', totalNew: 0, pushed: 0 };
168
+ return checkSubscriptions({ urls, config });
169
+ }
@@ -1,32 +0,0 @@
1
- import { defineTool } from '@zhin.js/agent/tools';
2
- import { z } from 'zod';
3
- import { getRssAgentDeps } from '../../src/rss-agent-deps.js';
4
- export default defineTool({
5
- description: '查询当前所有 RSS 订阅',
6
- inputSchema: z.object({}),
7
- async execute() {
8
- const { getSubs } = getRssAgentDeps();
9
- const Subs = getSubs();
10
- if (!Subs)
11
- return 'RSS 数据库尚未就绪';
12
- const all = (await Subs.select());
13
- if (all.length === 0)
14
- return '暂无任何 RSS 订阅';
15
- const grouped = new Map();
16
- for (const sub of all) {
17
- const key = `${sub.channel_type}:${sub.channel_id}`;
18
- const list = grouped.get(key) || [];
19
- list.push(sub);
20
- grouped.set(key, list);
21
- }
22
- const lines = [];
23
- for (const [channel, subs] of grouped) {
24
- lines.push(`[${channel}]`);
25
- for (const s of subs) {
26
- lines.push(` - ${s.feed_title || s.url}`);
27
- }
28
- }
29
- return `RSS 订阅总览 (${all.length} 条):\n${lines.join('\n')}`;
30
- },
31
- });
32
- //# sourceMappingURL=rss_list_subscriptions.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"rss_list_subscriptions.js","sourceRoot":"","sources":["../../../agent/tools/rss_list_subscriptions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE9D,eAAe,UAAU,CAAC;IACxB,WAAW,EAAE,eAAe;IAC5B,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;IACzB,KAAK,CAAC,OAAO;QACX,MAAM,EAAE,OAAO,EAAE,GAAG,eAAe,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;QACvB,IAAI,CAAC,IAAI;YAAE,OAAO,aAAa,CAAC;QAEhC,MAAM,GAAG,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,CAK9B,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,aAAa,CAAC;QAE3C,MAAM,OAAO,GAAG,IAAI,GAAG,EAAsB,CAAC;QAC9C,KAAK,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;YACtB,MAAM,GAAG,GAAG,GAAG,GAAG,CAAC,YAAY,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC;YACpD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;YACpC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACf,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACzB,CAAC;QAED,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,OAAO,EAAE,CAAC;YACtC,KAAK,CAAC,IAAI,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC;YAC3B,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;gBACrB,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;YAC7C,CAAC;QACH,CAAC;QACD,OAAO,aAAa,GAAG,CAAC,MAAM,SAAS,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IAC5D,CAAC;CACF,CAAC,CAAC"}
@@ -1,25 +0,0 @@
1
- import { defineTool } from '@zhin.js/agent/tools';
2
- import { z } from 'zod';
3
- import { getRssAgentDeps } from '../../src/rss-agent-deps.js';
4
- export default defineTool({
5
- description: '预览一个 RSS 源的最新内容',
6
- inputSchema: z.object({ url: z.string().min(1) }),
7
- async execute({ url }) {
8
- if (!url)
9
- return '请提供 RSS 源 URL';
10
- try {
11
- const { fetchFeed } = getRssAgentDeps();
12
- const { title, items } = await fetchFeed(url);
13
- if (items.length === 0)
14
- return `${title}: 无内容`;
15
- return items
16
- .slice(0, 5)
17
- .map((item, i) => `${i + 1}. ${item.title}\n ${item.link}`)
18
- .join('\n');
19
- }
20
- catch (e) {
21
- return `解析失败: ${e.message}`;
22
- }
23
- },
24
- });
25
- //# sourceMappingURL=rss_preview_feed.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"rss_preview_feed.js","sourceRoot":"","sources":["../../../agent/tools/rss_preview_feed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAClD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE9D,eAAe,UAAU,CAAkB;IACzC,WAAW,EAAE,iBAAiB;IAC9B,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACjD,KAAK,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE;QACnB,IAAI,CAAC,GAAG;YAAE,OAAO,eAAe,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,EAAE,SAAS,EAAE,GAAG,eAAe,EAAE,CAAC;YACxC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC;YAC9C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAO,GAAG,KAAK,OAAO,CAAC;YAC/C,OAAO,KAAK;iBACT,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;iBACX,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,KAAK,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;iBAC5D,IAAI,CAAC,IAAI,CAAC,CAAC;QAChB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,OAAO,SAAU,CAAW,CAAC,OAAO,EAAE,CAAC;QACzC,CAAC;IACH,CAAC;CACF,CAAC,CAAC"}
package/lib/src/index.js DELETED
@@ -1,447 +0,0 @@
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, MessageCommand, Schema, usePlugin } from 'zhin.js';
35
- import Parser from "rss-parser";
36
- import { setRssAgentDeps } from './rss-agent-deps.js';
37
- const plugin = usePlugin();
38
- const { logger, root, addCommand, addSchedule, useContext, onDispose, declareConfig } = plugin;
39
- // ─── 配置 ─────────────────────────────────────────────────────────────────────
40
- const config = declareConfig("rss", Schema.object({
41
- pollCron: Schema.string().default("0 */5 * * * *").description("轮询频率 (6 段 Cron 表达式)"),
42
- maxPerGroup: Schema.number().default(30).min(1).max(200).description("每会话最大订阅数"),
43
- maxItems: Schema.number().default(5).min(1).max(20).description("单次推送最多条数"),
44
- timeout: Schema.number().default(15_000).min(1000).max(60000).description("拉取超时 (ms)"),
45
- }));
46
- // ─── Feed 解析器 ──────────────────────────────────────────────────────────────
47
- const parser = new Parser({
48
- timeout: config.timeout,
49
- headers: {
50
- "User-Agent": "ZhinBot-RSS/1.0",
51
- Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml",
52
- },
53
- });
54
- async function fetchFeed(url) {
55
- const feed = await parser.parseURL(url);
56
- const items = (feed.items || []).slice(0, 20).map((item) => ({
57
- title: (item.title || "").trim(),
58
- link: (item.link || "").trim(),
59
- date: item.isoDate || item.pubDate || "",
60
- summary: stripHtml(item.contentSnippet || item.content || "").slice(0, 200),
61
- guid: item.guid || item.link || item.title || "",
62
- }));
63
- return { title: (feed.title || url).trim(), items };
64
- }
65
- function stripHtml(html) {
66
- return html.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
67
- }
68
- // ─── 数据库 ───────────────────────────────────────────────────────────────────
69
- let _db = null;
70
- function getSubs() {
71
- if (!_db) {
72
- const database = root.inject("database");
73
- if (database)
74
- _db = database;
75
- }
76
- return _db?.models?.get("rss_subscriptions") ?? null;
77
- }
78
- function getSeen() {
79
- return _db?.models?.get("rss_seen_items") ?? null;
80
- }
81
- setRssAgentDeps({ getSubs, fetchFeed });
82
- useContext("database", (db) => {
83
- _db = db;
84
- db.define("rss_subscriptions", {
85
- url: { type: "text", nullable: false },
86
- feed_title: { type: "text", default: "" },
87
- adapter_name: { type: "text", default: "" },
88
- endpoint_id: { type: "text", default: "" },
89
- channel_type: { type: "text", default: "group" },
90
- channel_id: { type: "text", default: "" },
91
- creator_id: { type: "text", default: "" },
92
- creator_name: { type: "text", default: "" },
93
- created_at: { type: "text", default: "" },
94
- });
95
- db.define("rss_seen_items", {
96
- feed_url: { type: "text", nullable: false },
97
- item_guid: { type: "text", nullable: false },
98
- item_title: { type: "text", default: "" },
99
- seen_at: { type: "text", default: "" },
100
- });
101
- logger.debug(formatCompact({ op: "model" }));
102
- });
103
- function ts() {
104
- return new Date().toISOString();
105
- }
106
- // ─── 辅助:消息上下文提取 ─────────────────────────────────────────────────────
107
- function extractChannelInfo(message) {
108
- const channelType = message.$channel?.type || (message.type === "group" ? "group" : "private");
109
- const channelId = String(message.$channel?.id || message.$group?.id || message.$target?.id || message.$sender?.id || "");
110
- return {
111
- adapterName: String(message.$adapter || ""),
112
- endpointId: String(message.$endpoint || ""),
113
- channelType,
114
- channelId,
115
- };
116
- }
117
- // ─── 推送引擎 ─────────────────────────────────────────────────────────────────
118
- async function pushToChannel(adapterName, endpointId, channelType, channelId, content) {
119
- try {
120
- const adapter = root.inject(adapterName);
121
- if (!adapter)
122
- return false;
123
- await adapter.sendMessage({
124
- context: adapterName,
125
- endpoint: endpointId,
126
- type: channelType,
127
- id: channelId,
128
- content,
129
- });
130
- return true;
131
- }
132
- catch (e) {
133
- logger.warn(formatCompact({
134
- op: "push",
135
- ok: false,
136
- adapter: adapterName,
137
- endpoint: endpointId,
138
- channel: `${channelType}:${channelId}`,
139
- error: e.message,
140
- }));
141
- return false;
142
- }
143
- }
144
- function formatItems(feedTitle, items) {
145
- const lines = [`${feedTitle} 有 ${items.length} 条新内容:`];
146
- for (const item of items.slice(0, config.maxItems)) {
147
- lines.push("");
148
- lines.push(`${item.title}`);
149
- if (item.summary)
150
- lines.push(item.summary);
151
- if (item.link)
152
- lines.push(item.link);
153
- }
154
- if (items.length > config.maxItems) {
155
- lines.push(`\n...还有 ${items.length - config.maxItems} 条,请查看源站`);
156
- }
157
- return lines.join("\n");
158
- }
159
- // ─── 轮询逻辑 ─────────────────────────────────────────────────────────────────
160
- let polling = false;
161
- async function pollAllFeeds() {
162
- const Subs = getSubs();
163
- const Seen = getSeen();
164
- if (!Subs || !Seen)
165
- return;
166
- if (polling)
167
- return;
168
- polling = true;
169
- try {
170
- const allSubs = await Subs.select();
171
- if (allSubs.length === 0)
172
- return;
173
- const urlSet = new Set(allSubs.map((s) => s.url));
174
- for (const url of urlSet) {
175
- try {
176
- const { title, items } = await fetchFeed(url);
177
- if (items.length === 0)
178
- continue;
179
- const seenRows = await Seen.select().where({ feed_url: url });
180
- const seenGuids = new Set(seenRows.map((r) => r.item_guid));
181
- const newItems = items.filter((item) => !seenGuids.has(item.guid));
182
- if (newItems.length === 0)
183
- continue;
184
- for (const item of newItems) {
185
- await Seen.insert({
186
- feed_url: url,
187
- item_guid: item.guid,
188
- item_title: item.title,
189
- seen_at: ts(),
190
- });
191
- }
192
- const message = formatItems(title, newItems);
193
- const subscribers = allSubs.filter((s) => s.url === url);
194
- for (const sub of subscribers) {
195
- await pushToChannel(sub.adapter_name, sub.endpoint_id, sub.channel_type, sub.channel_id, message);
196
- }
197
- // 更新 feed 标题
198
- if (title) {
199
- for (const sub of subscribers) {
200
- if (sub.feed_title !== title) {
201
- await Subs.update({ feed_title: title }).where({ id: sub.id });
202
- }
203
- }
204
- }
205
- }
206
- catch (e) {
207
- logger.debug(`轮询 ${url} 失败: ${e.message}`);
208
- }
209
- }
210
- }
211
- catch (e) {
212
- logger.warn(formatCompact({ op: "poll", ok: false, error: e.message }));
213
- }
214
- finally {
215
- polling = false;
216
- }
217
- }
218
- // 定期清理过老的 seen 记录(保留最近 7 天)
219
- async function cleanOldSeen() {
220
- const Seen = getSeen();
221
- if (!Seen)
222
- return;
223
- try {
224
- const cutoff = new Date();
225
- cutoff.setDate(cutoff.getDate() - 7);
226
- const cutoffStr = cutoff.toISOString();
227
- const all = await Seen.select();
228
- const expired = all.filter((r) => r.seen_at && r.seen_at < cutoffStr);
229
- for (const row of expired) {
230
- await Seen.delete().where({ id: row.id });
231
- }
232
- }
233
- catch {
234
- // 清理失败不影响主流程
235
- }
236
- }
237
- // ─── Cron 定时任务 ────────────────────────────────────────────────────────────
238
- addSchedule({ kind: 'solar', cron: config.pollCron }, pollAllFeeds);
239
- addSchedule({ kind: 'solar', cron: '0 0 4 * * *' }, cleanOldSeen);
240
- // ─── 命令:rss-add ───────────────────────────────────────────────────────────
241
- addCommand(new MessageCommand("rss-add <url:text>")
242
- .desc("订阅 RSS", "订阅一个 RSS/Atom 源,新内容将自动推送到当前会话")
243
- .usage("rss-add <Feed URL>")
244
- .examples("rss-add https://sspai.com/feed", "rss-add https://rsshub.app/bilibili/user/dynamic/1")
245
- .action(async (message, result) => {
246
- const Subs = getSubs();
247
- if (!Subs)
248
- return "RSS 数据库尚未就绪,请稍后重试";
249
- const url = result.params.url?.trim();
250
- if (!url)
251
- return "请提供 RSS 源地址,格式:rss-add <URL>";
252
- if (!/^https?:\/\//i.test(url))
253
- return "请提供有效的 HTTP/HTTPS 地址";
254
- const { adapterName, endpointId, channelType, channelId } = extractChannelInfo(message);
255
- if (!channelId)
256
- return "无法确定当前会话,请在群聊或私聊中使用";
257
- // 检查是否已订阅
258
- const existing = await Subs.select().where({
259
- url,
260
- adapter_name: adapterName,
261
- channel_type: channelType,
262
- channel_id: channelId,
263
- });
264
- if (existing.length > 0)
265
- return "当前会话已订阅该源,无需重复添加";
266
- // 检查数量限制
267
- const currentSubs = await Subs.select().where({
268
- adapter_name: adapterName,
269
- channel_type: channelType,
270
- channel_id: channelId,
271
- });
272
- if (currentSubs.length >= config.maxPerGroup) {
273
- return `当前会话订阅数已达上限 (${config.maxPerGroup}),请先取消一些`;
274
- }
275
- // 验证 feed 可达
276
- let feedTitle = "";
277
- try {
278
- const { title } = await fetchFeed(url);
279
- feedTitle = title;
280
- }
281
- catch (e) {
282
- return `无法解析该地址: ${e.message}\n请确认是有效的 RSS/Atom 源`;
283
- }
284
- await Subs.insert({
285
- url,
286
- feed_title: feedTitle,
287
- adapter_name: adapterName,
288
- endpoint_id: endpointId,
289
- channel_type: channelType,
290
- channel_id: channelId,
291
- creator_id: String(message.$sender?.id || ""),
292
- creator_name: String(message.$sender?.name || ""),
293
- created_at: ts(),
294
- });
295
- // 标记现有条目为已读,避免订阅后立即推送历史
296
- try {
297
- const Seen = getSeen();
298
- if (Seen) {
299
- const { items } = await fetchFeed(url);
300
- for (const item of items) {
301
- const dup = await Seen.select().where({ feed_url: url, item_guid: item.guid });
302
- if (dup.length === 0) {
303
- await Seen.insert({ feed_url: url, item_guid: item.guid, item_title: item.title, seen_at: ts() });
304
- }
305
- }
306
- }
307
- }
308
- catch {
309
- // 标记失败不影响订阅
310
- }
311
- return `订阅成功!\n源: ${feedTitle || url}\n新内容将自动推送到当前会话`;
312
- }));
313
- // ─── 命令:rss-remove ────────────────────────────────────────────────────────
314
- addCommand(new MessageCommand("rss-remove <url:text>")
315
- .desc("取消订阅", "取消一个 RSS 订阅")
316
- .usage("rss-remove <Feed URL>")
317
- .examples("rss-remove https://sspai.com/feed")
318
- .action(async (message, result) => {
319
- const Subs = getSubs();
320
- if (!Subs)
321
- return "RSS 数据库尚未就绪";
322
- const url = result.params.url?.trim();
323
- if (!url)
324
- return "请提供要取消的 RSS 源地址";
325
- const { adapterName, channelType, channelId } = extractChannelInfo(message);
326
- const rows = await Subs.select().where({
327
- url,
328
- adapter_name: adapterName,
329
- channel_type: channelType,
330
- channel_id: channelId,
331
- });
332
- if (rows.length === 0)
333
- return "未找到该订阅";
334
- await Subs.delete().where({ id: rows[0].id });
335
- return `已取消订阅: ${rows[0].feed_title || url}`;
336
- }));
337
- // ─── 命令:rss-list ──────────────────────────────────────────────────────────
338
- addCommand(new MessageCommand("rss-list")
339
- .desc("订阅列表", "查看当前会话的所有 RSS 订阅")
340
- .action(async (message) => {
341
- const Subs = getSubs();
342
- if (!Subs)
343
- return "RSS 数据库尚未就绪";
344
- const { adapterName, channelType, channelId } = extractChannelInfo(message);
345
- const rows = await Subs.select().where({
346
- adapter_name: adapterName,
347
- channel_type: channelType,
348
- channel_id: channelId,
349
- });
350
- if (rows.length === 0)
351
- return "当前会话没有订阅任何 RSS 源\n使用 rss-add <URL> 添加订阅";
352
- const lines = rows.map((r, i) => {
353
- const title = r.feed_title || "(未知标题)";
354
- return `${i + 1}. ${title}\n ${r.url}`;
355
- });
356
- return `当前订阅 (${rows.length}/${config.maxPerGroup}):\n${lines.join("\n")}`;
357
- }));
358
- // ─── 命令:rss-check ─────────────────────────────────────────────────────────
359
- addCommand(new MessageCommand("rss-check [url:text]")
360
- .desc("检查更新", "手动触发检查 RSS 更新,可指定某个源")
361
- .usage("rss-check [URL]")
362
- .action(async (message, result) => {
363
- const Subs = getSubs();
364
- const Seen = getSeen();
365
- if (!Subs || !Seen)
366
- return "RSS 数据库尚未就绪";
367
- const targetUrl = result.params.url?.trim();
368
- const { adapterName, channelType, channelId } = extractChannelInfo(message);
369
- let subs;
370
- if (targetUrl) {
371
- subs = await Subs.select().where({
372
- url: targetUrl,
373
- adapter_name: adapterName,
374
- channel_type: channelType,
375
- channel_id: channelId,
376
- });
377
- if (subs.length === 0)
378
- return "当前会话未订阅该源";
379
- }
380
- else {
381
- subs = await Subs.select().where({
382
- adapter_name: adapterName,
383
- channel_type: channelType,
384
- channel_id: channelId,
385
- });
386
- if (subs.length === 0)
387
- return "当前会话没有任何订阅";
388
- }
389
- const urlSet = new Set(subs.map((s) => s.url));
390
- let totalNew = 0;
391
- for (const url of urlSet) {
392
- try {
393
- const { title, items } = await fetchFeed(url);
394
- const seenRows = await Seen.select().where({ feed_url: url });
395
- const seenGuids = new Set(seenRows.map((r) => r.item_guid));
396
- const newItems = items.filter((item) => !seenGuids.has(item.guid));
397
- if (newItems.length > 0) {
398
- for (const item of newItems) {
399
- await Seen.insert({ feed_url: url, item_guid: item.guid, item_title: item.title, seen_at: ts() });
400
- }
401
- totalNew += newItems.length;
402
- const msg = formatItems(title, newItems);
403
- await pushToChannel(adapterName, subs[0].endpoint_id, channelType, channelId, msg);
404
- }
405
- }
406
- catch (e) {
407
- await message.$reply(`检查 ${url} 失败: ${e.message}`);
408
- }
409
- }
410
- if (totalNew === 0)
411
- return `已检查 ${urlSet.size} 个源,没有新内容`;
412
- return "";
413
- }));
414
- // ─── 命令:rss-preview ───────────────────────────────────────────────────────
415
- addCommand(new MessageCommand("rss-preview <url:text>")
416
- .desc("预览 RSS", "预览一个 RSS 源的最新内容(不订阅)")
417
- .usage("rss-preview <URL>")
418
- .examples("rss-preview https://sspai.com/feed")
419
- .action(async (_message, result) => {
420
- const url = result.params.url?.trim();
421
- if (!url)
422
- return "请提供 RSS 源地址";
423
- if (!/^https?:\/\//i.test(url))
424
- return "请提供有效的 HTTP/HTTPS 地址";
425
- try {
426
- const { title, items } = await fetchFeed(url);
427
- if (items.length === 0)
428
- return `${title}: 暂无内容`;
429
- const lines = [`${title} (共 ${items.length} 条)`];
430
- for (const item of items.slice(0, 5)) {
431
- lines.push("");
432
- lines.push(`${item.title}`);
433
- if (item.date)
434
- lines.push(` ${new Date(item.date).toLocaleString("zh-CN")}`);
435
- if (item.link)
436
- lines.push(` ${item.link}`);
437
- }
438
- if (items.length > 5)
439
- lines.push(`\n...还有 ${items.length - 5} 条`);
440
- return lines.join("\n");
441
- }
442
- catch (e) {
443
- return `解析失败: ${e.message}\n请确认是有效的 RSS/Atom 地址`;
444
- }
445
- }));
446
- logger.debug(formatCompact({ op: "load", poll: config.pollCron, max_per_group: config.maxPerGroup }));
447
- //# sourceMappingURL=index.js.map