@zhin.js/plugin-rss 3.0.2 → 3.0.4
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/agent/tools/rss_list_subscriptions.ts +2 -2
- package/agent/tools/rss_preview_feed.ts +2 -2
- package/commands/rss-add/[url:string].ts +60 -0
- package/commands/rss-check/[url:string=].ts +40 -0
- package/commands/rss-list.ts +31 -0
- package/commands/rss-preview/[url:string].ts +18 -0
- package/commands/rss-remove/[url:string].ts +29 -0
- package/lib/channel.d.ts +16 -0
- package/lib/channel.d.ts.map +1 -0
- package/lib/channel.js +36 -0
- package/lib/channel.js.map +1 -0
- package/lib/db-store.d.ts +9 -0
- package/lib/db-store.d.ts.map +1 -0
- package/lib/db-store.js +24 -0
- package/lib/db-store.js.map +1 -0
- package/lib/feed.d.ts +29 -0
- package/lib/feed.d.ts.map +1 -0
- package/lib/feed.js +89 -0
- package/lib/feed.js.map +1 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -0
- package/lib/memory-store.d.ts +28 -0
- package/lib/memory-store.d.ts.map +1 -0
- package/lib/memory-store.js +57 -0
- package/lib/memory-store.js.map +1 -0
- package/lib/poll.d.ts +37 -0
- package/lib/poll.d.ts.map +1 -0
- package/lib/poll.js +152 -0
- package/lib/poll.js.map +1 -0
- package/lib/rss-agent-deps.d.ts +15 -0
- package/lib/rss-agent-deps.d.ts.map +1 -0
- package/lib/rss-agent-deps.js.map +1 -0
- package/package.json +37 -21
- package/plugin.ts +103 -0
- package/schema.json +30 -0
- package/src/channel.ts +58 -0
- package/src/db-store.ts +42 -0
- package/src/feed.ts +106 -0
- package/src/index.ts +19 -506
- package/src/memory-store.ts +86 -0
- package/src/poll.ts +169 -0
- package/lib/agent/tools/rss_list_subscriptions.js +0 -32
- package/lib/agent/tools/rss_list_subscriptions.js.map +0 -1
- package/lib/agent/tools/rss_preview_feed.js +0 -25
- package/lib/agent/tools/rss_preview_feed.js.map +0 -1
- package/lib/src/index.js +0 -447
- package/lib/src/index.js.map +0 -1
- package/lib/src/rss-agent-deps.js.map +0 -1
- /package/lib/{src/rss-agent-deps.js → rss-agent-deps.js} +0 -0
package/lib/poll.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed poll helpers: detect new items; optional OutboundHost push to subscribers.
|
|
3
|
+
*/
|
|
4
|
+
import { fetchFeed } from './feed.js';
|
|
5
|
+
import { getRssSeen, getRssSubs } from './db-store.js';
|
|
6
|
+
let _outboundPush = null;
|
|
7
|
+
export function setRssOutboundPush(push) {
|
|
8
|
+
_outboundPush = push;
|
|
9
|
+
}
|
|
10
|
+
export function getRssOutboundPush() {
|
|
11
|
+
return _outboundPush;
|
|
12
|
+
}
|
|
13
|
+
function ts() {
|
|
14
|
+
return new Date().toISOString();
|
|
15
|
+
}
|
|
16
|
+
export function formatNewItems(feedTitle, items, maxItems) {
|
|
17
|
+
const lines = [`${feedTitle} 有 ${items.length} 条新内容:`];
|
|
18
|
+
for (const item of items.slice(0, maxItems)) {
|
|
19
|
+
lines.push('');
|
|
20
|
+
lines.push(`${item.title}`);
|
|
21
|
+
if (item.summary)
|
|
22
|
+
lines.push(item.summary);
|
|
23
|
+
if (item.link)
|
|
24
|
+
lines.push(item.link);
|
|
25
|
+
}
|
|
26
|
+
if (items.length > maxItems) {
|
|
27
|
+
lines.push(`\n...还有 ${items.length - maxItems} 条,请查看源站`);
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
export async function markItemsSeen(feedUrl, items) {
|
|
32
|
+
const Seen = getRssSeen();
|
|
33
|
+
if (!Seen)
|
|
34
|
+
return;
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
const dup = await Seen.select().where({ feed_url: feedUrl, item_guid: item.guid });
|
|
37
|
+
if (dup.length === 0) {
|
|
38
|
+
await Seen.insert({
|
|
39
|
+
feed_url: feedUrl,
|
|
40
|
+
item_guid: item.guid,
|
|
41
|
+
item_title: item.title,
|
|
42
|
+
seen_at: ts(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function pushToSubscribers(feedUrl, content) {
|
|
48
|
+
const push = _outboundPush;
|
|
49
|
+
const Subs = getRssSubs();
|
|
50
|
+
if (!push || !Subs)
|
|
51
|
+
return 0;
|
|
52
|
+
const subscribers = await Subs.select().where({ url: feedUrl });
|
|
53
|
+
let pushed = 0;
|
|
54
|
+
for (const sub of subscribers) {
|
|
55
|
+
const adapterName = String(sub.adapter_name ?? '');
|
|
56
|
+
const channelId = String(sub.channel_id ?? '');
|
|
57
|
+
if (!adapterName || !channelId)
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
await push({
|
|
61
|
+
adapterName,
|
|
62
|
+
endpointId: String(sub.endpoint_id ?? adapterName),
|
|
63
|
+
channelType: String(sub.channel_type ?? 'private'),
|
|
64
|
+
channelId,
|
|
65
|
+
content,
|
|
66
|
+
});
|
|
67
|
+
pushed += 1;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// OutboundHost already logs; continue other subscribers.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return pushed;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Check subscribed feeds for new items and mark them seen.
|
|
77
|
+
* When OutboundHost is wired, pushes formatted text to each subscriber channel.
|
|
78
|
+
*/
|
|
79
|
+
export async function checkSubscriptions(options) {
|
|
80
|
+
const Seen = getRssSeen();
|
|
81
|
+
const Subs = getRssSubs();
|
|
82
|
+
if (!Seen || !Subs) {
|
|
83
|
+
return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
|
|
84
|
+
}
|
|
85
|
+
const parts = [];
|
|
86
|
+
let totalNew = 0;
|
|
87
|
+
let pushed = 0;
|
|
88
|
+
for (const url of options.urls) {
|
|
89
|
+
try {
|
|
90
|
+
const { title, items } = await fetchFeed(url, options.config.timeout);
|
|
91
|
+
const seenRows = await Seen.select().where({ feed_url: url });
|
|
92
|
+
const seenGuids = new Set(seenRows.map((r) => String(r.item_guid ?? '')));
|
|
93
|
+
const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid));
|
|
94
|
+
if (newItems.length === 0) {
|
|
95
|
+
parts.push(`${title || url}: 无新内容`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
await markItemsSeen(url, newItems);
|
|
99
|
+
totalNew += newItems.length;
|
|
100
|
+
const body = formatNewItems(title || url, newItems, options.config.maxItems);
|
|
101
|
+
parts.push(body);
|
|
102
|
+
pushed += await pushToSubscribers(url, body);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
parts.push(`${url}: 检查失败 — ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (parts.length === 0)
|
|
109
|
+
return { text: '没有可检查的订阅', totalNew: 0, pushed: 0 };
|
|
110
|
+
const pushNote = _outboundPush
|
|
111
|
+
? (pushed > 0 ? `,已推送 ${pushed} 个会话` : ',无出站推送')
|
|
112
|
+
: '(未装配 OutboundHost,仅本地标记)';
|
|
113
|
+
const header = totalNew > 0
|
|
114
|
+
? `检查完成,共 ${totalNew} 条新内容${pushNote}`
|
|
115
|
+
: '检查完成,暂无新内容';
|
|
116
|
+
return { text: `${header}\n\n${parts.join('\n\n')}`, totalNew, pushed };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Periodically clean seen records older than 7 days.
|
|
120
|
+
*/
|
|
121
|
+
export async function cleanOldSeen() {
|
|
122
|
+
const Seen = getRssSeen();
|
|
123
|
+
if (!Seen)
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
const cutoff = new Date();
|
|
127
|
+
cutoff.setDate(cutoff.getDate() - 7);
|
|
128
|
+
const cutoffStr = cutoff.toISOString();
|
|
129
|
+
const all = await Seen.select();
|
|
130
|
+
const expired = all.filter((r) => typeof r.seen_at === 'string' && r.seen_at < cutoffStr);
|
|
131
|
+
for (const row of expired) {
|
|
132
|
+
await Seen.delete().where({ id: row.id });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Cleanup failure must not break the main flow.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Poll all unique feed URLs across subscriptions.
|
|
141
|
+
*/
|
|
142
|
+
export async function pollAllFeeds(config) {
|
|
143
|
+
const Subs = getRssSubs();
|
|
144
|
+
if (!Subs)
|
|
145
|
+
return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
|
|
146
|
+
const all = await Subs.select();
|
|
147
|
+
const urls = [...new Set(all.map((s) => String(s.url ?? '')).filter(Boolean))];
|
|
148
|
+
if (urls.length === 0)
|
|
149
|
+
return { text: '暂无任何 RSS 订阅', totalNew: 0, pushed: 0 };
|
|
150
|
+
return checkSubscriptions({ urls, config });
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=poll.js.map
|
package/lib/poll.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"poll.js","sourceRoot":"","sources":["../src/poll.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface RssAgentDeps {
|
|
2
|
+
getSubs: () => {
|
|
3
|
+
select: () => Promise<unknown[]>;
|
|
4
|
+
} | null;
|
|
5
|
+
fetchFeed: (url: string) => Promise<{
|
|
6
|
+
title: string;
|
|
7
|
+
items: Array<{
|
|
8
|
+
title: string;
|
|
9
|
+
link: string;
|
|
10
|
+
}>;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
export declare function setRssAgentDeps(next: RssAgentDeps): void;
|
|
14
|
+
export declare function getRssAgentDeps(): RssAgentDeps;
|
|
15
|
+
//# sourceMappingURL=rss-agent-deps.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rss-agent-deps.d.ts","sourceRoot":"","sources":["../src/rss-agent-deps.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM;QAAE,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3D,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAC;CACvG;AAID,wBAAgB,eAAe,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAExD;AAED,wBAAgB,eAAe,IAAI,YAAY,CAG9C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rss-agent-deps.js","sourceRoot":"","sources":["../src/rss-agent-deps.ts"],"names":[],"mappings":"AAKA,IAAI,IAAI,GAAwB,IAAI,CAAC;AAErC,MAAM,UAAU,eAAe,CAAC,IAAkB;IAChD,IAAI,GAAG,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IAC7D,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/plugin-rss",
|
|
3
|
-
"version": "3.0.
|
|
4
|
-
"description": "RSS/Atom feed subscription
|
|
3
|
+
"version": "3.0.4",
|
|
4
|
+
"description": "RSS/Atom feed subscription plugin for Zhin.js (Plugin Runtime)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
"./package.json": "./package.json"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
+
"plugin.ts",
|
|
18
|
+
"schema.json",
|
|
19
|
+
"commands",
|
|
17
20
|
"src",
|
|
18
21
|
"lib",
|
|
19
22
|
"agent",
|
|
20
|
-
"client",
|
|
21
|
-
"dist",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
24
25
|
"keywords": [
|
|
@@ -30,10 +31,7 @@
|
|
|
30
31
|
"atom",
|
|
31
32
|
"feed",
|
|
32
33
|
"subscription",
|
|
33
|
-
"push"
|
|
34
|
-
"notify",
|
|
35
|
-
"chatbot",
|
|
36
|
-
"im"
|
|
34
|
+
"push"
|
|
37
35
|
],
|
|
38
36
|
"author": {
|
|
39
37
|
"name": "lc-cn",
|
|
@@ -42,18 +40,27 @@
|
|
|
42
40
|
},
|
|
43
41
|
"license": "MIT",
|
|
44
42
|
"dependencies": {
|
|
45
|
-
"rss-parser": "^3.13.0"
|
|
43
|
+
"rss-parser": "^3.13.0",
|
|
44
|
+
"@zhin.js/command": "1.0.2",
|
|
45
|
+
"@zhin.js/plugin-runtime": "1.1.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"typescript": "^6.0.3",
|
|
49
|
+
"vitest": "^4.1.10",
|
|
49
50
|
"zod": "^4.4.3",
|
|
50
|
-
"zhin.js": "
|
|
51
|
-
"@zhin.js/agent": "1.0.3"
|
|
51
|
+
"@zhin.js/agent": "1.0.5"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"zod": "^4.0.0",
|
|
55
|
-
"zhin.js": "
|
|
56
|
-
|
|
55
|
+
"@zhin.js/agent": "1.0.5"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@zhin.js/agent": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"zod": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
57
64
|
},
|
|
58
65
|
"repository": {
|
|
59
66
|
"type": "git",
|
|
@@ -64,17 +71,26 @@
|
|
|
64
71
|
"access": "public",
|
|
65
72
|
"registry": "https://registry.npmjs.org"
|
|
66
73
|
},
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
76
|
+
},
|
|
77
|
+
"zhin": {
|
|
78
|
+
"protocol": 1,
|
|
79
|
+
"type": "plugin",
|
|
80
|
+
"entry": "./plugin.ts",
|
|
81
|
+
"engine": "^1.0.0",
|
|
82
|
+
"runtime": "trusted",
|
|
83
|
+
"features": [
|
|
84
|
+
{
|
|
85
|
+
"package": "@zhin.js/command",
|
|
86
|
+
"api": "^1.0.0"
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"plugins": []
|
|
74
90
|
},
|
|
75
91
|
"scripts": {
|
|
76
92
|
"build": "tsc",
|
|
77
93
|
"clean": "rimraf lib",
|
|
78
|
-
"test": "
|
|
94
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/utils/rss/tests"
|
|
79
95
|
}
|
|
80
96
|
}
|
package/plugin.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
databaseHostToken,
|
|
4
|
+
outboundHostToken,
|
|
5
|
+
scheduleHostToken,
|
|
6
|
+
} from '@zhin.js/plugin-runtime';
|
|
7
|
+
import { fetchFeed, resolveRssConfig, type RssConfig } from './src/feed.js';
|
|
8
|
+
import {
|
|
9
|
+
ensureRssMemoryDb,
|
|
10
|
+
getRssSubs,
|
|
11
|
+
setRssDb,
|
|
12
|
+
RSS_SEEN_TABLE,
|
|
13
|
+
RSS_SUBS_TABLE,
|
|
14
|
+
} from './src/db-store.js';
|
|
15
|
+
import { setRssAgentDeps } from './src/rss-agent-deps.js';
|
|
16
|
+
import { cleanOldSeen, pollAllFeeds, setRssOutboundPush } from './src/poll.js';
|
|
17
|
+
|
|
18
|
+
function defineRssTables(db: { define: (name: string, schema: Record<string, unknown>) => void }): void {
|
|
19
|
+
db.define(RSS_SUBS_TABLE, {
|
|
20
|
+
url: { type: 'text', nullable: false },
|
|
21
|
+
feed_title: { type: 'text', default: '' },
|
|
22
|
+
adapter_name: { type: 'text', nullable: false },
|
|
23
|
+
endpoint_id: { type: 'text', default: '' },
|
|
24
|
+
channel_type: { type: 'text', default: 'private' },
|
|
25
|
+
channel_id: { type: 'text', nullable: false },
|
|
26
|
+
creator_id: { type: 'text', default: '' },
|
|
27
|
+
creator_name: { type: 'text', default: '' },
|
|
28
|
+
created_at: { type: 'text', default: '' },
|
|
29
|
+
});
|
|
30
|
+
db.define(RSS_SEEN_TABLE, {
|
|
31
|
+
feed_url: { type: 'text', nullable: false },
|
|
32
|
+
item_guid: { type: 'text', nullable: false },
|
|
33
|
+
item_title: { type: 'text', default: '' },
|
|
34
|
+
seen_at: { type: 'text', default: '' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Plugin Runtime:
|
|
40
|
+
* - Commands: add/list/remove/check.
|
|
41
|
+
* - DB: prefer `databaseHostToken`; else in-memory.
|
|
42
|
+
* - Outbound: `outboundHostToken` pushes new items to subscriber channels.
|
|
43
|
+
* - Cron: `scheduleHostToken` poll job when available.
|
|
44
|
+
*/
|
|
45
|
+
export default definePlugin<RssConfig>({
|
|
46
|
+
name: 'rss',
|
|
47
|
+
metadata: {
|
|
48
|
+
displayName: 'RSS',
|
|
49
|
+
},
|
|
50
|
+
setup(context) {
|
|
51
|
+
const config = resolveRssConfig(context.config.get());
|
|
52
|
+
if (context.resources.has(databaseHostToken)) {
|
|
53
|
+
const host = context.resources.use(databaseHostToken);
|
|
54
|
+
defineRssTables(host);
|
|
55
|
+
setRssDb(host);
|
|
56
|
+
} else {
|
|
57
|
+
ensureRssMemoryDb();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (context.resources.has(outboundHostToken)) {
|
|
61
|
+
const outbound = context.resources.use(outboundHostToken);
|
|
62
|
+
setRssOutboundPush(async (input) => {
|
|
63
|
+
await outbound.send({
|
|
64
|
+
adapter: input.adapterName,
|
|
65
|
+
endpointId: input.endpointId,
|
|
66
|
+
channelType: input.channelType,
|
|
67
|
+
channelId: input.channelId,
|
|
68
|
+
content: input.content,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
context.lifecycle.add(() => setRssOutboundPush(null));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setRssAgentDeps({
|
|
75
|
+
getSubs: () => getRssSubs() as { select: () => Promise<unknown[]> } | null,
|
|
76
|
+
fetchFeed: (url) => fetchFeed(url, config.timeout).then((f) => ({
|
|
77
|
+
title: f.title,
|
|
78
|
+
items: f.items.map((i) => ({ title: i.title, link: i.link })),
|
|
79
|
+
})),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!context.resources.has(scheduleHostToken)) return;
|
|
83
|
+
const schedule = context.resources.use(scheduleHostToken);
|
|
84
|
+
const dispose = schedule.register({
|
|
85
|
+
id: 'rss/poll_feeds',
|
|
86
|
+
cron: config.pollCron,
|
|
87
|
+
description: 'Poll RSS subscriptions',
|
|
88
|
+
async execute() {
|
|
89
|
+
await pollAllFeeds(config);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const disposeClean = schedule.register({
|
|
93
|
+
id: 'rss/clean_old_seen',
|
|
94
|
+
cron: '0 0 4 * * *',
|
|
95
|
+
description: 'Clean seen records older than 7 days',
|
|
96
|
+
async execute() {
|
|
97
|
+
await cleanOldSeen();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
context.lifecycle.add(dispose);
|
|
101
|
+
context.lifecycle.add(disposeClean);
|
|
102
|
+
},
|
|
103
|
+
});
|
package/schema.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"properties": {
|
|
6
|
+
"pollCron": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"default": "0 */5 * * * *",
|
|
9
|
+
"description": "轮询频率 (6 段 Cron 表达式)"
|
|
10
|
+
},
|
|
11
|
+
"maxPerGroup": {
|
|
12
|
+
"type": "number",
|
|
13
|
+
"default": 30,
|
|
14
|
+
"minimum": 1,
|
|
15
|
+
"maximum": 200
|
|
16
|
+
},
|
|
17
|
+
"maxItems": {
|
|
18
|
+
"type": "number",
|
|
19
|
+
"default": 5,
|
|
20
|
+
"minimum": 1,
|
|
21
|
+
"maximum": 20
|
|
22
|
+
},
|
|
23
|
+
"timeout": {
|
|
24
|
+
"type": "number",
|
|
25
|
+
"default": 15000,
|
|
26
|
+
"minimum": 1000,
|
|
27
|
+
"maximum": 60000
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract subscription channel keys from Runtime Message (`input`).
|
|
3
|
+
* Gaps vs legacy: no `$channel` / `$group` — use `adapter` / `target` / `metadata`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ChannelInfo {
|
|
7
|
+
adapterName: string;
|
|
8
|
+
endpointId: string;
|
|
9
|
+
channelType: string;
|
|
10
|
+
channelId: string;
|
|
11
|
+
senderId: string;
|
|
12
|
+
senderName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Smoke / agent / execute()-without-dispatch fallback. */
|
|
16
|
+
export const SMOKE_CHANNEL: ChannelInfo = {
|
|
17
|
+
adapterName: 'smoke',
|
|
18
|
+
endpointId: 'local',
|
|
19
|
+
channelType: 'private',
|
|
20
|
+
channelId: 'global',
|
|
21
|
+
senderId: '',
|
|
22
|
+
senderName: '',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function extractChannelInfo(input: unknown): ChannelInfo {
|
|
26
|
+
if (!input || typeof input !== 'object') return { ...SMOKE_CHANNEL };
|
|
27
|
+
|
|
28
|
+
const msg = input as {
|
|
29
|
+
adapter?: string;
|
|
30
|
+
target?: string;
|
|
31
|
+
sender?: string;
|
|
32
|
+
metadata?: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
const meta = msg.metadata ?? {};
|
|
35
|
+
const channelType = String(
|
|
36
|
+
meta.channelType ?? meta.type ?? meta.channel_type ?? 'private',
|
|
37
|
+
);
|
|
38
|
+
const channelId = String(
|
|
39
|
+
meta.channelId ?? meta.channel_id ?? msg.target ?? SMOKE_CHANNEL.channelId,
|
|
40
|
+
);
|
|
41
|
+
const adapterName = String(msg.adapter ?? meta.adapter ?? SMOKE_CHANNEL.adapterName);
|
|
42
|
+
const endpointId = String(
|
|
43
|
+
meta.endpoint ?? meta.endpointId ?? meta.endpoint_id ?? adapterName,
|
|
44
|
+
);
|
|
45
|
+
const senderId = String(msg.sender ?? meta.senderId ?? '');
|
|
46
|
+
const senderName = String(meta.senderName ?? meta.name ?? '');
|
|
47
|
+
|
|
48
|
+
if (!channelId) return { ...SMOKE_CHANNEL, adapterName, endpointId, senderId, senderName };
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
adapterName,
|
|
52
|
+
endpointId,
|
|
53
|
+
channelType,
|
|
54
|
+
channelId,
|
|
55
|
+
senderId,
|
|
56
|
+
senderName,
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/db-store.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createInMemoryRssDb,
|
|
3
|
+
RSS_SEEN_TABLE,
|
|
4
|
+
RSS_SUBS_TABLE,
|
|
5
|
+
type RssMemoryDb,
|
|
6
|
+
type RssModel,
|
|
7
|
+
} from './memory-store.js';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createInMemoryRssDb,
|
|
11
|
+
RSS_SEEN_TABLE,
|
|
12
|
+
RSS_SUBS_TABLE,
|
|
13
|
+
type RssMemoryDb,
|
|
14
|
+
type RssModel,
|
|
15
|
+
} from './memory-store.js';
|
|
16
|
+
|
|
17
|
+
let _db: RssMemoryDb | null = null;
|
|
18
|
+
|
|
19
|
+
export function ensureRssMemoryDb(): RssMemoryDb {
|
|
20
|
+
if (!_db) _db = createInMemoryRssDb();
|
|
21
|
+
return _db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getRssDb(): RssMemoryDb | null {
|
|
25
|
+
return ensureRssMemoryDb();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setRssDb(db: RssMemoryDb | null): void {
|
|
29
|
+
_db = db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resetRssDb(): void {
|
|
33
|
+
_db = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getRssSubs(): RssModel | null {
|
|
37
|
+
return ensureRssMemoryDb().models.get(RSS_SUBS_TABLE) ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getRssSeen(): RssModel | null {
|
|
41
|
+
return ensureRssMemoryDb().models.get(RSS_SEEN_TABLE) ?? null;
|
|
42
|
+
}
|
package/src/feed.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS feed fetch/preview helpers (no Plugin host).
|
|
3
|
+
*/
|
|
4
|
+
import Parser from 'rss-parser';
|
|
5
|
+
|
|
6
|
+
export interface FeedItem {
|
|
7
|
+
title: string;
|
|
8
|
+
link: string;
|
|
9
|
+
date: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
guid: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RssConfig {
|
|
15
|
+
readonly pollCron: string;
|
|
16
|
+
readonly maxPerGroup: number;
|
|
17
|
+
readonly maxItems: number;
|
|
18
|
+
readonly timeout: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_RSS_CONFIG: RssConfig = {
|
|
22
|
+
pollCron: '0 */5 * * * *',
|
|
23
|
+
maxPerGroup: 30,
|
|
24
|
+
maxItems: 5,
|
|
25
|
+
timeout: 15_000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function resolveRssConfig(raw: Partial<RssConfig> | undefined): RssConfig {
|
|
29
|
+
return { ...DEFAULT_RSS_CONFIG, ...raw };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 剔除 HTML 标签与 ` ` 实体并折叠空白。
|
|
34
|
+
* 线性单遍扫描实现(手写状态机),语义等价于原先的
|
|
35
|
+
* `/<[^>]*>/g` → `/ /g` → `/\s+/g` 三步 replace,但避免:
|
|
36
|
+
* - js/polynomial-redos:大量 `<` 无闭合时正则逐位重扫的二次方回溯;
|
|
37
|
+
* - js/incomplete-multi-character-sanitization:多趟 replace 的残留问题。
|
|
38
|
+
*/
|
|
39
|
+
export function stripHtml(html: string): string {
|
|
40
|
+
let out = '';
|
|
41
|
+
let pendingSpace = false;
|
|
42
|
+
let mayHaveTagEnd = html.includes('>');
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < html.length) {
|
|
45
|
+
const ch = html[i]!;
|
|
46
|
+
if (ch === '<' && mayHaveTagEnd) {
|
|
47
|
+
const end = html.indexOf('>', i + 1);
|
|
48
|
+
if (end >= 0) {
|
|
49
|
+
i = end + 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// 后续不再有 `>`:剩余的 `<` 都是字面字符,不再重复扫描。
|
|
53
|
+
mayHaveTagEnd = false;
|
|
54
|
+
}
|
|
55
|
+
if (ch === '&' && html.startsWith(' ', i)) {
|
|
56
|
+
pendingSpace = true;
|
|
57
|
+
i += ' '.length;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (/\s/u.test(ch)) {
|
|
61
|
+
pendingSpace = true;
|
|
62
|
+
i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (pendingSpace && out.length > 0) out += ' ';
|
|
66
|
+
pendingSpace = false;
|
|
67
|
+
out += ch;
|
|
68
|
+
i += 1;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function fetchFeed(
|
|
74
|
+
url: string,
|
|
75
|
+
timeout = DEFAULT_RSS_CONFIG.timeout,
|
|
76
|
+
): Promise<{ title: string; items: FeedItem[] }> {
|
|
77
|
+
const parser = new Parser({
|
|
78
|
+
timeout,
|
|
79
|
+
headers: {
|
|
80
|
+
'User-Agent': 'ZhinBot-RSS/1.0',
|
|
81
|
+
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
const feed = await parser.parseURL(url);
|
|
85
|
+
const items: FeedItem[] = (feed.items || []).slice(0, 20).map((item) => ({
|
|
86
|
+
title: (item.title || '').trim(),
|
|
87
|
+
link: (item.link || '').trim(),
|
|
88
|
+
date: item.isoDate || item.pubDate || '',
|
|
89
|
+
summary: stripHtml(item.contentSnippet || item.content || '').slice(0, 200),
|
|
90
|
+
guid: item.guid || item.link || item.title || '',
|
|
91
|
+
}));
|
|
92
|
+
return { title: (feed.title || url).trim(), items };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatFeedPreview(title: string, items: FeedItem[]): string {
|
|
96
|
+
if (items.length === 0) return `${title}: 暂无内容`;
|
|
97
|
+
const lines = [`${title} (共 ${items.length} 条)`];
|
|
98
|
+
for (const item of items.slice(0, 5)) {
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push(`${item.title}`);
|
|
101
|
+
if (item.date) lines.push(` ${new Date(item.date).toLocaleString('zh-CN')}`);
|
|
102
|
+
if (item.link) lines.push(` ${item.link}`);
|
|
103
|
+
}
|
|
104
|
+
if (items.length > 5) lines.push(`\n...还有 ${items.length - 5} 条`);
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|