@zhin.js/plugin-rss 1.1.18 → 1.1.20
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.
- package/LICENSE +21 -0
- package/README.md +15 -4
- package/commands/rss-add/[url]/index.js +1 -1
- package/commands/rss-add/[url]/index.ts +1 -1
- package/commands/rss-preview/[url]/index.js +1 -1
- package/commands/rss-preview/[url]/index.ts +1 -1
- package/lib/admin-i18n.d.ts +6 -2
- package/lib/admin-i18n.d.ts.map +1 -1
- package/lib/admin-i18n.js +4 -0
- package/lib/admin-i18n.js.map +1 -1
- package/lib/admin-tasks.d.ts +5 -1
- package/lib/admin-tasks.d.ts.map +1 -1
- package/lib/admin-tasks.js +30 -12
- package/lib/admin-tasks.js.map +1 -1
- package/lib/console.d.ts.map +1 -1
- package/lib/console.js +91 -2
- package/lib/console.js.map +1 -1
- package/lib/feed.d.ts +9 -1
- package/lib/feed.d.ts.map +1 -1
- package/lib/feed.js +27 -7
- package/lib/feed.js.map +1 -1
- package/lib/poll.d.ts +7 -2
- package/lib/poll.d.ts.map +1 -1
- package/lib/poll.js +56 -23
- package/lib/poll.js.map +1 -1
- package/lib/public-fetch.d.ts +34 -0
- package/lib/public-fetch.d.ts.map +1 -0
- package/lib/public-fetch.js +144 -0
- package/lib/public-fetch.js.map +1 -0
- package/package.json +14 -19
- package/pages/index/index.js +59 -5
- package/pages/index/index.ts +59 -5
- package/plugin.js +4 -1
- package/schema.json +46 -3
- package/skills/rss/tools/rss_preview_feed/index.js +1 -1
- package/skills/rss/tools/rss_preview_feed/index.ts +1 -1
- package/src/admin-i18n.ts +4 -0
- package/src/admin-tasks.ts +13 -3
- package/src/console.ts +92 -2
- package/src/feed.ts +30 -7
- package/src/poll.ts +58 -23
- package/src/public-fetch.ts +160 -0
package/pages/index/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
-
import { definePage,
|
|
2
|
+
import { definePage, definePageBlueprint } from '@zhin.js/console-contract';
|
|
3
3
|
export const meta = definePage({
|
|
4
4
|
title: 'RSS 订阅',
|
|
5
5
|
titleI18n: { en: 'RSS Subscriptions' },
|
|
@@ -7,7 +7,7 @@ export const meta = definePage({
|
|
|
7
7
|
order: 52,
|
|
8
8
|
requiredPermissions: ['rss:read'],
|
|
9
9
|
});
|
|
10
|
-
export default
|
|
10
|
+
export default definePageBlueprint({
|
|
11
11
|
title: { zh: 'RSS 订阅', en: 'RSS Subscriptions' },
|
|
12
12
|
description: {
|
|
13
13
|
zh: '管理 RSS/Atom 订阅源、轮询频率与已读缓存',
|
|
@@ -32,19 +32,73 @@ export default createDeclarativePage({
|
|
|
32
32
|
],
|
|
33
33
|
actions: [
|
|
34
34
|
{
|
|
35
|
-
name: '
|
|
36
|
-
label: { zh: '
|
|
35
|
+
name: 'addSubscription',
|
|
36
|
+
label: { zh: '添加订阅', en: 'Add Subscription' },
|
|
37
37
|
variant: 'primary',
|
|
38
|
+
fields: [
|
|
39
|
+
{
|
|
40
|
+
key: 'url',
|
|
41
|
+
label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
|
|
42
|
+
type: 'text',
|
|
43
|
+
required: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: 'title',
|
|
47
|
+
label: { zh: '订阅名称', en: 'Feed Title' },
|
|
48
|
+
type: 'text',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'adapter',
|
|
52
|
+
label: { zh: '推送平台', en: 'Adapter' },
|
|
53
|
+
type: 'text',
|
|
54
|
+
defaultValue: 'sandbox',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'targetType',
|
|
58
|
+
label: { zh: '目标类型', en: 'Target Type' },
|
|
59
|
+
type: 'select',
|
|
60
|
+
defaultValue: 'group',
|
|
61
|
+
options: [
|
|
62
|
+
{ value: 'group', label: { zh: '群聊 (group)', en: 'Group' } },
|
|
63
|
+
{ value: 'private', label: { zh: '私聊 (private)', en: 'Private' } },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: 'targetId',
|
|
68
|
+
label: { zh: '推送目标 ID', en: 'Target ID' },
|
|
69
|
+
type: 'text',
|
|
70
|
+
defaultValue: 'console',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'removeSubscription',
|
|
76
|
+
label: { zh: '移除订阅', en: 'Remove Subscription' },
|
|
77
|
+
variant: 'danger',
|
|
78
|
+
fields: [
|
|
79
|
+
{
|
|
80
|
+
key: 'url',
|
|
81
|
+
label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
|
|
82
|
+
type: 'text',
|
|
83
|
+
required: true,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'pollNow',
|
|
89
|
+
label: { zh: '立即轮询', en: 'Poll Now' },
|
|
90
|
+
variant: 'secondary',
|
|
38
91
|
},
|
|
39
92
|
{
|
|
40
93
|
name: 'clean-seen',
|
|
41
94
|
label: { zh: '清理已读缓存', en: 'Clean seen cache' },
|
|
42
|
-
variant: '
|
|
95
|
+
variant: 'ghost',
|
|
43
96
|
},
|
|
44
97
|
],
|
|
45
98
|
sections: [
|
|
46
99
|
{
|
|
47
100
|
id: 'subscriptions',
|
|
101
|
+
layout: 'table',
|
|
48
102
|
title: { zh: '当前订阅列表', en: 'Active Subscriptions' },
|
|
49
103
|
emptyText: { zh: '暂无 RSS 订阅', en: 'No RSS subscriptions' },
|
|
50
104
|
items: 'subscriptions',
|
package/pages/index/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definePage,
|
|
1
|
+
import { definePage, definePageBlueprint } from '@zhin.js/console-contract';
|
|
2
2
|
|
|
3
3
|
export interface RssSubscriptionItem {
|
|
4
4
|
readonly id: string;
|
|
@@ -29,7 +29,7 @@ export const meta = definePage({
|
|
|
29
29
|
requiredPermissions: ['rss:read'],
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
export default
|
|
32
|
+
export default definePageBlueprint<RssConsoleStatus>({
|
|
33
33
|
title: { zh: 'RSS 订阅', en: 'RSS Subscriptions' },
|
|
34
34
|
description: {
|
|
35
35
|
zh: '管理 RSS/Atom 订阅源、轮询频率与已读缓存',
|
|
@@ -54,19 +54,73 @@ export default createDeclarativePage<RssConsoleStatus>({
|
|
|
54
54
|
],
|
|
55
55
|
actions: [
|
|
56
56
|
{
|
|
57
|
-
name: '
|
|
58
|
-
label: { zh: '
|
|
57
|
+
name: 'addSubscription',
|
|
58
|
+
label: { zh: '添加订阅', en: 'Add Subscription' },
|
|
59
59
|
variant: 'primary',
|
|
60
|
+
fields: [
|
|
61
|
+
{
|
|
62
|
+
key: 'url',
|
|
63
|
+
label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
|
|
64
|
+
type: 'text',
|
|
65
|
+
required: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'title',
|
|
69
|
+
label: { zh: '订阅名称', en: 'Feed Title' },
|
|
70
|
+
type: 'text',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: 'adapter',
|
|
74
|
+
label: { zh: '推送平台', en: 'Adapter' },
|
|
75
|
+
type: 'text',
|
|
76
|
+
defaultValue: 'sandbox',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: 'targetType',
|
|
80
|
+
label: { zh: '目标类型', en: 'Target Type' },
|
|
81
|
+
type: 'select',
|
|
82
|
+
defaultValue: 'group',
|
|
83
|
+
options: [
|
|
84
|
+
{ value: 'group', label: { zh: '群聊 (group)', en: 'Group' } },
|
|
85
|
+
{ value: 'private', label: { zh: '私聊 (private)', en: 'Private' } },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: 'targetId',
|
|
90
|
+
label: { zh: '推送目标 ID', en: 'Target ID' },
|
|
91
|
+
type: 'text',
|
|
92
|
+
defaultValue: 'console',
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'removeSubscription',
|
|
98
|
+
label: { zh: '移除订阅', en: 'Remove Subscription' },
|
|
99
|
+
variant: 'danger',
|
|
100
|
+
fields: [
|
|
101
|
+
{
|
|
102
|
+
key: 'url',
|
|
103
|
+
label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
|
|
104
|
+
type: 'text',
|
|
105
|
+
required: true,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'pollNow',
|
|
111
|
+
label: { zh: '立即轮询', en: 'Poll Now' },
|
|
112
|
+
variant: 'secondary',
|
|
60
113
|
},
|
|
61
114
|
{
|
|
62
115
|
name: 'clean-seen',
|
|
63
116
|
label: { zh: '清理已读缓存', en: 'Clean seen cache' },
|
|
64
|
-
variant: '
|
|
117
|
+
variant: 'ghost',
|
|
65
118
|
},
|
|
66
119
|
],
|
|
67
120
|
sections: [
|
|
68
121
|
{
|
|
69
122
|
id: 'subscriptions',
|
|
123
|
+
layout: 'table',
|
|
70
124
|
title: { zh: '当前订阅列表', en: 'Active Subscriptions' },
|
|
71
125
|
emptyText: { zh: '暂无 RSS 订阅', en: 'No RSS subscriptions' },
|
|
72
126
|
items: 'subscriptions',
|
package/plugin.js
CHANGED
|
@@ -78,6 +78,9 @@ export default definePlugin({
|
|
|
78
78
|
throw new DataMigrationError('unavailable', 'Historical RSS subscription awaits exact-target binding');
|
|
79
79
|
if (!outbound.sendTarget)
|
|
80
80
|
throw new Error('Canonical subscription delivery requires an exact-target Outbound Host');
|
|
81
|
+
// ADR 0050 / 0055: pushes are not messages, so the plugin checks its own switch per target conversation.
|
|
82
|
+
if (!context.isEnabledFor({ conversation: input.target }))
|
|
83
|
+
return;
|
|
81
84
|
const run = async () => { await outbound.sendTarget(input.target, input.content, { locale: input.locale }); };
|
|
82
85
|
if (migrationScopes)
|
|
83
86
|
await migrationScopes.run({ scope: subscriptionMigrationScope(input), run });
|
|
@@ -112,7 +115,7 @@ export default definePlugin({
|
|
|
112
115
|
}
|
|
113
116
|
catch { /* No persistent state port: no administrator provider. */ }
|
|
114
117
|
if (state) {
|
|
115
|
-
administrator = new AdministratorRssTasks(createAdministratorRssState(database), outbound, config, undefined, runtime.locale);
|
|
118
|
+
administrator = new AdministratorRssTasks(createAdministratorRssState(database), outbound, config, undefined, runtime.locale, target => context.isEnabledFor({ conversation: target }));
|
|
116
119
|
context.lifecycle.add(() => administrator?.stop());
|
|
117
120
|
}
|
|
118
121
|
}
|
package/schema.json
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
|
+
"x-zhin-config": {
|
|
6
|
+
"levels": [
|
|
7
|
+
"global",
|
|
8
|
+
"adapter",
|
|
9
|
+
"endpoint",
|
|
10
|
+
"conversation"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
5
13
|
"properties": {
|
|
6
14
|
"pollCron": {
|
|
7
15
|
"type": "string",
|
|
@@ -11,25 +19,60 @@
|
|
|
11
19
|
"en": {
|
|
12
20
|
"description": "Polling schedule (6-field cron expression)."
|
|
13
21
|
}
|
|
22
|
+
},
|
|
23
|
+
"x-zhin-config": {
|
|
24
|
+
"levels": [
|
|
25
|
+
"global"
|
|
26
|
+
]
|
|
14
27
|
}
|
|
15
28
|
},
|
|
16
29
|
"maxPerGroup": {
|
|
17
30
|
"type": "number",
|
|
18
31
|
"default": 30,
|
|
19
32
|
"minimum": 1,
|
|
20
|
-
"maximum": 200
|
|
33
|
+
"maximum": 200,
|
|
34
|
+
"x-zhin-config": {
|
|
35
|
+
"levels": [
|
|
36
|
+
"global"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
21
39
|
},
|
|
22
40
|
"maxItems": {
|
|
23
41
|
"type": "number",
|
|
24
42
|
"default": 5,
|
|
25
43
|
"minimum": 1,
|
|
26
|
-
"maximum": 20
|
|
44
|
+
"maximum": 20,
|
|
45
|
+
"x-zhin-config": {
|
|
46
|
+
"levels": [
|
|
47
|
+
"global"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
27
50
|
},
|
|
28
51
|
"timeout": {
|
|
29
52
|
"type": "number",
|
|
30
53
|
"default": 15000,
|
|
31
54
|
"minimum": 1000,
|
|
32
|
-
"maximum": 60000
|
|
55
|
+
"maximum": 60000,
|
|
56
|
+
"x-zhin-config": {
|
|
57
|
+
"levels": [
|
|
58
|
+
"global"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"allowPrivateNetwork": {
|
|
63
|
+
"type": "boolean",
|
|
64
|
+
"default": false,
|
|
65
|
+
"description": "允许订阅回环/内网地址上的 feed(如自建 RSSHub)。默认拒绝,因为订阅地址来自聊天用户。",
|
|
66
|
+
"x-i18n": {
|
|
67
|
+
"en": {
|
|
68
|
+
"description": "Allow feeds on loopback/LAN addresses (for example a self-hosted RSSHub). Refused by default because feed URLs come from chat users."
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"x-zhin-config": {
|
|
72
|
+
"levels": [
|
|
73
|
+
"global"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
33
76
|
}
|
|
34
77
|
}
|
|
35
78
|
}
|
|
@@ -12,7 +12,7 @@ export default defineAgentTool({
|
|
|
12
12
|
return t(context.locale, 'urlRequired');
|
|
13
13
|
try {
|
|
14
14
|
const runtime = context.use(rssRuntimeToken);
|
|
15
|
-
const { title, items } = await fetchFeed(url, runtime.config
|
|
15
|
+
const { title, items } = await fetchFeed(url, runtime.config);
|
|
16
16
|
if (items.length === 0)
|
|
17
17
|
return t(context.locale, 'toolPreviewEmpty', { title });
|
|
18
18
|
return items
|
|
@@ -11,7 +11,7 @@ export default defineAgentTool<{ url: string }>({
|
|
|
11
11
|
if (!url) return t(context.locale, 'urlRequired');
|
|
12
12
|
try {
|
|
13
13
|
const runtime = context.use(rssRuntimeToken);
|
|
14
|
-
const { title, items } = await fetchFeed(url, runtime.config
|
|
14
|
+
const { title, items } = await fetchFeed(url, runtime.config);
|
|
15
15
|
if (items.length === 0) return t(context.locale, 'toolPreviewEmpty', { title });
|
|
16
16
|
return items
|
|
17
17
|
.slice(0, 5)
|
package/src/admin-i18n.ts
CHANGED
|
@@ -146,6 +146,10 @@ const catalog = {
|
|
|
146
146
|
"zh-CN": "平台未确认投递,请核实目标会话后取消并另建订阅;不会自动重试",
|
|
147
147
|
"en": "The platform did not confirm delivery. Check the target conversation, then cancel and create another subscription; no automatic retry."
|
|
148
148
|
},
|
|
149
|
+
"featureDisabled": {
|
|
150
|
+
"zh-CN": "RSS 已在目标会话停用(功能开关),本轮未投递;重新启用后从后续新文章开始推送",
|
|
151
|
+
"en": "RSS is switched off for the target conversation (feature switch); nothing was delivered this poll. Future articles are delivered once it is re-enabled."
|
|
152
|
+
},
|
|
149
153
|
"delivered": {
|
|
150
154
|
"zh-CN": "已确认投递;等待后续新文章",
|
|
151
155
|
"en": "Delivery confirmed; waiting for future articles."
|
package/src/admin-tasks.ts
CHANGED
|
@@ -70,15 +70,22 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
70
70
|
readonly #outbound: OutboundHost;
|
|
71
71
|
readonly #config: Readonly<RssConfig>;
|
|
72
72
|
readonly #load: FeedLoader;
|
|
73
|
+
readonly #enabledFor: (target: DeliveryTarget) => boolean;
|
|
73
74
|
readonly projectLocale: Locale;
|
|
74
75
|
readonly #admission = createGenerationAdmissionGate();
|
|
75
76
|
#stopped = false;
|
|
76
77
|
#working = false;
|
|
77
78
|
get [generationAdmissionSource]() { return [this.#admission]; }
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
/**
|
|
81
|
+
* `enabledFor` is the ADR 0050 / 0055 feature switch of the target conversation (pushes are not
|
|
82
|
+
* messages, so dispatch gating never sees them); a switched-off target is skipped, not sent.
|
|
83
|
+
*/
|
|
84
|
+
constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed, projectLocale: Locale = 'zh-CN',
|
|
85
|
+
enabledFor: (target: DeliveryTarget) => boolean = () => true) {
|
|
80
86
|
if (!outbound.sendTarget) throw unavailable('exactTarget');
|
|
81
87
|
this.projectLocale = projectLocale; this.#state = state; this.#outbound = outbound; this.#config = config; this.#load = load;
|
|
88
|
+
this.#enabledFor = enabledFor;
|
|
82
89
|
}
|
|
83
90
|
stop(): void { this.#stopped = true; }
|
|
84
91
|
|
|
@@ -169,7 +176,7 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
169
176
|
if (this.#stopped) break;
|
|
170
177
|
if (task.state !== 'scheduled' || flushed.has(task.id)) continue;
|
|
171
178
|
let feed: Awaited<ReturnType<FeedLoader>>;
|
|
172
|
-
try { feed = await this.#load(task.url, this.#config
|
|
179
|
+
try { feed = await this.#load(task.url, this.#config); }
|
|
173
180
|
catch { await this.#recordCheck(task, 'fetchFailed'); continue; }
|
|
174
181
|
if (this.#stopped) break;
|
|
175
182
|
// Keep only bounded identifiers, never arbitrary feed bodies, in durable state.
|
|
@@ -211,7 +218,10 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
211
218
|
async #deliver(claimed: Task, content: string): Promise<void> {
|
|
212
219
|
if (this.#stopped) return;
|
|
213
220
|
let state: Task['state'] = 'unknown', detail = taskDetail('unknown');
|
|
214
|
-
|
|
221
|
+
let enabled: boolean;
|
|
222
|
+
try { enabled = this.#enabledFor(claimed.target); } catch { enabled = true; }
|
|
223
|
+
if (!enabled) { state = 'scheduled'; detail = taskDetail('featureDisabled'); }
|
|
224
|
+
else try {
|
|
215
225
|
const id = await this.#outbound.sendTarget!(claimed.target, content, { locale: resolveLocale(claimed.locale) });
|
|
216
226
|
if (id) { state = 'scheduled'; detail = taskDetail('delivered'); }
|
|
217
227
|
} catch (error) {
|
package/src/console.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type PluginConsoleRpcInput,
|
|
8
8
|
type PluginConsoleRpcJson,
|
|
9
9
|
} from 'zhin.js';
|
|
10
|
-
import { listAllSubscriptions } from './db-store.js';
|
|
10
|
+
import { getRssSubs, listAllSubscriptions } from './db-store.js';
|
|
11
11
|
import { cleanOldSeen, pollAllFeeds } from './poll.js';
|
|
12
12
|
import { rssRuntimeToken, type RssRuntime } from './runtime.js';
|
|
13
13
|
|
|
@@ -39,6 +39,8 @@ function resolveRssRuntime(call: PluginConsoleRpcCall, fallback?: RssRuntime): R
|
|
|
39
39
|
|
|
40
40
|
export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleRpc {
|
|
41
41
|
const rpc = definePluginConsoleRpc({
|
|
42
|
+
// Day-to-day edits belong to operators; `:write` would otherwise infer `manage` (ADR 0049).
|
|
43
|
+
permissions: [{ id: 'rss:write', level: 'operate', description: '编辑订阅' }],
|
|
42
44
|
status: {
|
|
43
45
|
page: 'index',
|
|
44
46
|
requiredPermissions: ['rss:read'],
|
|
@@ -58,6 +60,86 @@ export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleR
|
|
|
58
60
|
},
|
|
59
61
|
},
|
|
60
62
|
actions: {
|
|
63
|
+
'add-subscription': {
|
|
64
|
+
page: 'index',
|
|
65
|
+
requiredPermissions: ['rss:write'],
|
|
66
|
+
async handler(input: PluginConsoleRpcInput, call: PluginConsoleRpcCall): Promise<PluginConsoleRpcJson> {
|
|
67
|
+
const runtime = resolveRssRuntime(call, defaultRuntime);
|
|
68
|
+
const url = typeof input.url === 'string' ? input.url.trim() : '';
|
|
69
|
+
let parsedUrl: URL;
|
|
70
|
+
try {
|
|
71
|
+
parsedUrl = new URL(url);
|
|
72
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
73
|
+
throw new Error('invalid protocol');
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
throw new PluginConsoleRpcError('invalid_input', '请提供有效的 HTTP/HTTPS RSS 订阅源 URL');
|
|
77
|
+
}
|
|
78
|
+
const title =
|
|
79
|
+
typeof input.title === 'string' && input.title.trim()
|
|
80
|
+
? input.title.trim()
|
|
81
|
+
: parsedUrl.hostname;
|
|
82
|
+
const adapter =
|
|
83
|
+
typeof input.adapter === 'string' && input.adapter.trim()
|
|
84
|
+
? input.adapter.trim()
|
|
85
|
+
: 'sandbox';
|
|
86
|
+
const targetType = input.targetType === 'private' ? 'private' : 'group';
|
|
87
|
+
const targetId =
|
|
88
|
+
typeof input.targetId === 'string' && input.targetId.trim()
|
|
89
|
+
? input.targetId.trim()
|
|
90
|
+
: 'console';
|
|
91
|
+
const Subs = getRssSubs(runtime.db);
|
|
92
|
+
if (!Subs) {
|
|
93
|
+
throw new PluginConsoleRpcError('unavailable', 'RSS 订阅表尚未初始化');
|
|
94
|
+
}
|
|
95
|
+
await Subs.insert({
|
|
96
|
+
url,
|
|
97
|
+
feed_title: title,
|
|
98
|
+
adapter_name: adapter,
|
|
99
|
+
endpoint_id: adapter,
|
|
100
|
+
channel_type: targetType,
|
|
101
|
+
channel_id: targetId,
|
|
102
|
+
creator_id: 'console',
|
|
103
|
+
creator_name: 'Console',
|
|
104
|
+
created_at: new Date().toISOString(),
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
url,
|
|
109
|
+
title,
|
|
110
|
+
adapter,
|
|
111
|
+
targetType,
|
|
112
|
+
targetId,
|
|
113
|
+
message: `已添加 RSS 订阅:${title} (${url})`,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
'remove-subscription': {
|
|
118
|
+
page: 'index',
|
|
119
|
+
requiredPermissions: ['rss:write'],
|
|
120
|
+
async handler(input: PluginConsoleRpcInput, call: PluginConsoleRpcCall): Promise<PluginConsoleRpcJson> {
|
|
121
|
+
const runtime = resolveRssRuntime(call, defaultRuntime);
|
|
122
|
+
const url = typeof input.url === 'string' ? input.url.trim() : '';
|
|
123
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
124
|
+
if (!url && !id) {
|
|
125
|
+
throw new PluginConsoleRpcError('invalid_input', '请提供要移除的 RSS 订阅 URL 或 ID');
|
|
126
|
+
}
|
|
127
|
+
const Subs = getRssSubs(runtime.db);
|
|
128
|
+
if (!Subs) {
|
|
129
|
+
throw new PluginConsoleRpcError('unavailable', 'RSS 订阅表尚未初始化');
|
|
130
|
+
}
|
|
131
|
+
if (url) {
|
|
132
|
+
await Subs.delete().where({ url });
|
|
133
|
+
} else {
|
|
134
|
+
await Subs.delete().where({ id });
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
url: url || id,
|
|
139
|
+
message: `已移除 RSS 订阅:${url || id}`,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
},
|
|
61
143
|
'poll-now': {
|
|
62
144
|
page: 'index',
|
|
63
145
|
requiredPermissions: ['rss:write'],
|
|
@@ -83,7 +165,15 @@ export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleR
|
|
|
83
165
|
const origGet = methods.get.bind(methods);
|
|
84
166
|
const origHas = methods.has.bind(methods);
|
|
85
167
|
const normalizeKey = (key: string) =>
|
|
86
|
-
key === 'pollNow'
|
|
168
|
+
key === 'pollNow'
|
|
169
|
+
? 'poll-now'
|
|
170
|
+
: key === 'cleanSeen'
|
|
171
|
+
? 'clean-seen'
|
|
172
|
+
: key === 'addSubscription'
|
|
173
|
+
? 'add-subscription'
|
|
174
|
+
: key === 'removeSubscription'
|
|
175
|
+
? 'remove-subscription'
|
|
176
|
+
: key;
|
|
87
177
|
methods.get = (key: string) => origGet(normalizeKey(key));
|
|
88
178
|
methods.has = (key: string) => origHas(normalizeKey(key));
|
|
89
179
|
return rpc;
|
package/src/feed.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { rssMessages as t } from './i18n.js';
|
|
|
4
4
|
* RSS feed fetch/preview helpers (no Plugin host).
|
|
5
5
|
*/
|
|
6
6
|
import Parser from 'rss-parser';
|
|
7
|
+
import { fetchPublic, readCappedBody, type HostLookup } from './public-fetch.js';
|
|
8
|
+
|
|
9
|
+
/** Feeds larger than this are refused instead of buffered. */
|
|
10
|
+
const MAX_FEED_BYTES = 5 * 1024 * 1024;
|
|
7
11
|
|
|
8
12
|
export interface FeedItem {
|
|
9
13
|
title: string;
|
|
@@ -20,6 +24,8 @@ export interface RssConfig {
|
|
|
20
24
|
readonly timeout: number;
|
|
21
25
|
readonly maxItemsPerPoll?: number;
|
|
22
26
|
readonly fetchTimeoutMs?: number;
|
|
27
|
+
/** Let feeds live on loopback/LAN addresses (self-hosted RSSHub and the like). Default false. */
|
|
28
|
+
readonly allowPrivateNetwork?: boolean;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const DEFAULT_RSS_CONFIG: RssConfig = {
|
|
@@ -79,18 +85,35 @@ export function stripHtml(html: string): string {
|
|
|
79
85
|
return out;
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Fetch and parse a feed. The URL comes from chat users, so it is requested through the
|
|
90
|
+
* public-address policy (every redirect hop re-checked), with one timeout for the whole
|
|
91
|
+
* exchange and a body cap.
|
|
92
|
+
*/
|
|
82
93
|
export async function fetchFeed(
|
|
83
94
|
url: string,
|
|
84
|
-
timeout = DEFAULT_RSS_CONFIG
|
|
95
|
+
options: number | Pick<RssConfig, 'timeout' | 'allowPrivateNetwork'> = DEFAULT_RSS_CONFIG,
|
|
96
|
+
lookup?: HostLookup,
|
|
85
97
|
): Promise<{ title: string; items: FeedItem[] }> {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
const { timeout, allowPrivateNetwork = false } = typeof options === 'number' ? { timeout: options } : options;
|
|
99
|
+
const { response } = await fetchPublic(url, {
|
|
100
|
+
timeoutMs: timeout,
|
|
101
|
+
lookup,
|
|
102
|
+
allowPrivateNetwork,
|
|
103
|
+
init: {
|
|
104
|
+
headers: {
|
|
105
|
+
'User-Agent': 'ZhinBot-RSS/1.0',
|
|
106
|
+
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
|
|
107
|
+
},
|
|
91
108
|
},
|
|
92
109
|
});
|
|
93
|
-
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
await response.body?.cancel();
|
|
112
|
+
throw new Error(`Status code ${response.status}`);
|
|
113
|
+
}
|
|
114
|
+
const body = await readCappedBody(response, MAX_FEED_BYTES);
|
|
115
|
+
if (!body) throw new Error(`Feed is larger than ${MAX_FEED_BYTES} bytes`);
|
|
116
|
+
const feed = await new Parser().parseString(body.toString('utf8'));
|
|
94
117
|
const items: FeedItem[] = (feed.items || []).slice(0, 20).map((item) => ({
|
|
95
118
|
title: (item.title || '').trim(),
|
|
96
119
|
link: (item.link || '').trim(),
|
package/src/poll.ts
CHANGED
|
@@ -8,10 +8,22 @@ import { fetchFeed, type FeedItem } from './feed.js';
|
|
|
8
8
|
import { getRssSeen, getRssSubs } from './db-store.js';
|
|
9
9
|
import type { RssRuntime } from './runtime.js';
|
|
10
10
|
|
|
11
|
+
/** Seen rows are kept this long after the item was last present in its feed. */
|
|
12
|
+
export const SEEN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
/** Seen rows of items still in the feed are re-stamped at most this often. */
|
|
14
|
+
const SEEN_REFRESH_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
11
16
|
function ts(): string {
|
|
12
17
|
return new Date().toISOString();
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
/** True for an item whose own publish date is past the retention window. Undated items are never stale. */
|
|
21
|
+
function isStale(item: FeedItem, cutoffMs: number): boolean {
|
|
22
|
+
if (!item.date) return false;
|
|
23
|
+
const published = Date.parse(item.date);
|
|
24
|
+
return Number.isFinite(published) && published < cutoffMs;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: number, locale: Locale = 'zh-CN'): string {
|
|
16
28
|
const lines = [t(locale, 'newItems', { title: feedTitle, count: items.length })];
|
|
17
29
|
for (const item of items.slice(0, maxItems)) {
|
|
@@ -26,19 +38,28 @@ export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: n
|
|
|
26
38
|
return lines.join('\n');
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
export async function markItemsSeen(
|
|
41
|
+
export async function markItemsSeen(
|
|
42
|
+
runtime: RssRuntime,
|
|
43
|
+
feedUrl: string,
|
|
44
|
+
items: FeedItem[],
|
|
45
|
+
/** Guids already stored for this feed; loaded once when the caller does not have them. */
|
|
46
|
+
knownGuids?: ReadonlySet<string>,
|
|
47
|
+
): Promise<void> {
|
|
30
48
|
const Seen = getRssSeen(runtime.db);
|
|
31
|
-
if (!Seen) return;
|
|
49
|
+
if (!Seen || items.length === 0) return;
|
|
50
|
+
const known = new Set(knownGuids
|
|
51
|
+
?? (await Seen.select().where({ feed_url: feedUrl })).map((row) => String(row.item_guid ?? '')));
|
|
52
|
+
// One timestamp per batch so cleanup can drop the whole batch with a single delete.
|
|
53
|
+
const seenAt = ts();
|
|
32
54
|
for (const item of items) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
55
|
+
if (known.has(item.guid)) continue;
|
|
56
|
+
known.add(item.guid);
|
|
57
|
+
await Seen.insert({
|
|
58
|
+
feed_url: feedUrl,
|
|
59
|
+
item_guid: item.guid,
|
|
60
|
+
item_title: item.title,
|
|
61
|
+
seen_at: seenAt,
|
|
62
|
+
});
|
|
42
63
|
}
|
|
43
64
|
}
|
|
44
65
|
|
|
@@ -110,15 +131,28 @@ export async function checkSubscriptions(runtime: RssRuntime, options: {
|
|
|
110
131
|
|
|
111
132
|
for (const url of options.urls) {
|
|
112
133
|
try {
|
|
113
|
-
const { title, items } = await fetchFeed(url, runtime.config
|
|
134
|
+
const { title, items } = await fetchFeed(url, runtime.config);
|
|
114
135
|
const seenRows = await Seen.select().where({ feed_url: url });
|
|
115
|
-
const
|
|
116
|
-
const
|
|
136
|
+
const seenAt = new Map(seenRows.map((r) => [String(r.item_guid ?? ''), String(r.seen_at ?? '')]));
|
|
137
|
+
const seenGuids = new Set(seenAt.keys());
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
// Keep the seen row of an item that is still listed, so it is not re-announced after cleanup.
|
|
140
|
+
const refreshBefore = new Date(now - SEEN_REFRESH_MS).toISOString();
|
|
141
|
+
const refreshed = ts();
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
const stamp = seenAt.get(item.guid);
|
|
144
|
+
if (item.guid && stamp !== undefined && stamp < refreshBefore) {
|
|
145
|
+
await Seen.update({ seen_at: refreshed }).where({ feed_url: url, item_guid: item.guid });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// An item older than the retention window can no longer have a seen row; never treat it as new.
|
|
149
|
+
const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid)
|
|
150
|
+
&& !isStale(item, now - SEEN_RETENTION_MS));
|
|
117
151
|
if (newItems.length === 0) {
|
|
118
152
|
parts.push(t(locale, 'noNew', { title: title || url }));
|
|
119
153
|
continue;
|
|
120
154
|
}
|
|
121
|
-
await markItemsSeen(runtime, url, newItems);
|
|
155
|
+
await markItemsSeen(runtime, url, newItems, seenGuids);
|
|
122
156
|
totalNew += newItems.length;
|
|
123
157
|
const body = formatNewItems(title || url, newItems, runtime.config.maxItems, locale);
|
|
124
158
|
parts.push(body);
|
|
@@ -139,21 +173,22 @@ export async function checkSubscriptions(runtime: RssRuntime, options: {
|
|
|
139
173
|
}
|
|
140
174
|
|
|
141
175
|
/**
|
|
142
|
-
* Periodically clean seen records older than 7 days.
|
|
176
|
+
* Periodically clean seen records older than 7 days. Rows of items still listed in their
|
|
177
|
+
* feed are re-stamped by checkSubscriptions and therefore survive.
|
|
143
178
|
*/
|
|
144
179
|
export async function cleanOldSeen(runtime: RssRuntime): Promise<void> {
|
|
145
180
|
const Seen = getRssSeen(runtime.db);
|
|
146
181
|
if (!Seen) return;
|
|
147
182
|
try {
|
|
148
|
-
const
|
|
149
|
-
cutoff.setDate(cutoff.getDate() - 7);
|
|
150
|
-
const cutoffStr = cutoff.toISOString();
|
|
183
|
+
const cutoffStr = new Date(Date.now() - SEEN_RETENTION_MS).toISOString();
|
|
151
184
|
const all = await Seen.select();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
185
|
+
// The table has no id column. Rows of one batch share (feed_url, seen_at), so one delete per batch.
|
|
186
|
+
const batches = new Map<string, { feed_url: unknown; seen_at: string }>();
|
|
187
|
+
for (const row of all) {
|
|
188
|
+
if (typeof row.seen_at !== 'string' || row.seen_at >= cutoffStr) continue;
|
|
189
|
+
batches.set(`${String(row.feed_url)}\0${row.seen_at}`, { feed_url: row.feed_url, seen_at: row.seen_at });
|
|
156
190
|
}
|
|
191
|
+
for (const batch of batches.values()) await Seen.delete().where(batch);
|
|
157
192
|
} catch (e) {
|
|
158
193
|
// Cleanup failure must not break the main flow, but it must be visible.
|
|
159
194
|
console.warn(`[rss] cleanOldSeen failed: ${e instanceof Error ? e.message : String(e)}`);
|