@zhin.js/plugin-rss 1.1.6 → 1.1.7

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/plugin.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { definePlugin, databaseHostToken, outboundHostToken, scheduleHostToken, } from 'zhin.js';
3
+ import { pluginTaskProviderToken } from 'zhin.js/plugin-runtime';
4
+ import { AdministratorRssTasks } from "./lib/admin-tasks.js";
3
5
  import { resolveRssConfig } from "./lib/feed.js";
4
6
  import { createInMemoryRssDb, RSS_SEEN_TABLE, RSS_SUBS_TABLE, } from "./lib/db-store.js";
5
7
  import { cleanOldSeen, pollAllFeeds } from "./lib/poll.js";
@@ -37,14 +39,15 @@ export default definePlugin({
37
39
  },
38
40
  setup(context) {
39
41
  const config = resolveRssConfig(context.config.get());
40
- const db = context.resources.has(databaseHostToken) ? (() => {
41
- const host = context.resources.use(databaseHostToken);
42
+ const database = context.resources.has(databaseHostToken) ? context.resources.use(databaseHostToken) : undefined;
43
+ const db = database ? (() => {
44
+ const host = database;
42
45
  defineRssTables(host);
43
46
  return host;
44
47
  })() : createInMemoryRssDb();
48
+ const outbound = context.resources.has(outboundHostToken) ? context.resources.use(outboundHostToken) : undefined;
45
49
  let outboundPush = null;
46
- if (context.resources.has(outboundHostToken)) {
47
- const outbound = context.resources.use(outboundHostToken);
50
+ if (outbound) {
48
51
  outboundPush = async (input) => {
49
52
  await outbound.send({
50
53
  adapter: input.adapterName,
@@ -63,6 +66,21 @@ export default definePlugin({
63
66
  outbound: outboundPush,
64
67
  });
65
68
  context.resources.provide(rssRuntimeToken, runtime);
69
+ // Keep optional in-memory/chat-only installations working. Administrator tasks
70
+ // must have the real durable state port, exact outbound and an active scheduler.
71
+ let administrator;
72
+ if (database && outbound?.sendTarget && context.resources.has(scheduleHostToken)) {
73
+ let state;
74
+ try {
75
+ state = database.state;
76
+ }
77
+ catch { /* No persistent state port: no administrator provider. */ }
78
+ if (state) {
79
+ administrator = new AdministratorRssTasks(state, outbound, config);
80
+ context.resources.provide(pluginTaskProviderToken, administrator);
81
+ context.lifecycle.add(() => administrator?.stop());
82
+ }
83
+ }
66
84
  if (!context.resources.has(scheduleHostToken))
67
85
  return;
68
86
  const schedule = context.resources.use(scheduleHostToken);
@@ -71,7 +89,13 @@ export default definePlugin({
71
89
  cron: config.pollCron,
72
90
  description: 'Poll RSS subscriptions',
73
91
  async execute() {
74
- await pollAllFeeds(runtime);
92
+ // Both pools share this existing registration. A slow chat feed does not
93
+ // prevent administrator polling, and neither pool shares the other's cursor.
94
+ const results = await Promise.allSettled([pollAllFeeds(runtime), administrator?.tick()]);
95
+ // Keep the schedule lease until both pools drain, even if one fails.
96
+ for (const result of results)
97
+ if (result.status === 'rejected')
98
+ throw result.reason;
75
99
  },
76
100
  });
77
101
  const disposeClean = schedule.register({
@@ -0,0 +1,369 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
3
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
4
+ import { createGenerationAdmissionGate, generationAdmissionSource, PluginTaskError,
5
+ type JsonValue, type PluginTask, type PluginTaskAction, type PluginTaskMutation,
6
+ type PluginTaskProvider, type PluginTaskSnapshot } from 'zhin.js/plugin-runtime';
7
+ import { fetchFeed, stripHtml, type FeedItem, type RssConfig } from './feed.js';
8
+ import { formatNewItems } from './poll.js';
9
+ import { isRssQuietMinute, parseQuietHours, type RssQuietHours } from './quiet-hours.js';
10
+
11
+ export const ADMIN_RSS_STATE_KEY = 'admin-rss/v1';
12
+ const MAX_TASKS = 8;
13
+ const MAX_RECEIPTS = 32;
14
+ const MAX_SEEN = 64;
15
+ const MAX_PENDING_ITEMS = 3;
16
+ const MAX_PENDING_COUNT = 10_000;
17
+ const UUID = /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/iu;
18
+ const DIGEST = /^[\da-f]{64}$/u;
19
+ type Task = {
20
+ readonly id: string; readonly creation: string; readonly version: string; readonly url: string;
21
+ readonly target: DeliveryTarget; readonly createdAt: number;
22
+ readonly state: 'scheduled' | 'paused' | 'unknown' | 'failed' | 'cancelled';
23
+ readonly seen: readonly string[] | null; readonly claimId: string | null;
24
+ readonly lastCheckAt: number | null; readonly lastRunAt: number | null; readonly detail: string;
25
+ readonly quiet?: RssQuietHours;
26
+ readonly pending?: PendingSummary | null;
27
+ };
28
+ type SummaryItem = { readonly key: string; readonly title: string; readonly link: string };
29
+ type PendingSummary = { readonly from: number; readonly to: number; readonly count: number; readonly countCapped: boolean; readonly items: readonly SummaryItem[] };
30
+ type Receipt = { readonly id: string; readonly fingerprint: string; readonly taskId: string; readonly operation: 'create' | PluginTaskAction };
31
+ export type AdministratorRssBook = { readonly version: 1 | 2; readonly revision: string; readonly tasks: readonly Task[]; readonly receipts: readonly Receipt[] };
32
+ type FeedLoader = typeof fetchFeed;
33
+
34
+ /** Administrator subscriptions use their own owner-bound durable pool and per-task cursors. */
35
+ export class AdministratorRssTasks implements PluginTaskProvider {
36
+ readonly title = '管理员 RSS 订阅';
37
+ readonly description = '向精确账号、会话与线程推送新文章,独立于聊天创建的 RSS 订阅';
38
+ readonly schema = { type: 'object', additionalProperties: false, required: ['url'], properties: {
39
+ url: { type: 'string', minLength: 1, maxLength: 2048, title: 'RSS / Atom 地址', description: 'HTTP(S) 地址,不接受 URL 用户名或密码' },
40
+ quietTimezone: { type: 'string', maxLength: 128, title: '静默时区(可选)', description: '如 Asia/Shanghai;三项全部留空关闭静默,启用须全部填写' },
41
+ quietStart: { type: 'string', maxLength: 5, title: '静默开始(可选)', description: 'HH:mm,包含此分钟,如 22:00;须同时填写时区和结束时间' },
42
+ quietEnd: { type: 'string', maxLength: 5, title: '静默结束(可选)', description: 'HH:mm,不包含此分钟,如 08:00;开始和结束不能相同' },
43
+ } } as const;
44
+ readonly effects = [
45
+ '保存后按实例 pollCron 定时访问源站;新建及暂停后恢复的首次成功抓取只建立基线,不发送历史文章',
46
+ '后续新文章经统一发送链投递到所选完整目标;暂停或取消不能撤回已开始的投递',
47
+ '未确认的投递不会自动重试或恢复;需核实目标会话后取消并另建订阅',
48
+ '每实例最多 8 条任务,操作回执保留最近 32 条;订阅仅保留最近 64 个条目标识,长期移除后重新出现的文章可能再次视为新内容',
49
+ '仅显式填写三项静默配置的管理员订阅延后汇总;退出静默后的首轮轮询先发已积累摘要,该订阅下一轮再抓源;不改变定时提醒或聊天订阅',
50
+ '摘要最多保留 3 条短明细,展示仍受实例 maxItems 限制;计数最多 10000 条,超过时以“至少”说明;暂停、编辑和取消清空待发摘要,恢复重新建基线、不补发',
51
+ ];
52
+ readonly #state: PluginStateStore;
53
+ readonly #outbound: OutboundHost;
54
+ readonly #config: Readonly<RssConfig>;
55
+ readonly #load: FeedLoader;
56
+ readonly #admission = createGenerationAdmissionGate();
57
+ #stopped = false;
58
+ #working = false;
59
+ get [generationAdmissionSource]() { return [this.#admission]; }
60
+
61
+ constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed) {
62
+ if (!outbound.sendTarget) throw unavailable('管理员 RSS 需要支持精确目标的 Outbound Host');
63
+ this.#state = state; this.#outbound = outbound; this.#config = config; this.#load = load;
64
+ }
65
+ stop(): void { this.#stopped = true; }
66
+
67
+ async read(): Promise<PluginTaskSnapshot> {
68
+ return this.#admit(async () => this.#snapshot(readBook(await this.#state.get<AdministratorRssBook>(ADMIN_RSS_STATE_KEY))));
69
+ }
70
+ async create(input: PluginTaskMutation & Readonly<{ target: DeliveryTarget; parameters: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
71
+ return this.#admit(async () => {
72
+ assertMutation(input);
73
+ let target: DeliveryTarget;
74
+ try { target = createDeliveryTarget({ conversation: input.target }); } catch { throw invalid('请提供完整有效的目标账号、会话、父容器和线程'); }
75
+ const parameters = parseParameters(input.parameters), { url, quiet } = parameters;
76
+ const fingerprint = digest({ operation: 'create', target, url, ...(quiet ? { quiet } : {}) });
77
+ const task: Task = { id: input.requestId, creation: fingerprint, version: randomUUID(), url, target, createdAt: Date.now(), state: 'scheduled',
78
+ seen: null, claimId: null, lastCheckAt: null, lastRunAt: null, detail: '等待首次抓取建立基线,不发送历史文章', ...(quiet ? { quiet, pending: null } : {}) };
79
+ const revision = randomUUID();
80
+ const book = await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
81
+ this.#assertLive(); const book = readBook(current);
82
+ if (reconcile(book, input.requestId, fingerprint)) return book;
83
+ assertRevision(book, input.expectedRevision);
84
+ const existing = book.tasks.find(item => item.id === task.id);
85
+ if (existing) { if (existing.creation !== fingerprint) throw conflict('同一请求 ID 已用于其他内容'); return book; }
86
+ const active = book.tasks.filter(item => item.state !== 'cancelled' && item.state !== 'failed');
87
+ if (active.length >= MAX_TASKS) throw invalid('管理员 RSS 订阅已达 8 条上限,请先取消任务');
88
+ if (active.some(item => item.url === url && JSON.stringify(item.target) === JSON.stringify(target))) throw invalid('此完整目标已有该源的管理员订阅,请先核实原任务');
89
+ const historyLimit = MAX_TASKS - active.length - 1;
90
+ const history = historyLimit ? book.tasks.filter(item => item.state === 'cancelled' || item.state === 'failed').slice(-historyLimit) : [];
91
+ return bounded({ version: 2, revision, tasks: [...active, ...history, task], receipts: receipt(book, input.requestId, fingerprint, task.id, 'create') });
92
+ });
93
+ return this.#snapshot(readBook(book));
94
+ });
95
+ }
96
+ async change(input: PluginTaskMutation & Readonly<{ id: string; action: PluginTaskAction; parameters?: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
97
+ return this.#admit(async () => {
98
+ assertMutation(input);
99
+ if (!isUuid(input.id) || !['pause', 'resume', 'cancel', 'update'].includes(input.action)) throw invalid('任务 ID 或操作无效');
100
+ if (input.action !== 'update' && input.parameters !== undefined) throw invalid('仅编辑操作接受参数');
101
+ const parameters = input.action === 'update' ? parseParameters(input.parameters) : undefined;
102
+ const url = parameters?.url;
103
+ const fingerprint = digest({ operation: input.action, id: input.id, url: url ?? null, ...(parameters?.quiet ? { quiet: parameters.quiet } : {}) }), revision = randomUUID(), version = randomUUID();
104
+ const result = await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
105
+ this.#assertLive(); const book = readBook(current);
106
+ if (reconcile(book, input.requestId, fingerprint)) return book;
107
+ assertRevision(book, input.expectedRevision);
108
+ const task = book.tasks.find(item => item.id === input.id);
109
+ if (!task) throw new PluginTaskError('not_found', '未找到当前实例的管理员 RSS 订阅;聊天订阅不在此入口管理');
110
+ let changed: Task;
111
+ switch (input.action) {
112
+ case 'pause':
113
+ if (task.state !== 'scheduled') throw invalid('仅待轮询订阅可以暂停');
114
+ changed = { ...task, version, state: 'paused', pending: null, detail: '管理员暂停并清空待发摘要;恢复时重新建立基线,不补发历史文章' }; break;
115
+ case 'resume':
116
+ if (task.state !== 'paused') throw invalid('仅暂停订阅可以恢复;未确认投递不能恢复或重试');
117
+ changed = { ...task, version, state: 'scheduled', seen: null, pending: null, detail: '已恢复,等待重新建立基线,不补发历史文章' }; break;
118
+ case 'update':
119
+ if (task.state !== 'paused') throw invalid('编辑前请先暂停订阅;未确认投递不能编辑');
120
+ if (book.tasks.some(item => item.id !== task.id && item.state !== 'cancelled' && item.state !== 'failed' && item.url === url
121
+ && JSON.stringify(item.target) === JSON.stringify(task.target))) throw invalid('此完整目标已有该源的管理员订阅');
122
+ changed = { ...withoutQuiet(task), version, url: url!, ...(parameters?.quiet ? { quiet: parameters.quiet } : {}), pending: null, seen: null, detail: '已编辑并清空待发摘要,保持暂停,恢复后首次抓取只建立基线' }; break;
123
+ case 'cancel':
124
+ if (task.state === 'cancelled' || task.state === 'failed') throw invalid('任务已结束,不能再次取消');
125
+ changed = { ...task, version, state: 'cancelled', pending: null, detail: '管理员取消并清空待发摘要;已经开始的投递无法撤回' }; break;
126
+ }
127
+ return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id ? changed : item), receipts: receipt(book, input.requestId, fingerprint, task.id, input.action) });
128
+ });
129
+ return this.#snapshot(readBook(result));
130
+ });
131
+ }
132
+
133
+ /** Called by the plugin's one existing RSS poll registration, never by chat rss-check. */
134
+ async tick(): Promise<void> {
135
+ if (this.#stopped || this.#working) return;
136
+ this.#working = true;
137
+ try {
138
+ await this.#admit(async () => {
139
+ const queued = readBook(await this.#state.get<AdministratorRssBook>(ADMIN_RSS_STATE_KEY)).tasks;
140
+ const flushed = new Set<string>();
141
+ // Drain durable summaries before touching any source. A broken or slow feed
142
+ // cannot block an already-observed summary, including other subscriptions.
143
+ for (const task of queued) {
144
+ if (this.#stopped) break;
145
+ if (task.state !== 'scheduled' || !task.quiet || !task.pending || isRssQuietMinute(task.quiet, Date.now())) continue;
146
+ const claimed = await this.#claimPending(task);
147
+ if (claimed) { flushed.add(task.id); await this.#deliver(claimed, formatSummary(claimed.url, claimed.pending!, this.#config.maxItems)); }
148
+ }
149
+ const tasks = readBook(await this.#state.get<AdministratorRssBook>(ADMIN_RSS_STATE_KEY)).tasks;
150
+ for (const task of tasks) {
151
+ if (this.#stopped) break;
152
+ if (task.state !== 'scheduled' || flushed.has(task.id)) continue;
153
+ let feed: Awaited<ReturnType<FeedLoader>>;
154
+ try { feed = await this.#load(task.url, this.#config.timeout); }
155
+ catch { await this.#recordCheck(task, '源站抓取失败;尚未尝试投递,下轮重查'); continue; }
156
+ if (this.#stopped) break;
157
+ // Keep only bounded identifiers, never arbitrary feed bodies, in durable state.
158
+ const unique = new Map<string, FeedItem>();
159
+ for (const item of feed.items.slice(0, 20)) if (item.guid) unique.set(digest(item.guid), item);
160
+ const newItems = task.seen === null ? [] : [...unique].filter(([key]) => !task.seen!.includes(key)).map(([, item]) => item);
161
+ const seen = [...new Set([...unique.keys(), ...(task.seen ?? [])])].slice(0, MAX_SEEN);
162
+ const claimId = randomUUID(), version = randomUUID(), revision = randomUUID(), now = Date.now();
163
+ const quiet = task.quiet !== undefined && isRssQuietMinute(task.quiet, now);
164
+ const pending = task.quiet && (quiet || task.pending) ? appendSummary(task.pending, newItems, now) : null;
165
+ const shouldSend = !quiet && (newItems.length > 0 || pending !== null);
166
+ const result = await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
167
+ const book = readBook(current);
168
+ const live = book.tasks.find(item => item.id === task.id);
169
+ if (this.#stopped || live?.version !== task.version || live.state !== 'scheduled') return current;
170
+ const changed: Task = { ...live, seen, version, lastCheckAt: now, pending, ...(shouldSend
171
+ ? { state: 'unknown', claimId, lastRunAt: now, detail: '投递中或未确认;不会自动重试' }
172
+ : { detail: pending ? `静默中,已积累${pending.countCapped ? '至少' : ''}${pending.count} 条更新;退出静默后的首轮轮询汇总`
173
+ : task.seen === null ? '已建立基线;从后续新文章开始推送' : '抓取完成,暂无新文章' }) };
174
+ return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id ? changed : item) });
175
+ });
176
+ const claimed = readBook(result).tasks.find(item => item.id === task.id && item.claimId === claimId && item.state === 'unknown');
177
+ if (!claimed || this.#stopped) continue;
178
+ await this.#deliver(claimed, claimed.pending ? formatSummary(claimed.url, claimed.pending, this.#config.maxItems)
179
+ : formatNewItems(feed.title || claimed.url, newItems, this.#config.maxItems));
180
+ }
181
+ });
182
+ } finally { this.#working = false; }
183
+ }
184
+ async #claimPending(task: Task): Promise<Task | undefined> {
185
+ const claimId = randomUUID(), version = randomUUID(), revision = randomUUID(), now = Date.now();
186
+ const result = await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
187
+ const book = readBook(current), live = book.tasks.find(item => item.id === task.id);
188
+ if (this.#stopped || !live || live.version !== task.version || live.state !== 'scheduled' || !live.pending || !live.quiet || isRssQuietMinute(live.quiet, now)) return current;
189
+ return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id
190
+ ? { ...item, version, claimId, lastRunAt: now, state: 'unknown', detail: '摘要投递中或未确认;不会自动重试' } : item) });
191
+ });
192
+ return readBook(result).tasks.find(item => item.id === task.id && item.claimId === claimId && item.state === 'unknown');
193
+ }
194
+ async #deliver(claimed: Task, content: string): Promise<void> {
195
+ if (this.#stopped) return;
196
+ let state: Task['state'] = 'unknown', detail = '平台未确认投递,请核实目标会话后取消并另建订阅;不会自动重试';
197
+ try {
198
+ const id = await this.#outbound.sendTarget!(claimed.target, content);
199
+ if (id) { state = 'scheduled'; detail = '已确认投递;等待后续新文章'; }
200
+ } catch (error) {
201
+ if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') { state = 'failed'; detail = '平台明确未投递或拒绝;订阅已停止'; }
202
+ }
203
+ const revision = randomUUID(), version = randomUUID();
204
+ await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
205
+ const book = readBook(current), live = book.tasks.find(item => item.id === claimed.id);
206
+ if (live?.claimId !== claimed.claimId || live.state !== 'unknown') return current;
207
+ return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === claimed.id
208
+ ? { ...item, state, detail, version, ...(state === 'scheduled' ? { pending: null } : {}) } : item) });
209
+ });
210
+ }
211
+ async #recordCheck(task: Task, detail: string): Promise<void> {
212
+ if (this.#stopped) return;
213
+ const revision = randomUUID(), version = randomUUID(), now = Date.now();
214
+ await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
215
+ const book = readBook(current);
216
+ if (this.#stopped || !book.tasks.some(item => item.id === task.id && item.version === task.version && item.state === 'scheduled')) return current;
217
+ return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id ? { ...item, version, detail, lastCheckAt: now } : item) });
218
+ });
219
+ }
220
+ #assertLive(): void { if (this.#stopped) throw unavailable('管理员 RSS 服务属于已停止的插件实例'); }
221
+ async #admit<T>(operation: () => Promise<T>): Promise<T> {
222
+ this.#assertLive(); const release = this.#admission.acquire();
223
+ if (!release) throw unavailable('管理员 RSS 服务属于未发布或已退役的 generation');
224
+ try { return await operation(); } finally { release(); }
225
+ }
226
+ #snapshot(book: AdministratorRssBook): PluginTaskSnapshot {
227
+ return { revision: book.revision, receipts: book.receipts.map(item => ({ requestId: item.id, taskId: item.taskId, operation: item.operation })),
228
+ tasks: book.tasks.map(task => ({ id: task.id, ownership: 'administrator', title: `RSS · ${task.url}`.slice(0, 200), target: task.target,
229
+ state: task.state, createdAt: task.createdAt, ...(task.lastRunAt === null ? {} : { lastRunAt: task.lastRunAt }), parameters: { url: task.url,
230
+ ...(task.quiet ? { quietTimezone: task.quiet.timezone, quietStart: task.quiet.start, quietEnd: task.quiet.end } : {}) },
231
+ detail: `${task.detail}${task.quiet ? `;静默 ${task.quiet.timezone} ${task.quiet.start}–${task.quiet.end}` : ''}${task.pending ? `;${task.state === 'scheduled' ? '待汇总' : '本次摘要'}${task.pending.countCapped ? '至少' : ''}${task.pending.count} 条更新` : ''};实例轮询 ${this.#config.pollCron}${task.lastCheckAt === null ? '' : `;最近抓取 ${new Date(task.lastCheckAt).toISOString()}`};状态不代表平台连接健康`,
232
+ actions: task.state === 'scheduled' ? ['pause', 'cancel'] : task.state === 'paused' ? ['update', 'resume', 'cancel'] : task.state === 'unknown' ? ['cancel'] : [],
233
+ })) };
234
+ }
235
+ }
236
+
237
+ function parseParameters(value: Readonly<Record<string, JsonValue>> | undefined): { url: string; quiet?: RssQuietHours } {
238
+ if (!value || Array.isArray(value) || Object.keys(value).some(key => !['url', 'quietTimezone', 'quietStart', 'quietEnd'].includes(key))
239
+ || typeof value.url !== 'string' || value.url.length > 2048) throw invalid('参数须包含有效 HTTP(S) RSS 地址及可选的三项静默设置');
240
+ let quiet: RssQuietHours | undefined;
241
+ try { quiet = parseQuietHours(value); }
242
+ catch (error) { throw invalid(error instanceof TypeError ? error.message : '静默设置无效'); }
243
+ try {
244
+ const url = new URL(value.url);
245
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || /\s/u.test(value.url)) throw new Error('url');
246
+ url.hash = '';
247
+ if (url.href.length > 2048) throw new Error('url length');
248
+ return { url: url.href, ...(quiet ? { quiet } : {}) };
249
+ } catch { throw invalid('请提供有效 HTTP(S) RSS 地址,不接受 URL 用户名或密码'); }
250
+ }
251
+ function assertMutation(input: PluginTaskMutation): void {
252
+ if (!isUuid(input.requestId) || typeof input.expectedRevision !== 'string'
253
+ || !input.expectedRevision || input.expectedRevision.length > 128) throw invalid('操作须包含 UUID requestId 和读取到的 expectedRevision');
254
+ }
255
+ function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
256
+ function isUuid(value: unknown): value is string { return typeof value === 'string' && UUID.test(value); }
257
+ function isDigest(value: unknown): value is string { return typeof value === 'string' && DIGEST.test(value); }
258
+ function isTimestamp(value: unknown): value is number {
259
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && Number.isFinite(new Date(value).getTime());
260
+ }
261
+ function invalid(message: string): PluginTaskError { return new PluginTaskError('invalid_input', message); }
262
+ function conflict(message: string): PluginTaskError { return new PluginTaskError('conflict', message); }
263
+ function unavailable(message: string): PluginTaskError { return new PluginTaskError('unavailable', message); }
264
+ function assertRevision(book: AdministratorRssBook, revision: string): void { if (book.revision !== revision) throw conflict('订阅状态已变化,请刷新后重新确认'); }
265
+ function reconcile(book: AdministratorRssBook, id: string, fingerprint: string): boolean {
266
+ const previous = book.receipts.find(item => item.id === id);
267
+ if (!previous) return false;
268
+ if (previous.fingerprint !== fingerprint) throw conflict('同一操作请求 ID 已用于其他内容,禁止重放');
269
+ return true;
270
+ }
271
+ function receipt(book: AdministratorRssBook, id: string, fingerprint: string, taskId: string, operation: Receipt['operation']): readonly Receipt[] {
272
+ return [...book.receipts, { id, fingerprint, taskId, operation }].slice(-MAX_RECEIPTS);
273
+ }
274
+ function bounded(book: AdministratorRssBook): AdministratorRssBook {
275
+ if (book.tasks.length > MAX_TASKS || bytes(book) > 60 * 1024) throw invalid('管理员 RSS 状态达到 8 条或 60 KiB 上限,请先取消并整理订阅');
276
+ // Reserve future bounded cursors, summary samples and claim metadata at mutation
277
+ // time. Once accepted, ordinary quiet accumulation cannot exceed the state port.
278
+ const reserved = { ...book, revision: 'f'.repeat(36), tasks: book.tasks.map(task => !task.quiet || task.state === 'cancelled' || task.state === 'failed' ? task : {
279
+ ...task, seen: Array.from({ length: MAX_SEEN }, () => 'f'.repeat(64)), claimId: 'f'.repeat(36),
280
+ lastCheckAt: 8_640_000_000_000_000, lastRunAt: 8_640_000_000_000_000,
281
+ detail: bytes(task.detail) >= 514 ? task.detail : 'x'.repeat(512),
282
+ pending: { from: 8_640_000_000_000_000, to: 8_640_000_000_000_000, count: MAX_PENDING_COUNT, countCapped: false,
283
+ items: Array.from({ length: MAX_PENDING_ITEMS }, () => ({ key: 'f'.repeat(64), title: 'x'.repeat(128), link: 'x'.repeat(384) })) },
284
+ }) };
285
+ if (bytes(reserved) > 60 * 1024) throw invalid('管理员 RSS 状态空间不足以保留静默摘要及去重游标;请先取消并整理订阅,现有数据未改动');
286
+ return book;
287
+ }
288
+ function readBook(value: AdministratorRssBook | undefined): AdministratorRssBook {
289
+ if (value === undefined) return { version: 1, revision: '0', tasks: [], receipts: [] };
290
+ try {
291
+ if (!value || ![1, 2].includes(value.version) || (value.revision !== '0' && !isUuid(value.revision))
292
+ || !Array.isArray(value.tasks) || !Array.isArray(value.receipts) || value.receipts.length > MAX_RECEIPTS) throw new Error('book');
293
+ const ids = new Set<string>(), requests = new Set<string>();
294
+ for (const item of value.receipts) {
295
+ if (!item || !isUuid(item.id) || requests.has(item.id) || !isUuid(item.taskId) || !isDigest(item.fingerprint)
296
+ || !['create', 'pause', 'resume', 'cancel', 'update'].includes(item.operation)) throw new Error('receipt');
297
+ requests.add(item.id);
298
+ }
299
+ for (const task of value.tasks) {
300
+ if (!task || !isUuid(task.id) || ids.has(task.id) || !isUuid(task.version) || !isDigest(task.creation)
301
+ || !isTimestamp(task.createdAt) || !['scheduled', 'paused', 'unknown', 'failed', 'cancelled'].includes(task.state)
302
+ || (task.lastCheckAt !== null && !isTimestamp(task.lastCheckAt))
303
+ || (task.lastRunAt !== null && !isTimestamp(task.lastRunAt))
304
+ || (task.claimId !== null && !isUuid(task.claimId)) || (task.state === 'unknown' && (!task.claimId || task.seen === null || task.lastRunAt === null))
305
+ || (task.seen !== null && (!Array.isArray(task.seen) || task.seen.length > MAX_SEEN || !task.seen.every((key: unknown) => typeof key === 'string' && DIGEST.test(key))))
306
+ || typeof task.detail !== 'string' || task.detail.length > 1000 || parseParameters({ url: task.url }).url !== task.url) throw new Error('task');
307
+ if (task.quiet !== undefined) {
308
+ if (value.version !== 2 || !task.quiet || Object.keys(task.quiet).sort().join(',') !== 'end,start,timezone'
309
+ || JSON.stringify(parseQuietHours({ quietTimezone: task.quiet.timezone, quietStart: task.quiet.start, quietEnd: task.quiet.end })) !== JSON.stringify(task.quiet)) throw new Error('quiet');
310
+ }
311
+ if (task.pending != null) {
312
+ const pending = task.pending;
313
+ if (!task.quiet || task.seen === null || task.state === 'paused' || task.state === 'cancelled'
314
+ || Object.keys(pending).sort().join(',') !== 'count,countCapped,from,items,to'
315
+ || !isTimestamp(pending.from) || !isTimestamp(pending.to) || pending.to < pending.from
316
+ || !Number.isSafeInteger(pending.count) || pending.count < 1 || pending.count > MAX_PENDING_COUNT
317
+ || typeof pending.countCapped !== 'boolean' || (pending.countCapped && pending.count !== MAX_PENDING_COUNT)
318
+ || !Array.isArray(pending.items) || pending.items.length > MAX_PENDING_ITEMS || pending.items.length > pending.count
319
+ || new Set(pending.items.map((item: SummaryItem) => item.key)).size !== pending.items.length
320
+ || pending.items.some((item: SummaryItem) => !item || Object.keys(item).sort().join(',') !== 'key,link,title'
321
+ || !isDigest(item.key) || typeof item.title !== 'string' || bytes(item.title) > 128
322
+ || typeof item.link !== 'string' || bytes(item.link) > 384 || (item.link !== '' && shortLink(item.link) !== item.link))) throw new Error('pending');
323
+ }
324
+ const target = createDeliveryTarget({ conversation: task.target });
325
+ if (JSON.stringify(target) !== JSON.stringify(task.target)) throw new Error('target');
326
+ ids.add(task.id);
327
+ }
328
+ return bounded(value);
329
+ } catch { throw unavailable('管理员 RSS 持久状态格式无效,已停止管理员轮询与投递;请保留原数据核查'); }
330
+ }
331
+
332
+ function withoutQuiet(task: Task): Omit<Task, 'quiet'> { const { quiet: _quiet, ...rest } = task; return rest; }
333
+ function bytes(value: unknown): number { return new TextEncoder().encode(JSON.stringify(value)).byteLength; }
334
+ function appendSummary(previous: PendingSummary | null | undefined, items: readonly FeedItem[], now: number): PendingSummary | null {
335
+ if (!previous && items.length === 0) return null;
336
+ const samples = [...(previous?.items ?? [])];
337
+ for (const item of items) {
338
+ if (samples.length >= MAX_PENDING_ITEMS) break;
339
+ const key = digest(item.guid);
340
+ if (!samples.some(sample => sample.key === key)) samples.push({ key, title: shortTitle(item.title), link: shortLink(item.link) });
341
+ }
342
+ const count = (previous?.count ?? 0) + items.length;
343
+ return { from: Math.min(previous?.from ?? now, now), to: Math.max(previous?.to ?? now, now), count: Math.min(count, MAX_PENDING_COUNT),
344
+ countCapped: previous?.countCapped === true || count > MAX_PENDING_COUNT, items: samples };
345
+ }
346
+ function shortTitle(value: string): string {
347
+ let title = '';
348
+ for (const original of stripHtml(value.slice(0, 2048))) {
349
+ const code = original.codePointAt(0)!;
350
+ const character = code < 32 || code === 127 ? ' ' : original;
351
+ if (bytes(title + character) > 128) break;
352
+ title += character;
353
+ }
354
+ return title.trim();
355
+ }
356
+ function shortLink(value: string): string {
357
+ if (!value || value.length > 384) return '';
358
+ try {
359
+ const url = new URL(value);
360
+ return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && bytes(url.href) <= 384 ? url.href : '';
361
+ } catch { return ''; }
362
+ }
363
+ function formatSummary(url: string, pending: PendingSummary, maxItems: number): string {
364
+ const lines = [`RSS 静默摘要:累计检测到${pending.countCapped ? '至少' : ''} ${pending.count} 条更新`, `来源:${url}`];
365
+ const shown = pending.items.slice(0, Math.min(MAX_PENDING_ITEMS, maxItems));
366
+ for (const item of shown) lines.push('', item.title || '未命名文章', ...(item.link ? [item.link] : []));
367
+ if (pending.count > shown.length || pending.countCapped) lines.push('', `仅展示 ${shown.length} 条短明细,其余请查看源站;计数基于有界轮询窗口。`);
368
+ return lines.join('\n');
369
+ }
@@ -0,0 +1,30 @@
1
+ export type RssQuietHours = {
2
+ readonly timezone: string;
3
+ readonly start: string;
4
+ readonly end: string;
5
+ };
6
+
7
+ /** Flat provider parameters keep the generic task form editable, including clearing all three inputs. */
8
+ export function parseQuietHours(parameters: Readonly<Record<string, unknown>>): RssQuietHours | undefined {
9
+ const values = [parameters.quietTimezone, parameters.quietStart, parameters.quietEnd];
10
+ if (values.every(value => value === undefined || (typeof value === 'string' && !value.trim()))) return undefined;
11
+ if (!values.every(value => typeof value === 'string' && value.trim())) throw new TypeError('静默时区、开始和结束时间须全部填写;三项全部留空关闭静默');
12
+ const [timezone, start, end] = values.map(value => (value as string).trim());
13
+ if (timezone.length > 128 || !/^[A-Za-z][A-Za-z0-9_+/-]*$/u.test(timezone)) throw new TypeError('请填写有效 IANA 时区,如 Asia/Shanghai;不能使用 +08:00 等偏移');
14
+ try { new Intl.DateTimeFormat('en-GB', { timeZone: timezone }).format(0); }
15
+ catch { throw new TypeError('请填写有效 IANA 时区,如 Asia/Shanghai'); }
16
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/u.test(start) || !/^([01]\d|2[0-3]):[0-5]\d$/u.test(end) || start === end) {
17
+ throw new TypeError('静默时间须为 HH:mm(24 小时制),开始和结束不能相同');
18
+ }
19
+ return Object.freeze({ timezone, start, end });
20
+ }
21
+
22
+ /** Test the actual local minute. No inferred next wall-clock Date or DST disambiguation. */
23
+ export function isRssQuietMinute(policy: RssQuietHours, at: number): boolean {
24
+ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: policy.timezone, hourCycle: 'h23', hour: '2-digit', minute: '2-digit' }).formatToParts(at);
25
+ const minute = Number(parts.find(part => part.type === 'hour')!.value) * 60 + Number(parts.find(part => part.type === 'minute')!.value);
26
+ const start = minutes(policy.start), end = minutes(policy.end);
27
+ return start < end ? minute >= start && minute < end : minute >= start || minute < end;
28
+ }
29
+
30
+ function minutes(value: string): number { return Number(value.slice(0, 2)) * 60 + Number(value.slice(3)); }