@zhin.js/plugin-rss 1.1.9 → 1.1.10
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/README.md +8 -0
- package/commands/rss-add/[url]/index.js +12 -10
- package/commands/rss-add/[url]/index.ts +12 -10
- package/commands/rss-check/[[url]]/index.js +8 -7
- package/commands/rss-check/[[url]]/index.ts +8 -7
- package/commands/rss-list/index.js +9 -7
- package/commands/rss-list/index.ts +9 -7
- package/commands/rss-preview/[url]/index.js +7 -6
- package/commands/rss-preview/[url]/index.ts +7 -6
- package/commands/rss-remove/[url]/index.js +8 -7
- package/commands/rss-remove/[url]/index.ts +8 -7
- package/lib/admin-i18n.d.ts +219 -0
- package/lib/admin-i18n.d.ts.map +1 -0
- package/lib/admin-i18n.js +216 -0
- package/lib/admin-i18n.js.map +1 -0
- package/lib/admin-tasks.d.ts +40 -2
- package/lib/admin-tasks.d.ts.map +1 -1
- package/lib/admin-tasks.js +99 -72
- package/lib/admin-tasks.js.map +1 -1
- package/lib/channel.d.ts +2 -1
- package/lib/channel.d.ts.map +1 -1
- package/lib/channel.js +3 -3
- package/lib/channel.js.map +1 -1
- package/lib/feed.d.ts +2 -1
- package/lib/feed.d.ts.map +1 -1
- package/lib/feed.js +6 -5
- package/lib/feed.js.map +1 -1
- package/lib/i18n.d.ts +2 -0
- package/lib/i18n.d.ts.map +1 -0
- package/lib/i18n.js +168 -0
- package/lib/i18n.js.map +1 -0
- package/lib/poll.d.ts +3 -1
- package/lib/poll.d.ts.map +1 -1
- package/lib/poll.js +23 -18
- package/lib/poll.js.map +1 -1
- package/lib/quiet-hours.d.ts +5 -0
- package/lib/quiet-hours.d.ts.map +1 -1
- package/lib/quiet-hours.js +9 -4
- package/lib/quiet-hours.js.map +1 -1
- package/lib/runtime.d.ts +3 -0
- package/lib/runtime.d.ts.map +1 -1
- package/lib/runtime.js.map +1 -1
- package/lib/storage.d.ts.map +1 -1
- package/lib/storage.js +6 -5
- package/lib/storage.js.map +1 -1
- package/package.json +7 -7
- package/plugin.js +6 -2
- package/schema.json +6 -1
- package/skills/rss/tools/rss_list_subscriptions/index.js +4 -3
- package/skills/rss/tools/rss_list_subscriptions/index.ts +4 -3
- package/skills/rss/tools/rss_preview_feed/index.js +4 -3
- package/skills/rss/tools/rss_preview_feed/index.ts +4 -3
- package/src/admin-i18n.ts +218 -0
- package/src/admin-tasks.ts +95 -72
- package/src/channel.ts +4 -2
- package/src/feed.ts +7 -5
- package/src/i18n.ts +168 -0
- package/src/poll.ts +24 -18
- package/src/quiet-hours.ts +11 -4
- package/src/runtime.ts +3 -0
- package/src/storage.ts +6 -5
package/src/admin-tasks.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { adminText, taskDetail, formatTaskDetail, isAdminTextKey, LocalizedTaskError, type AdminTextKey } from './admin-i18n.js';
|
|
2
|
+
import { rssMessages as t } from './i18n.js';
|
|
3
|
+
import { resolveLocale, type Locale } from 'zhin.js/i18n';
|
|
1
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
5
|
import { createDeliveryTarget, type DeliveryTarget, type OutboundHost, type PluginStateStore } from 'zhin.js';
|
|
3
6
|
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
@@ -6,7 +9,7 @@ import { createGenerationAdmissionGate, generationAdmissionSource, PluginTaskErr
|
|
|
6
9
|
type PluginTaskProvider, type PluginTaskSnapshot } from 'zhin.js/plugin-runtime';
|
|
7
10
|
import { fetchFeed, stripHtml, type FeedItem, type RssConfig } from './feed.js';
|
|
8
11
|
import { formatNewItems } from './poll.js';
|
|
9
|
-
import { isRssQuietMinute, parseQuietHours, type RssQuietHours } from './quiet-hours.js';
|
|
12
|
+
import { isRssQuietMinute, parseQuietHours, LocalizedQuietHoursError, type RssQuietHours } from './quiet-hours.js';
|
|
10
13
|
|
|
11
14
|
export const ADMIN_RSS_STATE_KEY = 'admin-rss/v1';
|
|
12
15
|
const MAX_TASKS = 8;
|
|
@@ -18,10 +21,10 @@ const UUID = /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/iu;
|
|
|
18
21
|
const DIGEST = /^[\da-f]{64}$/u;
|
|
19
22
|
type Task = {
|
|
20
23
|
readonly id: string; readonly creation: string; readonly version: string; readonly url: string;
|
|
21
|
-
readonly target: DeliveryTarget; readonly createdAt: number;
|
|
24
|
+
readonly target: DeliveryTarget; readonly locale?: Locale; readonly createdAt: number;
|
|
22
25
|
readonly state: 'scheduled' | 'paused' | 'unknown' | 'failed' | 'cancelled';
|
|
23
26
|
readonly seen: readonly string[] | null; readonly claimId: string | null;
|
|
24
|
-
readonly lastCheckAt: number | null; readonly lastRunAt: number | null; readonly detail: string;
|
|
27
|
+
readonly lastCheckAt: number | null; readonly lastRunAt: number | null; readonly detail: string; readonly detailKey?: AdminTextKey;
|
|
25
28
|
readonly quiet?: RssQuietHours;
|
|
26
29
|
readonly pending?: PendingSummary | null;
|
|
27
30
|
};
|
|
@@ -33,13 +36,27 @@ type FeedLoader = typeof fetchFeed;
|
|
|
33
36
|
|
|
34
37
|
/** Administrator subscriptions use their own owner-bound durable pool and per-task cursors. */
|
|
35
38
|
export class AdministratorRssTasks implements PluginTaskProvider {
|
|
39
|
+
readonly i18n = {
|
|
40
|
+
"en": {
|
|
41
|
+
"title": "Administrator RSS subscriptions",
|
|
42
|
+
"description": "Deliver new articles to an exact account, conversation and thread, independently of chat subscriptions.",
|
|
43
|
+
"effects": [
|
|
44
|
+
"Fetches the feed on the instance pollCron schedule. The first successful fetch after creation or resuming only establishes a baseline; historical articles are not sent.",
|
|
45
|
+
"New articles use the unified send pipeline and the complete selected target. Pausing or cancelling cannot recall a delivery already in progress.",
|
|
46
|
+
"Unconfirmed delivery is never retried or resumed automatically. Check the target conversation before cancelling and creating another subscription.",
|
|
47
|
+
"Each instance supports 8 tasks and retains 32 operation receipts and 64 recent article identifiers. An article that disappears for a long time may be treated as new when it returns.",
|
|
48
|
+
"Only administrator subscriptions with all three quiet-hours fields defer and summarize delivery. The first poll after quiet hours sends the pending summary; the next poll fetches the feed. Reminders and chat subscriptions are unaffected.",
|
|
49
|
+
"A summary stores up to 3 short items, further limited by maxItems, and counts up to 10000 updates before showing “at least”. Pausing, editing or cancelling clears pending summaries; resuming establishes a new baseline without backfilling."
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
} as const;
|
|
36
53
|
readonly title = '管理员 RSS 订阅';
|
|
37
54
|
readonly description = '向精确账号、会话与线程推送新文章,独立于聊天创建的 RSS 订阅';
|
|
38
55
|
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;开始和结束不能相同' },
|
|
56
|
+
url: { type: 'string', minLength: 1, maxLength: 2048, 'x-i18n': {"en": {"title": "RSS / Atom URL", "description": "HTTP(S) URL without a username or password"}}, title: 'RSS / Atom 地址', description: 'HTTP(S) 地址,不接受 URL 用户名或密码' },
|
|
57
|
+
quietTimezone: { type: 'string', maxLength: 128, 'x-i18n': {"en": {"title": "Quiet timezone (optional)", "description": "For example Asia/Shanghai. Fill in all three quiet-hours fields to enable, or leave all empty to disable."}}, title: '静默时区(可选)', description: '如 Asia/Shanghai;三项全部留空关闭静默,启用须全部填写' },
|
|
58
|
+
quietStart: { type: 'string', maxLength: 5, 'x-i18n': {"en": {"title": "Quiet start (optional)", "description": "HH:mm, inclusive, such as 22:00. Timezone and end time are also required."}}, title: '静默开始(可选)', description: 'HH:mm,包含此分钟,如 22:00;须同时填写时区和结束时间' },
|
|
59
|
+
quietEnd: { type: 'string', maxLength: 5, 'x-i18n': {"en": {"title": "Quiet end (optional)", "description": "HH:mm, exclusive, such as 08:00. Start and end must be different."}}, title: '静默结束(可选)', description: 'HH:mm,不包含此分钟,如 08:00;开始和结束不能相同' },
|
|
43
60
|
} } as const;
|
|
44
61
|
readonly effects = [
|
|
45
62
|
'保存后按实例 pollCron 定时访问源站;新建及暂停后恢复的首次成功抓取只建立基线,不发送历史文章',
|
|
@@ -53,51 +70,52 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
53
70
|
readonly #outbound: OutboundHost;
|
|
54
71
|
readonly #config: Readonly<RssConfig>;
|
|
55
72
|
readonly #load: FeedLoader;
|
|
73
|
+
readonly projectLocale: Locale;
|
|
56
74
|
readonly #admission = createGenerationAdmissionGate();
|
|
57
75
|
#stopped = false;
|
|
58
76
|
#working = false;
|
|
59
77
|
get [generationAdmissionSource]() { return [this.#admission]; }
|
|
60
78
|
|
|
61
|
-
constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed) {
|
|
62
|
-
if (!outbound.sendTarget) throw unavailable('
|
|
63
|
-
this.#state = state; this.#outbound = outbound; this.#config = config; this.#load = load;
|
|
79
|
+
constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed, projectLocale: Locale = 'zh-CN') {
|
|
80
|
+
if (!outbound.sendTarget) throw unavailable('exactTarget');
|
|
81
|
+
this.projectLocale = projectLocale; this.#state = state; this.#outbound = outbound; this.#config = config; this.#load = load;
|
|
64
82
|
}
|
|
65
83
|
stop(): void { this.#stopped = true; }
|
|
66
84
|
|
|
67
|
-
async read(): Promise<PluginTaskSnapshot> {
|
|
68
|
-
return this.#admit(async () => this.#snapshot(readBook(await this.#state.get<AdministratorRssBook>(ADMIN_RSS_STATE_KEY))));
|
|
85
|
+
async read(options?: { readonly locale?: Locale }): Promise<PluginTaskSnapshot> {
|
|
86
|
+
return this.#admit(async () => this.#snapshot(readBook(await this.#state.get<AdministratorRssBook>(ADMIN_RSS_STATE_KEY)), options?.locale), options?.locale);
|
|
69
87
|
}
|
|
70
88
|
async create(input: PluginTaskMutation & Readonly<{ target: DeliveryTarget; parameters: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
71
89
|
return this.#admit(async () => {
|
|
72
90
|
assertMutation(input);
|
|
73
91
|
let target: DeliveryTarget;
|
|
74
|
-
try { target = createDeliveryTarget({ conversation: input.target }); } catch { throw invalid('
|
|
92
|
+
try { target = createDeliveryTarget({ conversation: input.target }); } catch { throw invalid('invalidTarget'); }
|
|
75
93
|
const parameters = parseParameters(input.parameters), { url, quiet } = parameters;
|
|
76
94
|
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,
|
|
95
|
+
const task: Task = { id: input.requestId, creation: fingerprint, version: randomUUID(), url, target, locale: this.projectLocale, createdAt: Date.now(), state: 'scheduled',
|
|
96
|
+
seen: null, claimId: null, lastCheckAt: null, lastRunAt: null, ...taskDetail('waiting'), ...(quiet ? { quiet, pending: null } : {}) };
|
|
79
97
|
const revision = randomUUID();
|
|
80
98
|
const book = await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
|
|
81
99
|
this.#assertLive(); const book = readBook(current);
|
|
82
100
|
if (reconcile(book, input.requestId, fingerprint)) return book;
|
|
83
101
|
assertRevision(book, input.expectedRevision);
|
|
84
102
|
const existing = book.tasks.find(item => item.id === task.id);
|
|
85
|
-
if (existing) { if (existing.creation !== fingerprint) throw conflict('
|
|
103
|
+
if (existing) { if (existing.creation !== fingerprint) throw conflict('requestReused'); return book; }
|
|
86
104
|
const active = book.tasks.filter(item => item.state !== 'cancelled' && item.state !== 'failed');
|
|
87
|
-
if (active.length >= MAX_TASKS) throw invalid('
|
|
88
|
-
if (active.some(item => item.url === url && JSON.stringify(item.target) === JSON.stringify(target))) throw invalid('
|
|
105
|
+
if (active.length >= MAX_TASKS) throw invalid('quota');
|
|
106
|
+
if (active.some(item => item.url === url && JSON.stringify(item.target) === JSON.stringify(target))) throw invalid('duplicate');
|
|
89
107
|
const historyLimit = MAX_TASKS - active.length - 1;
|
|
90
108
|
const history = historyLimit ? book.tasks.filter(item => item.state === 'cancelled' || item.state === 'failed').slice(-historyLimit) : [];
|
|
91
109
|
return bounded({ version: 2, revision, tasks: [...active, ...history, task], receipts: receipt(book, input.requestId, fingerprint, task.id, 'create') });
|
|
92
110
|
});
|
|
93
|
-
return this.#snapshot(readBook(book));
|
|
94
|
-
});
|
|
111
|
+
return this.#snapshot(readBook(book), input.locale);
|
|
112
|
+
}, input.locale);
|
|
95
113
|
}
|
|
96
114
|
async change(input: PluginTaskMutation & Readonly<{ id: string; action: PluginTaskAction; parameters?: Readonly<Record<string, JsonValue>> }>): Promise<PluginTaskSnapshot> {
|
|
97
115
|
return this.#admit(async () => {
|
|
98
116
|
assertMutation(input);
|
|
99
|
-
if (!isUuid(input.id) || !['pause', 'resume', 'cancel', 'update'].includes(input.action)) throw invalid('
|
|
100
|
-
if (input.action !== 'update' && input.parameters !== undefined) throw invalid('
|
|
117
|
+
if (!isUuid(input.id) || !['pause', 'resume', 'cancel', 'update'].includes(input.action)) throw invalid('invalidAction');
|
|
118
|
+
if (input.action !== 'update' && input.parameters !== undefined) throw invalid('updateParameters');
|
|
101
119
|
const parameters = input.action === 'update' ? parseParameters(input.parameters) : undefined;
|
|
102
120
|
const url = parameters?.url;
|
|
103
121
|
const fingerprint = digest({ operation: input.action, id: input.id, url: url ?? null, ...(parameters?.quiet ? { quiet: parameters.quiet } : {}) }), revision = randomUUID(), version = randomUUID();
|
|
@@ -106,28 +124,28 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
106
124
|
if (reconcile(book, input.requestId, fingerprint)) return book;
|
|
107
125
|
assertRevision(book, input.expectedRevision);
|
|
108
126
|
const task = book.tasks.find(item => item.id === input.id);
|
|
109
|
-
if (!task) throw new
|
|
127
|
+
if (!task) throw new LocalizedTaskError('not_found', 'notFound');
|
|
110
128
|
let changed: Task;
|
|
111
129
|
switch (input.action) {
|
|
112
130
|
case 'pause':
|
|
113
|
-
if (task.state !== 'scheduled') throw invalid('
|
|
114
|
-
changed = { ...task, version, state: 'paused', pending: null,
|
|
131
|
+
if (task.state !== 'scheduled') throw invalid('pauseState');
|
|
132
|
+
changed = { ...task, version, state: 'paused', pending: null, ...taskDetail('paused') }; break;
|
|
115
133
|
case 'resume':
|
|
116
|
-
if (task.state !== 'paused') throw invalid('
|
|
117
|
-
changed = { ...task, version, state: 'scheduled', seen: null, pending: null,
|
|
134
|
+
if (task.state !== 'paused') throw invalid('resumeState');
|
|
135
|
+
changed = { ...task, version, state: 'scheduled', seen: null, pending: null, ...taskDetail('resumed') }; break;
|
|
118
136
|
case 'update':
|
|
119
|
-
if (task.state !== 'paused') throw invalid('
|
|
137
|
+
if (task.state !== 'paused') throw invalid('updateState');
|
|
120
138
|
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,
|
|
139
|
+
&& JSON.stringify(item.target) === JSON.stringify(task.target))) throw invalid('duplicateUpdate');
|
|
140
|
+
changed = { ...withoutQuiet(task), version, url: url!, ...(parameters?.quiet ? { quiet: parameters.quiet } : {}), pending: null, seen: null, ...taskDetail('edited') }; break;
|
|
123
141
|
case 'cancel':
|
|
124
|
-
if (task.state === 'cancelled' || task.state === 'failed') throw invalid('
|
|
125
|
-
changed = { ...task, version, state: 'cancelled', pending: null,
|
|
142
|
+
if (task.state === 'cancelled' || task.state === 'failed') throw invalid('finished');
|
|
143
|
+
changed = { ...task, version, state: 'cancelled', pending: null, ...taskDetail('cancelled') }; break;
|
|
126
144
|
}
|
|
127
145
|
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
146
|
});
|
|
129
|
-
return this.#snapshot(readBook(result));
|
|
130
|
-
});
|
|
147
|
+
return this.#snapshot(readBook(result), input.locale);
|
|
148
|
+
}, input.locale);
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
/** Called by the plugin's one existing RSS poll registration, never by chat rss-check. */
|
|
@@ -144,7 +162,7 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
144
162
|
if (this.#stopped) break;
|
|
145
163
|
if (task.state !== 'scheduled' || !task.quiet || !task.pending || isRssQuietMinute(task.quiet, Date.now())) continue;
|
|
146
164
|
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)); }
|
|
165
|
+
if (claimed) { flushed.add(task.id); await this.#deliver(claimed, formatSummary(claimed.url, claimed.pending!, this.#config.maxItems, resolveLocale(claimed.locale))); }
|
|
148
166
|
}
|
|
149
167
|
const tasks = readBook(await this.#state.get<AdministratorRssBook>(ADMIN_RSS_STATE_KEY)).tasks;
|
|
150
168
|
for (const task of tasks) {
|
|
@@ -152,7 +170,7 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
152
170
|
if (task.state !== 'scheduled' || flushed.has(task.id)) continue;
|
|
153
171
|
let feed: Awaited<ReturnType<FeedLoader>>;
|
|
154
172
|
try { feed = await this.#load(task.url, this.#config.timeout); }
|
|
155
|
-
catch { await this.#recordCheck(task, '
|
|
173
|
+
catch { await this.#recordCheck(task, 'fetchFailed'); continue; }
|
|
156
174
|
if (this.#stopped) break;
|
|
157
175
|
// Keep only bounded identifiers, never arbitrary feed bodies, in durable state.
|
|
158
176
|
const unique = new Map<string, FeedItem>();
|
|
@@ -168,15 +186,14 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
168
186
|
const live = book.tasks.find(item => item.id === task.id);
|
|
169
187
|
if (this.#stopped || live?.version !== task.version || live.state !== 'scheduled') return current;
|
|
170
188
|
const changed: Task = { ...live, seen, version, lastCheckAt: now, pending, ...(shouldSend
|
|
171
|
-
? { state: 'unknown', claimId, lastRunAt: now,
|
|
172
|
-
:
|
|
173
|
-
: task.seen === null ? '已建立基线;从后续新文章开始推送' : '抓取完成,暂无新文章' }) };
|
|
189
|
+
? { state: 'unknown', claimId, lastRunAt: now, ...taskDetail('delivering') }
|
|
190
|
+
: taskDetail(pending ? 'quietPending' : task.seen === null ? 'baseline' : 'noNew')) };
|
|
174
191
|
return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id ? changed : item) });
|
|
175
192
|
});
|
|
176
193
|
const claimed = readBook(result).tasks.find(item => item.id === task.id && item.claimId === claimId && item.state === 'unknown');
|
|
177
194
|
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));
|
|
195
|
+
await this.#deliver(claimed, claimed.pending ? formatSummary(claimed.url, claimed.pending, this.#config.maxItems, resolveLocale(claimed.locale))
|
|
196
|
+
: formatNewItems(feed.title || claimed.url, newItems, this.#config.maxItems, resolveLocale(claimed.locale)));
|
|
180
197
|
}
|
|
181
198
|
});
|
|
182
199
|
} finally { this.#working = false; }
|
|
@@ -187,48 +204,53 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
187
204
|
const book = readBook(current), live = book.tasks.find(item => item.id === task.id);
|
|
188
205
|
if (this.#stopped || !live || live.version !== task.version || live.state !== 'scheduled' || !live.pending || !live.quiet || isRssQuietMinute(live.quiet, now)) return current;
|
|
189
206
|
return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id
|
|
190
|
-
? { ...item, version, claimId, lastRunAt: now, state: 'unknown',
|
|
207
|
+
? { ...item, version, claimId, lastRunAt: now, state: 'unknown', ...taskDetail('summaryDelivering') } : item) });
|
|
191
208
|
});
|
|
192
209
|
return readBook(result).tasks.find(item => item.id === task.id && item.claimId === claimId && item.state === 'unknown');
|
|
193
210
|
}
|
|
194
211
|
async #deliver(claimed: Task, content: string): Promise<void> {
|
|
195
212
|
if (this.#stopped) return;
|
|
196
|
-
let state: Task['state'] = 'unknown', detail = '
|
|
213
|
+
let state: Task['state'] = 'unknown', detail = taskDetail('unknown');
|
|
197
214
|
try {
|
|
198
|
-
const id = await this.#outbound.sendTarget!(claimed.target, content);
|
|
199
|
-
if (id) { state = 'scheduled'; detail = '
|
|
215
|
+
const id = await this.#outbound.sendTarget!(claimed.target, content, { locale: resolveLocale(claimed.locale) });
|
|
216
|
+
if (id) { state = 'scheduled'; detail = taskDetail('delivered'); }
|
|
200
217
|
} catch (error) {
|
|
201
|
-
if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') { state = 'failed'; detail = '
|
|
218
|
+
if (error instanceof EndpointDeliveryError && error.disposition !== 'unknown') { state = 'failed'; detail = taskDetail('rejected'); }
|
|
202
219
|
}
|
|
203
220
|
const revision = randomUUID(), version = randomUUID();
|
|
204
221
|
await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
|
|
205
222
|
const book = readBook(current), live = book.tasks.find(item => item.id === claimed.id);
|
|
206
223
|
if (live?.claimId !== claimed.claimId || live.state !== 'unknown') return current;
|
|
207
224
|
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) });
|
|
225
|
+
? { ...item, state, ...detail, version, ...(state === 'scheduled' ? { pending: null } : {}) } : item) });
|
|
209
226
|
});
|
|
210
227
|
}
|
|
211
|
-
async #recordCheck(task: Task,
|
|
228
|
+
async #recordCheck(task: Task, detailKey: AdminTextKey): Promise<void> {
|
|
212
229
|
if (this.#stopped) return;
|
|
213
230
|
const revision = randomUUID(), version = randomUUID(), now = Date.now();
|
|
214
231
|
await this.#state.update<AdministratorRssBook>(ADMIN_RSS_STATE_KEY, current => {
|
|
215
232
|
const book = readBook(current);
|
|
216
233
|
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,
|
|
234
|
+
return bounded({ ...book, version: 2, revision, tasks: book.tasks.map(item => item.id === task.id ? { ...item, version, ...taskDetail(detailKey), lastCheckAt: now } : item) });
|
|
218
235
|
});
|
|
219
236
|
}
|
|
220
|
-
#assertLive(): void { if (this.#stopped) throw unavailable('
|
|
221
|
-
async #admit<T>(operation: () => Promise<T
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
#assertLive(): void { if (this.#stopped) throw unavailable('stopped'); }
|
|
238
|
+
async #admit<T>(operation: () => Promise<T>, locale?: Locale): Promise<T> {
|
|
239
|
+
let release: (() => void) | undefined;
|
|
240
|
+
try {
|
|
241
|
+
this.#assertLive(); release = this.#admission.acquire();
|
|
242
|
+
if (!release) throw unavailable('retired');
|
|
243
|
+
return await operation();
|
|
244
|
+
} catch (error) { throw error instanceof LocalizedTaskError ? error.localize(locale) : error; } finally { release?.(); }
|
|
225
245
|
}
|
|
226
|
-
#snapshot(book: AdministratorRssBook): PluginTaskSnapshot {
|
|
246
|
+
#snapshot(book: AdministratorRssBook, locale?: Locale): PluginTaskSnapshot {
|
|
227
247
|
return { revision: book.revision, receipts: book.receipts.map(item => ({ requestId: item.id, taskId: item.taskId, operation: item.operation })),
|
|
228
248
|
tasks: book.tasks.map(task => ({ id: task.id, ownership: 'administrator', title: `RSS · ${task.url}`.slice(0, 200), target: task.target,
|
|
229
249
|
state: task.state, createdAt: task.createdAt, ...(task.lastRunAt === null ? {} : { lastRunAt: task.lastRunAt }), parameters: { url: task.url,
|
|
230
250
|
...(task.quiet ? { quietTimezone: task.quiet.timezone, quietStart: task.quiet.start, quietEnd: task.quiet.end } : {}) },
|
|
231
|
-
detail:
|
|
251
|
+
detail: [formatTaskDetail(task, locale), task.quiet ? adminText(locale, 'quietDescription', task.quiet) : '',
|
|
252
|
+
task.pending ? adminText(locale, task.state === 'scheduled' ? 'pendingDescription' : 'summaryDescription', { count: task.pending.countCapped ? adminText(locale, 'countCapped', { count: task.pending.count }) : task.pending.count }) : '',
|
|
253
|
+
adminText(locale, 'pollDescription', { cron: this.#config.pollCron }), task.lastCheckAt === null ? '' : adminText(locale, 'checkedDescription', { at: new Date(task.lastCheckAt).toISOString() }), adminText(locale, 'stateNote')].filter(Boolean).join(locale === 'en' ? '; ' : ';'),
|
|
232
254
|
actions: task.state === 'scheduled' ? ['pause', 'cancel'] : task.state === 'paused' ? ['update', 'resume', 'cancel'] : task.state === 'unknown' ? ['cancel'] : [],
|
|
233
255
|
})) };
|
|
234
256
|
}
|
|
@@ -236,21 +258,21 @@ export class AdministratorRssTasks implements PluginTaskProvider {
|
|
|
236
258
|
|
|
237
259
|
function parseParameters(value: Readonly<Record<string, JsonValue>> | undefined): { url: string; quiet?: RssQuietHours } {
|
|
238
260
|
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('
|
|
261
|
+
|| typeof value.url !== 'string' || value.url.length > 2048) throw invalid('invalidParameters');
|
|
240
262
|
let quiet: RssQuietHours | undefined;
|
|
241
263
|
try { quiet = parseQuietHours(value); }
|
|
242
|
-
catch (error) {
|
|
264
|
+
catch (error) { if (error instanceof LocalizedQuietHoursError) throw invalid(error.messageKey); throw invalid('invalidQuiet'); }
|
|
243
265
|
try {
|
|
244
266
|
const url = new URL(value.url);
|
|
245
267
|
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || /\s/u.test(value.url)) throw new Error('url');
|
|
246
268
|
url.hash = '';
|
|
247
269
|
if (url.href.length > 2048) throw new Error('url length');
|
|
248
270
|
return { url: url.href, ...(quiet ? { quiet } : {}) };
|
|
249
|
-
} catch { throw invalid('
|
|
271
|
+
} catch { throw invalid('invalidUrl'); }
|
|
250
272
|
}
|
|
251
273
|
function assertMutation(input: PluginTaskMutation): void {
|
|
252
274
|
if (!isUuid(input.requestId) || typeof input.expectedRevision !== 'string'
|
|
253
|
-
|| !input.expectedRevision || input.expectedRevision.length > 128) throw invalid('
|
|
275
|
+
|| !input.expectedRevision || input.expectedRevision.length > 128) throw invalid('invalidMutation');
|
|
254
276
|
}
|
|
255
277
|
function digest(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
256
278
|
function isUuid(value: unknown): value is string { return typeof value === 'string' && UUID.test(value); }
|
|
@@ -258,21 +280,21 @@ function isDigest(value: unknown): value is string { return typeof value === 'st
|
|
|
258
280
|
function isTimestamp(value: unknown): value is number {
|
|
259
281
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && Number.isFinite(new Date(value).getTime());
|
|
260
282
|
}
|
|
261
|
-
function invalid(message:
|
|
262
|
-
function conflict(message:
|
|
263
|
-
function unavailable(message:
|
|
264
|
-
function assertRevision(book: AdministratorRssBook, revision: string): void { if (book.revision !== revision) throw conflict('
|
|
283
|
+
function invalid(message: AdminTextKey): PluginTaskError { return new LocalizedTaskError('invalid_input', message); }
|
|
284
|
+
function conflict(message: AdminTextKey): PluginTaskError { return new LocalizedTaskError('conflict', message); }
|
|
285
|
+
function unavailable(message: AdminTextKey): PluginTaskError { return new LocalizedTaskError('unavailable', message); }
|
|
286
|
+
function assertRevision(book: AdministratorRssBook, revision: string): void { if (book.revision !== revision) throw conflict('revision'); }
|
|
265
287
|
function reconcile(book: AdministratorRssBook, id: string, fingerprint: string): boolean {
|
|
266
288
|
const previous = book.receipts.find(item => item.id === id);
|
|
267
289
|
if (!previous) return false;
|
|
268
|
-
if (previous.fingerprint !== fingerprint) throw conflict('
|
|
290
|
+
if (previous.fingerprint !== fingerprint) throw conflict('replayConflict');
|
|
269
291
|
return true;
|
|
270
292
|
}
|
|
271
293
|
function receipt(book: AdministratorRssBook, id: string, fingerprint: string, taskId: string, operation: Receipt['operation']): readonly Receipt[] {
|
|
272
294
|
return [...book.receipts, { id, fingerprint, taskId, operation }].slice(-MAX_RECEIPTS);
|
|
273
295
|
}
|
|
274
296
|
function bounded(book: AdministratorRssBook): AdministratorRssBook {
|
|
275
|
-
if (book.tasks.length > MAX_TASKS || bytes(book) > 60 * 1024) throw invalid('
|
|
297
|
+
if (book.tasks.length > MAX_TASKS || bytes(book) > 60 * 1024) throw invalid('capacity');
|
|
276
298
|
// Reserve future bounded cursors, summary samples and claim metadata at mutation
|
|
277
299
|
// time. Once accepted, ordinary quiet accumulation cannot exceed the state port.
|
|
278
300
|
const reserved = { ...book, revision: 'f'.repeat(36), tasks: book.tasks.map(task => !task.quiet || task.state === 'cancelled' || task.state === 'failed' ? task : {
|
|
@@ -282,7 +304,7 @@ function bounded(book: AdministratorRssBook): AdministratorRssBook {
|
|
|
282
304
|
pending: { from: 8_640_000_000_000_000, to: 8_640_000_000_000_000, count: MAX_PENDING_COUNT, countCapped: false,
|
|
283
305
|
items: Array.from({ length: MAX_PENDING_ITEMS }, () => ({ key: 'f'.repeat(64), title: 'x'.repeat(128), link: 'x'.repeat(384) })) },
|
|
284
306
|
}) };
|
|
285
|
-
if (bytes(reserved) > 60 * 1024) throw invalid('
|
|
307
|
+
if (bytes(reserved) > 60 * 1024) throw invalid('capacityReserved');
|
|
286
308
|
return book;
|
|
287
309
|
}
|
|
288
310
|
function readBook(value: AdministratorRssBook | undefined): AdministratorRssBook {
|
|
@@ -303,6 +325,7 @@ function readBook(value: AdministratorRssBook | undefined): AdministratorRssBook
|
|
|
303
325
|
|| (task.lastRunAt !== null && !isTimestamp(task.lastRunAt))
|
|
304
326
|
|| (task.claimId !== null && !isUuid(task.claimId)) || (task.state === 'unknown' && (!task.claimId || task.seen === null || task.lastRunAt === null))
|
|
305
327
|
|| (task.seen !== null && (!Array.isArray(task.seen) || task.seen.length > MAX_SEEN || !task.seen.every((key: unknown) => typeof key === 'string' && DIGEST.test(key))))
|
|
328
|
+
|| (task.locale !== undefined && task.locale !== 'zh-CN' && task.locale !== 'en') || (task.detailKey !== undefined && !isAdminTextKey(task.detailKey))
|
|
306
329
|
|| typeof task.detail !== 'string' || task.detail.length > 1000 || parseParameters({ url: task.url }).url !== task.url) throw new Error('task');
|
|
307
330
|
if (task.quiet !== undefined) {
|
|
308
331
|
if (value.version !== 2 || !task.quiet || Object.keys(task.quiet).sort().join(',') !== 'end,start,timezone'
|
|
@@ -326,7 +349,7 @@ function readBook(value: AdministratorRssBook | undefined): AdministratorRssBook
|
|
|
326
349
|
ids.add(task.id);
|
|
327
350
|
}
|
|
328
351
|
return bounded(value);
|
|
329
|
-
} catch { throw unavailable('
|
|
352
|
+
} catch { throw unavailable('corrupt'); }
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
function withoutQuiet(task: Task): Omit<Task, 'quiet'> { const { quiet: _quiet, ...rest } = task; return rest; }
|
|
@@ -360,10 +383,10 @@ function shortLink(value: string): string {
|
|
|
360
383
|
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && bytes(url.href) <= 384 ? url.href : '';
|
|
361
384
|
} catch { return ''; }
|
|
362
385
|
}
|
|
363
|
-
function formatSummary(url: string, pending: PendingSummary, maxItems: number): string {
|
|
364
|
-
const lines = [
|
|
386
|
+
function formatSummary(url: string, pending: PendingSummary, maxItems: number, locale: Locale = 'zh-CN'): string {
|
|
387
|
+
const lines = [t(locale, 'summaryHeading', { minimum: pending.countCapped ? t(locale, 'atLeast') : '', count: pending.count }), t(locale, 'source', { url })];
|
|
365
388
|
const shown = pending.items.slice(0, Math.min(MAX_PENDING_ITEMS, maxItems));
|
|
366
|
-
for (const item of shown) lines.push('', item.title || '
|
|
367
|
-
if (pending.count > shown.length || pending.countCapped) lines.push('',
|
|
389
|
+
for (const item of shown) lines.push('', item.title || t(locale, 'untitled'), ...(item.link ? [item.link] : []));
|
|
390
|
+
if (pending.count > shown.length || pending.countCapped) lines.push('', t(locale, 'summaryMore', { count: shown.length }));
|
|
368
391
|
return lines.join('\n');
|
|
369
392
|
}
|
package/src/channel.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Locale } from 'zhin.js/i18n';
|
|
2
|
+
import { rssMessages as t } from './i18n.js';
|
|
1
3
|
/** A subscription destination uses public Message fields, never CapabilityId internals. */
|
|
2
4
|
|
|
3
5
|
export interface ChannelInfo {
|
|
@@ -26,8 +28,8 @@ function hasUnsupportedAddress(input: unknown): boolean {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/** Explain rejection without silently flattening a thread/container address. */
|
|
29
|
-
export function channelSourceError(input: unknown): string {
|
|
30
|
-
return hasUnsupportedAddress(input) ?
|
|
31
|
+
export function channelSourceError(input: unknown, locale: Locale = 'zh-CN'): string {
|
|
32
|
+
return t(locale, hasUnsupportedAddress(input) ? 'unsupportedAddress' : 'channelRequired');
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** No IM source means no destination; callers must not invent a global subscription. */
|
package/src/feed.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Locale } from 'zhin.js/i18n';
|
|
2
|
+
import { rssMessages as t } from './i18n.js';
|
|
1
3
|
/**
|
|
2
4
|
* RSS feed fetch/preview helpers (no Plugin host).
|
|
3
5
|
*/
|
|
@@ -92,15 +94,15 @@ export async function fetchFeed(
|
|
|
92
94
|
return { title: (feed.title || url).trim(), items };
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
export function formatFeedPreview(title: string, items: FeedItem[]): string {
|
|
96
|
-
if (items.length === 0) return
|
|
97
|
-
const lines = [
|
|
97
|
+
export function formatFeedPreview(title: string, items: FeedItem[], locale: Locale = 'zh-CN'): string {
|
|
98
|
+
if (items.length === 0) return t(locale, 'previewEmpty', { title });
|
|
99
|
+
const lines = [t(locale, 'previewTitle', { title, count: items.length })];
|
|
98
100
|
for (const item of items.slice(0, 5)) {
|
|
99
101
|
lines.push('');
|
|
100
102
|
lines.push(`${item.title}`);
|
|
101
|
-
if (item.date) lines.push(` ${new Date(item.date).toLocaleString(
|
|
103
|
+
if (item.date) lines.push(` ${new Date(item.date).toLocaleString(locale)}`);
|
|
102
104
|
if (item.link) lines.push(` ${item.link}`);
|
|
103
105
|
}
|
|
104
|
-
if (items.length > 5) lines.push(
|
|
106
|
+
if (items.length > 5) lines.push(t(locale, 'more', { count: items.length - 5 }));
|
|
105
107
|
return lines.join('\n');
|
|
106
108
|
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createMessages } from 'zhin.js/i18n';
|
|
2
|
+
|
|
3
|
+
export const rssMessages = createMessages({
|
|
4
|
+
toolPreviewEmpty: { 'zh-CN': '{title}: 无内容', en: '{title}: No items' },
|
|
5
|
+
toolPreviewFailed: { 'zh-CN': '解析失败: {error}', en: 'Parsing failed: {error}' },
|
|
6
|
+
allEmpty: { 'zh-CN': '暂无任何 RSS 订阅', en: 'There are no RSS subscriptions' },
|
|
7
|
+
allList: { 'zh-CN': 'RSS 订阅总览 ({count} 条):\n{items}', en: 'RSS subscriptions ({count}):\n{items}' },
|
|
8
|
+
"databaseUnavailable": {
|
|
9
|
+
"zh-CN": "RSS 数据库尚未就绪",
|
|
10
|
+
"en": "The RSS database is not ready"
|
|
11
|
+
},
|
|
12
|
+
"databaseRetry": {
|
|
13
|
+
"zh-CN": "RSS 数据库尚未就绪,请稍后重试",
|
|
14
|
+
"en": "The RSS database is not ready; try again later"
|
|
15
|
+
},
|
|
16
|
+
"urlRequired": {
|
|
17
|
+
"zh-CN": "请提供 RSS 源地址",
|
|
18
|
+
"en": "Provide an RSS feed URL"
|
|
19
|
+
},
|
|
20
|
+
"httpRequired": {
|
|
21
|
+
"zh-CN": "请提供有效的 HTTP/HTTPS 地址",
|
|
22
|
+
"en": "Provide a valid HTTP/HTTPS URL"
|
|
23
|
+
},
|
|
24
|
+
"existing": {
|
|
25
|
+
"zh-CN": "当前会话已订阅该源,无需重复添加",
|
|
26
|
+
"en": "This conversation already subscribes to that feed"
|
|
27
|
+
},
|
|
28
|
+
"limit": {
|
|
29
|
+
"zh-CN": "当前会话订阅数已达上限 ({max}),请先取消一些",
|
|
30
|
+
"en": "This conversation has reached its subscription limit ({max}); remove a subscription first"
|
|
31
|
+
},
|
|
32
|
+
"loadFailed": {
|
|
33
|
+
"zh-CN": "无法解析该地址: {error}\n请确认是有效的 RSS/Atom 源",
|
|
34
|
+
"en": "Could not parse this URL: {error}\nCheck that it is a valid RSS/Atom feed"
|
|
35
|
+
},
|
|
36
|
+
"added": {
|
|
37
|
+
"zh-CN": "订阅成功!\n源: {feed}\n新内容将在轮询时推送到本会话(需 OutboundHost)",
|
|
38
|
+
"en": "Subscribed!\nFeed: {feed}\nNew items will be sent to this conversation when polled (requires OutboundHost)"
|
|
39
|
+
},
|
|
40
|
+
"empty": {
|
|
41
|
+
"zh-CN": "当前会话没有订阅任何 RSS 源",
|
|
42
|
+
"en": "This conversation has no RSS subscriptions"
|
|
43
|
+
},
|
|
44
|
+
"addHint": {
|
|
45
|
+
"zh-CN": "使用 {command} <URL> 添加订阅",
|
|
46
|
+
"en": "Use {command} <URL> to subscribe"
|
|
47
|
+
},
|
|
48
|
+
"unknownTitle": {
|
|
49
|
+
"zh-CN": "(未知标题)",
|
|
50
|
+
"en": "(Untitled)"
|
|
51
|
+
},
|
|
52
|
+
"list": {
|
|
53
|
+
"zh-CN": "当前订阅 ({count}/{max}):\n{items}",
|
|
54
|
+
"en": "Subscriptions ({count}/{max}):\n{items}"
|
|
55
|
+
},
|
|
56
|
+
"notSubscribed": {
|
|
57
|
+
"zh-CN": "当前会话未订阅该源",
|
|
58
|
+
"en": "This conversation does not subscribe to that feed"
|
|
59
|
+
},
|
|
60
|
+
"noSubscriptions": {
|
|
61
|
+
"zh-CN": "当前会话没有任何订阅",
|
|
62
|
+
"en": "This conversation has no subscriptions"
|
|
63
|
+
},
|
|
64
|
+
"removeUrl": {
|
|
65
|
+
"zh-CN": "请提供要取消的 RSS 源地址",
|
|
66
|
+
"en": "Provide the RSS feed URL to unsubscribe"
|
|
67
|
+
},
|
|
68
|
+
"notFound": {
|
|
69
|
+
"zh-CN": "未找到该订阅",
|
|
70
|
+
"en": "Subscription not found"
|
|
71
|
+
},
|
|
72
|
+
"removed": {
|
|
73
|
+
"zh-CN": "已取消订阅: {feed}",
|
|
74
|
+
"en": "Unsubscribed: {feed}"
|
|
75
|
+
},
|
|
76
|
+
"previewFailed": {
|
|
77
|
+
"zh-CN": "解析失败: {error}\n请确认是有效的 RSS/Atom 地址",
|
|
78
|
+
"en": "Parsing failed: {error}\nCheck that it is a valid RSS/Atom URL"
|
|
79
|
+
},
|
|
80
|
+
"channelRequired": {
|
|
81
|
+
"zh-CN": "请在机器人私聊、群聊或频道内执行此命令;当前调用没有有效的订阅会话",
|
|
82
|
+
"en": "Run this command in a bot private chat, group or channel; this invocation has no valid subscription conversation"
|
|
83
|
+
},
|
|
84
|
+
"unsupportedAddress": {
|
|
85
|
+
"zh-CN": "暂不支持线程/父频道寻址的订阅,请在无需线程或父频道信息的会话内执行",
|
|
86
|
+
"en": "Thread or parent-channel subscription addresses are not supported; use a conversation that does not require them"
|
|
87
|
+
},
|
|
88
|
+
"previewEmpty": {
|
|
89
|
+
"zh-CN": "{title}: 暂无内容",
|
|
90
|
+
"en": "{title}: No items"
|
|
91
|
+
},
|
|
92
|
+
"previewTitle": {
|
|
93
|
+
"zh-CN": "{title} (共 {count} 条)",
|
|
94
|
+
"en": "{title} ({count} items)"
|
|
95
|
+
},
|
|
96
|
+
"more": {
|
|
97
|
+
"zh-CN": "\n...还有 {count} 条",
|
|
98
|
+
"en": "\n...{count} more items"
|
|
99
|
+
},
|
|
100
|
+
"newItems": {
|
|
101
|
+
"zh-CN": "{title} 有 {count} 条新内容:",
|
|
102
|
+
"en": "{title}: {count} new items:"
|
|
103
|
+
},
|
|
104
|
+
"moreSource": {
|
|
105
|
+
"zh-CN": "\n...还有 {count} 条,请查看源站",
|
|
106
|
+
"en": "\n...{count} more items; visit the source website"
|
|
107
|
+
},
|
|
108
|
+
"noNew": {
|
|
109
|
+
"zh-CN": "{title}: 无新内容",
|
|
110
|
+
"en": "{title}: No new items"
|
|
111
|
+
},
|
|
112
|
+
"checkFailed": {
|
|
113
|
+
"zh-CN": "{url}: 检查失败 — {error}",
|
|
114
|
+
"en": "{url}: Check failed — {error}"
|
|
115
|
+
},
|
|
116
|
+
"nothingToCheck": {
|
|
117
|
+
"zh-CN": "没有可检查的订阅",
|
|
118
|
+
"en": "There are no subscriptions to check"
|
|
119
|
+
},
|
|
120
|
+
"pushed": {
|
|
121
|
+
"zh-CN": ",已推送 {count} 个会话",
|
|
122
|
+
"en": ", sent to {count} conversations"
|
|
123
|
+
},
|
|
124
|
+
"notPushed": {
|
|
125
|
+
"zh-CN": ",无出站推送",
|
|
126
|
+
"en": ", no outbound delivery"
|
|
127
|
+
},
|
|
128
|
+
"localOnly": {
|
|
129
|
+
"zh-CN": "(未装配 OutboundHost,仅本地标记)",
|
|
130
|
+
"en": " (OutboundHost is not configured; items were only marked locally)"
|
|
131
|
+
},
|
|
132
|
+
"checked": {
|
|
133
|
+
"zh-CN": "检查完成,共 {count} 条新内容{pushNote}",
|
|
134
|
+
"en": "Check complete: {count} new items{pushNote}"
|
|
135
|
+
},
|
|
136
|
+
"checkedEmpty": {
|
|
137
|
+
"zh-CN": "检查完成,暂无新内容",
|
|
138
|
+
"en": "Check complete: no new items"
|
|
139
|
+
},
|
|
140
|
+
"noFeeds": {
|
|
141
|
+
"zh-CN": "暂无任何 RSS 订阅",
|
|
142
|
+
"en": "There are no RSS subscriptions"
|
|
143
|
+
},
|
|
144
|
+
"summaryTitle": {
|
|
145
|
+
"zh-CN": "RSS 静默摘要",
|
|
146
|
+
"en": "RSS quiet-hours digest"
|
|
147
|
+
},
|
|
148
|
+
"summaryHeading": {
|
|
149
|
+
"zh-CN": "RSS 静默摘要:累计检测到{minimum} {count} 条更新",
|
|
150
|
+
"en": "RSS quiet-hours digest: detected {minimum} {count} updates"
|
|
151
|
+
},
|
|
152
|
+
"atLeast": {
|
|
153
|
+
"zh-CN": "至少",
|
|
154
|
+
"en": "at least"
|
|
155
|
+
},
|
|
156
|
+
"source": {
|
|
157
|
+
"zh-CN": "来源:{url}",
|
|
158
|
+
"en": "Source: {url}"
|
|
159
|
+
},
|
|
160
|
+
"untitled": {
|
|
161
|
+
"zh-CN": "未命名文章",
|
|
162
|
+
"en": "Untitled article"
|
|
163
|
+
},
|
|
164
|
+
"summaryMore": {
|
|
165
|
+
"zh-CN": "仅展示 {count} 条短明细,其余请查看源站;计数基于有界轮询窗口。",
|
|
166
|
+
"en": "Showing {count} short items; visit the source for the rest. Counts are based on the bounded polling window."
|
|
167
|
+
}
|
|
168
|
+
});
|