@zhin.js/plugin-rss 1.1.8 → 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.
Files changed (62) hide show
  1. package/README.md +12 -0
  2. package/commands/rss-add/[url]/index.js +12 -10
  3. package/commands/rss-add/[url]/index.ts +12 -10
  4. package/commands/rss-check/[[url]]/index.js +8 -7
  5. package/commands/rss-check/[[url]]/index.ts +8 -7
  6. package/commands/rss-list/index.js +9 -7
  7. package/commands/rss-list/index.ts +9 -7
  8. package/commands/rss-preview/[url]/index.js +7 -6
  9. package/commands/rss-preview/[url]/index.ts +7 -6
  10. package/commands/rss-remove/[url]/index.js +8 -7
  11. package/commands/rss-remove/[url]/index.ts +8 -7
  12. package/lib/admin-i18n.d.ts +219 -0
  13. package/lib/admin-i18n.d.ts.map +1 -0
  14. package/lib/admin-i18n.js +216 -0
  15. package/lib/admin-i18n.js.map +1 -0
  16. package/lib/admin-tasks.d.ts +40 -2
  17. package/lib/admin-tasks.d.ts.map +1 -1
  18. package/lib/admin-tasks.js +99 -72
  19. package/lib/admin-tasks.js.map +1 -1
  20. package/lib/channel.d.ts +2 -1
  21. package/lib/channel.d.ts.map +1 -1
  22. package/lib/channel.js +3 -3
  23. package/lib/channel.js.map +1 -1
  24. package/lib/feed.d.ts +2 -1
  25. package/lib/feed.d.ts.map +1 -1
  26. package/lib/feed.js +6 -5
  27. package/lib/feed.js.map +1 -1
  28. package/lib/i18n.d.ts +2 -0
  29. package/lib/i18n.d.ts.map +1 -0
  30. package/lib/i18n.js +168 -0
  31. package/lib/i18n.js.map +1 -0
  32. package/lib/poll.d.ts +3 -1
  33. package/lib/poll.d.ts.map +1 -1
  34. package/lib/poll.js +23 -18
  35. package/lib/poll.js.map +1 -1
  36. package/lib/quiet-hours.d.ts +5 -0
  37. package/lib/quiet-hours.d.ts.map +1 -1
  38. package/lib/quiet-hours.js +9 -4
  39. package/lib/quiet-hours.js.map +1 -1
  40. package/lib/runtime.d.ts +3 -0
  41. package/lib/runtime.d.ts.map +1 -1
  42. package/lib/runtime.js.map +1 -1
  43. package/lib/storage.d.ts +3 -0
  44. package/lib/storage.d.ts.map +1 -0
  45. package/lib/storage.js +57 -0
  46. package/lib/storage.js.map +1 -0
  47. package/package.json +7 -7
  48. package/plugin.js +12 -4
  49. package/schema.json +6 -1
  50. package/skills/rss/tools/rss_list_subscriptions/index.js +4 -3
  51. package/skills/rss/tools/rss_list_subscriptions/index.ts +4 -3
  52. package/skills/rss/tools/rss_preview_feed/index.js +4 -3
  53. package/skills/rss/tools/rss_preview_feed/index.ts +4 -3
  54. package/src/admin-i18n.ts +218 -0
  55. package/src/admin-tasks.ts +95 -72
  56. package/src/channel.ts +4 -2
  57. package/src/feed.ts +7 -5
  58. package/src/i18n.ts +168 -0
  59. package/src/poll.ts +24 -18
  60. package/src/quiet-hours.ts +11 -4
  61. package/src/runtime.ts +3 -0
  62. package/src/storage.ts +59 -0
@@ -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('管理员 RSS 需要支持精确目标的 Outbound Host');
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, detail: '等待首次抓取建立基线,不发送历史文章', ...(quiet ? { quiet, pending: 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('同一请求 ID 已用于其他内容'); return book; }
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('管理员 RSS 订阅已达 8 条上限,请先取消任务');
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('任务 ID 或操作无效');
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 PluginTaskError('not_found', '未找到当前实例的管理员 RSS 订阅;聊天订阅不在此入口管理');
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, detail: '管理员暂停并清空待发摘要;恢复时重新建立基线,不补发历史文章' }; break;
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, detail: '已恢复,等待重新建立基线,不补发历史文章' }; break;
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, detail: '已编辑并清空待发摘要,保持暂停,恢复后首次抓取只建立基线' }; break;
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, detail: '管理员取消并清空待发摘要;已经开始的投递无法撤回' }; break;
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, '源站抓取失败;尚未尝试投递,下轮重查'); continue; }
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, detail: '投递中或未确认;不会自动重试' }
172
- : { detail: pending ? `静默中,已积累${pending.countCapped ? '至少' : ''}${pending.count} 条更新;退出静默后的首轮轮询汇总`
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', detail: '摘要投递中或未确认;不会自动重试' } : item) });
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, detail: string): Promise<void> {
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, detail, lastCheckAt: now } : item) });
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('管理员 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(); }
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: `${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()}`};状态不代表平台连接健康`,
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('参数须包含有效 HTTP(S) RSS 地址及可选的三项静默设置');
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) { throw invalid(error instanceof TypeError ? error.message : '静默设置无效'); }
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('请提供有效 HTTP(S) RSS 地址,不接受 URL 用户名或密码'); }
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('操作须包含 UUID requestId 和读取到的 expectedRevision');
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: 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('订阅状态已变化,请刷新后重新确认'); }
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('同一操作请求 ID 已用于其他内容,禁止重放');
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('管理员 RSS 状态达到 8 条或 60 KiB 上限,请先取消并整理订阅');
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('管理员 RSS 状态空间不足以保留静默摘要及去重游标;请先取消并整理订阅,现有数据未改动');
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('管理员 RSS 持久状态格式无效,已停止管理员轮询与投递;请保留原数据核查'); }
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 = [`RSS 静默摘要:累计检测到${pending.countCapped ? '至少' : ''} ${pending.count} 条更新`, `来源:${url}`];
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 || '未命名文章', ...(item.link ? [item.link] : []));
367
- if (pending.count > shown.length || pending.countCapped) lines.push('', `仅展示 ${shown.length} 条短明细,其余请查看源站;计数基于有界轮询窗口。`);
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) ? UNSUPPORTED_ADDRESS_MESSAGE : CHANNEL_REQUIRED_MESSAGE;
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 `${title}: 暂无内容`;
97
- const lines = [`${title} (共 ${items.length} 条)`];
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('zh-CN')}`);
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(`\n...还有 ${items.length - 5} 条`);
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
+ });