koishi-plugin-codex-reset 0.1.4 → 0.1.5

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 CHANGED
@@ -26,7 +26,9 @@
26
26
 
27
27
  日历、月度统计及右侧动态仅展示已确认且日期不晚于今天的事件,不展示预告。日期优先使用实际发生日期,缺失时使用北京时间的确认日期;两者均缺失时不展示,不使用预计时间窗口代替实际日期。
28
28
 
29
- 首次轮询只建立本地基线,不会把 API 返回的历史事件全部推送;后续轮询只处理新增事件或 `announced` → `confirmed` 的状态变化。插件重启后会重新建立基线。
29
+ 首次安装时的第一份快照只建立基线,不会把历史事件全部推送。插件将已见事件状态和待发送通知保存在 Koishi 工作目录的 `data/codex-reset/` 中;重启后继续去重并重试未成功的目标。API 暂时漏掉旧事件、随后又补回时,也不会把它再次当作新事件。只有最近 48 小时内发布的预告或确认事件才会新建推送;超过 48 小时仍未送达的通知会放弃。更改 API 地址或推送目标后会建立新的基线。
30
+
31
+ 图片生成最多同时进行 2 项,新的图片任务至少间隔 3 秒;历史图片超过 120 条事件时会提示缩小查询范围。图片中的长篇来源文字会截短。API 响应上限为 4 MiB。
30
32
 
31
33
  ## 安装
32
34
 
package/lib/calendar.js CHANGED
@@ -150,7 +150,8 @@ function renderCalendarGrid(month, marks, selectedDate, today) {
150
150
  return cells.join('');
151
151
  }
152
152
  function renderPostCard(event, post) {
153
- const text = post.text || post.originalText;
153
+ const originalText = post.text || post.originalText;
154
+ const text = originalText.length > 500 ? `${originalText.slice(0, 500)}…` : originalText;
154
155
  const avatar = TIBO_AVATAR
155
156
  ? `<img class="avatar-image" src="${TIBO_AVATAR}" alt="">`
156
157
  : '<span class="avatar-fallback">T</span>';
@@ -174,7 +175,7 @@ function renderDetails(events) {
174
175
  const statusClass = event.status === 'confirmed' ? 'confirmed-state' : 'announced-state';
175
176
  return `<section class="event-group">
176
177
  <div class="event-state ${statusClass}"><span class="state-icon">${event.status === 'confirmed' ? '✓' : '◷'}</span>${status}</div>
177
- <h2>${escapeHtml(event.title)}</h2>
178
+ <h2>${escapeHtml(event.title.slice(0, 200))}</h2>
178
179
  ${renderPostCard(event, post)}
179
180
  </section>`;
180
181
  }).join('');
package/lib/event.js CHANGED
@@ -44,7 +44,8 @@ function renderSourcePost(summary) {
44
44
  const avatar = (0, calendar_1.getTiboAvatarDataUri)()
45
45
  ? `<img class="avatar-image" src="${(0, calendar_1.getTiboAvatarDataUri)()}" alt="">`
46
46
  : '<span class="avatar-fallback">T</span>';
47
- const text = post.text || post.originalText;
47
+ const originalText = post.text || post.originalText;
48
+ const text = originalText.length > 500 ? `${originalText.slice(0, 500)}…` : originalText;
48
49
  const publishedAt = Date.parse(post.publishedAt);
49
50
  const time = Number.isFinite(publishedAt) ? formatDateTime(publishedAt) : '时间未知';
50
51
  return `<article class="post-card">
@@ -120,7 +121,7 @@ function renderLatestResetHtml(summary, now = new Date()) {
120
121
  </section>
121
122
  <section class="source">
122
123
  <header class="source-header"><h2>事件来源</h2><span>北京时间</span></header>
123
- <h2 class="event-title">${escapeHtml(event.title)}</h2>
124
+ <h2 class="event-title">${escapeHtml(event.title.slice(0, 200))}</h2>
124
125
  ${renderSourcePost(summary)}
125
126
  <p class="source-note">${escapeHtml(event.scope ? `范围:${event.scope}` : '公开来源帖子记录')}</p>
126
127
  <div class="mark">RESET MONITOR</div>
package/lib/history.js CHANGED
@@ -20,8 +20,8 @@ function parseTimestamp(value) {
20
20
  }
21
21
  function eventTimestamp(event) {
22
22
  const values = [
23
- event.confirmedAt,
24
23
  event.occurredOn ? `${event.occurredOn}T12:00:00+08:00` : null,
24
+ event.confirmedAt,
25
25
  event.schedule?.from,
26
26
  event.createdAt,
27
27
  ];
@@ -43,7 +43,9 @@ function filterHistoryEvents(events, now = new Date(), range = 1) {
43
43
  day: 'numeric',
44
44
  }).formatToParts(now);
45
45
  const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
46
- const cutoff = Date.UTC(Number(values.year), Number(values.month) - 1 - months, Number(values.day), -8);
46
+ const targetMonth = new Date(Date.UTC(Number(values.year), Number(values.month) - 1 - months, 1));
47
+ const lastDay = new Date(Date.UTC(targetMonth.getUTCFullYear(), targetMonth.getUTCMonth() + 1, 0)).getUTCDate();
48
+ const cutoff = Date.UTC(targetMonth.getUTCFullYear(), targetMonth.getUTCMonth(), Math.min(Number(values.day), lastDay), -8);
47
49
  const current = now.getTime();
48
50
  return events.filter((event) => {
49
51
  const timestamp = eventTimestamp(event);
@@ -89,7 +91,8 @@ function renderPostPreview(event) {
89
91
  const post = event.posts[0];
90
92
  if (!post)
91
93
  return '<div class="post-preview empty">暂无来源帖子内容</div>';
92
- const text = post.text || post.originalText;
94
+ const originalText = post.text || post.originalText;
95
+ const text = originalText.length > 500 ? `${originalText.slice(0, 500)}…` : originalText;
93
96
  const avatar = (0, calendar_1.getTiboAvatarDataUri)()
94
97
  ? `<img class="avatar-image" src="${(0, calendar_1.getTiboAvatarDataUri)()}" alt="">`
95
98
  : '<span class="avatar-fallback">T</span>';
@@ -105,13 +108,13 @@ function renderPostPreview(event) {
105
108
  function renderTimelineItem(event) {
106
109
  const timestamp = eventTimestamp(event);
107
110
  const statusClass = event.status === 'confirmed' ? 'confirmed' : 'announced';
108
- const schedule = event.schedule?.label ? `<div class="schedule">时间:${escapeHtml(event.schedule.label)}</div>` : '';
111
+ const schedule = event.schedule?.label ? `<div class="schedule">时间:${escapeHtml(event.schedule.label.slice(0, 150))}</div>` : '';
109
112
  return `<article class="timeline-item">
110
113
  <div class="timeline-date"><strong>${escapeHtml(dateLabel(timestamp))}</strong><span>${escapeHtml(timeLabel(timestamp))}</span></div>
111
114
  <div class="timeline-dot ${statusClass}"></div>
112
115
  <div class="event-card">
113
116
  <div class="event-meta"><span class="type-label">${typeLabel(event)}</span><span class="status ${statusClass}">${statusLabel(event)}</span></div>
114
- <h2>${escapeHtml(event.title)}</h2>
117
+ <h2>${escapeHtml(event.title.slice(0, 200))}</h2>
115
118
  ${schedule}
116
119
  ${renderPostPreview(event)}
117
120
  </div>
package/lib/index.js CHANGED
@@ -3,12 +3,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Config = exports.inject = exports.name = void 0;
4
4
  exports.apply = apply;
5
5
  const koishi_1 = require("koishi");
6
+ const node_crypto_1 = require("node:crypto");
7
+ const promises_1 = require("node:fs/promises");
8
+ const node_path_1 = require("node:path");
6
9
  const calendar_1 = require("./calendar");
7
10
  const history_1 = require("./history");
8
11
  const event_1 = require("./event");
9
12
  const probability_1 = require("./probability");
10
13
  const stats_1 = require("./stats");
11
14
  exports.name = 'codex-reset';
15
+ const MAX_NOTIFICATION_AGE_MS = 48 * 60 * 60 * 1000;
16
+ const MAX_PENDING_AGE_MS = 48 * 60 * 60 * 1000;
17
+ const MAX_API_RESPONSE_BYTES = 4 * 1024 * 1024;
18
+ const MAX_HISTORY_EVENTS = 120;
19
+ const MAX_CONCURRENT_RENDERS = 2;
20
+ const RENDER_START_INTERVAL_MS = 3000;
21
+ class RenderBusyError extends Error {
22
+ }
12
23
  exports.inject = {
13
24
  optional: ['puppeteer'],
14
25
  };
@@ -125,36 +136,85 @@ function isSnapshot(value) {
125
136
  function eventSummary(event) {
126
137
  const kind = event.type === 'direct_reset' ? 'Codex 额度重置' : 'Codex 重置卡发放';
127
138
  const status = event.status === 'announced' ? '预告' : '已确认';
128
- const scope = event.scope ? `(范围:${event.scope})` : '';
129
- const schedule = event.schedule?.label ? `\n时间:${event.schedule.label}` : '';
130
- const source = event.posts[0]?.url ? `\n来源:${event.posts[0].url}` : '';
139
+ const scope = event.scope ? `(范围:${event.scope.slice(0, 200)})` : '';
140
+ let schedule = '';
141
+ if (event.status === 'announced' && event.schedule?.label) {
142
+ schedule = `\n预计时间:${event.schedule.label.slice(0, 200)}`;
143
+ }
144
+ else if (event.status === 'confirmed' && event.occurredOn) {
145
+ schedule = `\n发生日期:${event.occurredOn}(北京时间)`;
146
+ }
147
+ else if (event.status === 'confirmed' && event.confirmedAt) {
148
+ const confirmedAt = new Intl.DateTimeFormat('zh-CN', {
149
+ timeZone: 'Asia/Shanghai', dateStyle: 'medium', timeStyle: 'short',
150
+ }).format(new Date(event.confirmedAt));
151
+ schedule = `\n确认时间:${confirmedAt}`;
152
+ }
153
+ const source = event.posts[0]?.url ? `\n来源:${event.posts[0].url.slice(0, 500)}` : '';
131
154
  return `${kind}${status}${scope}${schedule}${source}`;
132
155
  }
133
- async function sendToTargets(ctx, targets, content) {
134
- let sent = 0;
135
- const sentBindings = new Set();
156
+ function stageKeys(event) {
157
+ return [`id:${event.status}:${event.id}`];
158
+ }
159
+ function isRecent(timestamp, now) {
160
+ return Number.isFinite(timestamp) && timestamp <= now + 5 * 60 * 1000 && now - timestamp <= MAX_NOTIFICATION_AGE_MS;
161
+ }
162
+ function isRecentEventDay(value, now) {
163
+ const timestamp = Date.parse(`${value}T12:00:00+08:00`);
164
+ // occurredOn has day precision. Today's noon may still be in the future
165
+ // when a morning confirmation arrives.
166
+ return Number.isFinite(timestamp) && timestamp <= now + 12 * 60 * 60 * 1000
167
+ && now - timestamp <= MAX_NOTIFICATION_AGE_MS;
168
+ }
169
+ function isFreshNotification(event, now, wasAnnounced) {
170
+ if (event.status === 'announced')
171
+ return isRecent(Date.parse(event.createdAt), now);
172
+ // A fresh confirmation can be attached to a much older reset. Never revive
173
+ // that historical event merely because the API filled in confirmedAt today.
174
+ if (event.occurredOn && !isRecentEventDay(event.occurredOn, now))
175
+ return false;
176
+ if (!event.occurredOn && event.schedule?.through
177
+ && now - Date.parse(event.schedule.through) > MAX_NOTIFICATION_AGE_MS)
178
+ return false;
179
+ if (event.confirmedAt)
180
+ return isRecent(Date.parse(event.confirmedAt), now);
181
+ if (event.occurredOn)
182
+ return isRecentEventDay(event.occurredOn, now);
183
+ return (wasAnnounced || isRecent(Date.parse(event.createdAt), now))
184
+ && isRecent(Date.parse(event.updatedAt), now);
185
+ }
186
+ async function sendToTargets(ctx, targets, content, deliveredBindings, onDelivered) {
187
+ const delivered = new Set(deliveredBindings);
188
+ let complete = true;
136
189
  for (const target of targets) {
137
190
  const channelId = target.channelId?.trim();
138
191
  if (!channelId)
139
192
  continue;
140
193
  const bots = [...ctx.bots].filter((bot) => !target.platform?.trim() || bot.platform === target.platform.trim());
141
- for (const bot of bots) {
142
- // A blank-platform target already covers explicit platform targets. Send only once
143
- // per platform/channel binding, while still trying another bot if the first fails.
144
- const key = `${bot.platform}\u0000${channelId}`;
145
- if (sentBindings.has(key))
194
+ if (!bots.length)
195
+ complete = false;
196
+ for (const platform of new Set(bots.map((bot) => bot.platform))) {
197
+ const key = `${platform}\u0000${channelId}`;
198
+ if (delivered.has(key))
146
199
  continue;
147
- try {
148
- await bot.sendMessage(channelId, content);
149
- sentBindings.add(key);
150
- sent++;
151
- }
152
- catch (error) {
153
- ctx.logger('codex-reset').warn(`发送到 ${bot.platform}:${target.channelId} 失败:${error instanceof Error ? error.message : String(error)}`);
200
+ for (const bot of bots.filter((candidate) => candidate.platform === platform)) {
201
+ try {
202
+ await bot.sendMessage(channelId, content);
203
+ }
204
+ catch (error) {
205
+ ctx.logger('codex-reset').warn(`发送到 ${bot.platform}:${target.channelId} 失败:${error instanceof Error ? error.message : String(error)}`);
206
+ continue;
207
+ }
208
+ delivered.add(key);
209
+ deliveredBindings.push(key);
210
+ await onDelivered();
211
+ break;
154
212
  }
213
+ if (!delivered.has(key))
214
+ complete = false;
155
215
  }
156
216
  }
157
- return sent;
217
+ return complete;
158
218
  }
159
219
  class CodexResetMonitor {
160
220
  ctx;
@@ -163,9 +223,21 @@ class CodexResetMonitor {
163
223
  etag = null;
164
224
  pollPromise = null;
165
225
  disposed = false;
226
+ statePath;
227
+ stateLoaded;
228
+ seenStages = new Set();
229
+ pending = new Map();
230
+ initialized = false;
231
+ activeRenders = 0;
232
+ nextRenderAllowedAt = 0;
166
233
  constructor(ctx, config) {
167
234
  this.ctx = ctx;
168
235
  this.config = config;
236
+ const instanceId = (0, node_crypto_1.createHash)('sha256')
237
+ .update(JSON.stringify({ apiUrl: config.apiUrl, targets: config.targets }))
238
+ .digest('hex').slice(0, 16);
239
+ this.statePath = (0, node_path_1.join)(ctx.baseDir, 'data', 'codex-reset', `${instanceId}.json`);
240
+ this.stateLoaded = this.loadState();
169
241
  }
170
242
  get currentSnapshot() {
171
243
  return this.snapshot;
@@ -196,16 +268,22 @@ class CodexResetMonitor {
196
268
  }
197
269
  async performPoll() {
198
270
  try {
271
+ await this.stateLoaded;
199
272
  const result = await this.fetchSnapshot();
200
- if (result.notModified || !result.snapshot)
201
- return;
202
- const previous = this.snapshot;
203
- this.snapshot = result.snapshot;
204
- this.etag = result.etag ?? this.etag;
205
- // The API returns a complete, mutable snapshot. The first fetch is a baseline.
206
- if (!previous)
207
- return;
208
- await this.notifyChanges(previous, result.snapshot);
273
+ if (!result.notModified && result.snapshot) {
274
+ this.snapshot = result.snapshot;
275
+ this.etag = result.etag ?? this.etag;
276
+ if (!this.initialized) {
277
+ // A fresh installation starts with the entire current snapshot as its baseline.
278
+ for (const event of result.snapshot.events)
279
+ this.markSeen(event);
280
+ this.initialized = true;
281
+ }
282
+ else {
283
+ this.captureChanges(result.snapshot, Date.now());
284
+ }
285
+ }
286
+ await this.flushPending();
209
287
  }
210
288
  catch (error) {
211
289
  this.ctx.logger('codex-reset').warn(`获取 AIHOT 重置事件失败:${error instanceof Error ? error.message : String(error)}`);
@@ -216,36 +294,149 @@ class CodexResetMonitor {
216
294
  const browser = this.ctx.puppeteer;
217
295
  if (!snapshot || !browser)
218
296
  return null;
219
- return (0, calendar_1.renderCalendarImage)(browser, snapshot.events);
297
+ return this.withRenderSlot(() => (0, calendar_1.renderCalendarImage)(browser, snapshot.events));
220
298
  }
221
299
  async createHistoryImage(events, now = new Date()) {
222
300
  const snapshot = await this.ensureSnapshot();
223
301
  const browser = this.ctx.puppeteer;
224
302
  if (!snapshot || !browser)
225
303
  return null;
226
- return (0, history_1.renderHistoryImage)(browser, events ?? snapshot.events, now);
304
+ return this.withRenderSlot(() => (0, history_1.renderHistoryImage)(browser, events ?? snapshot.events, now));
227
305
  }
228
306
  async createProbabilityImage(summary) {
229
307
  const browser = this.ctx.puppeteer;
230
308
  if (!browser)
231
309
  return null;
232
- return (0, probability_1.renderProbabilityImage)(browser, summary);
310
+ return this.withRenderSlot(() => (0, probability_1.renderProbabilityImage)(browser, summary));
233
311
  }
234
312
  async createLatestResetImage(summary, now = new Date()) {
235
313
  const browser = this.ctx.puppeteer;
236
314
  if (!browser)
237
315
  return null;
238
- return (0, event_1.renderLatestResetImage)(browser, summary, now);
316
+ return this.withRenderSlot(() => (0, event_1.renderLatestResetImage)(browser, summary, now));
317
+ }
318
+ async withRenderSlot(render) {
319
+ const now = Date.now();
320
+ if (this.activeRenders >= MAX_CONCURRENT_RENDERS || now < this.nextRenderAllowedAt) {
321
+ throw new RenderBusyError('图片生成任务较多');
322
+ }
323
+ this.nextRenderAllowedAt = now + RENDER_START_INTERVAL_MS;
324
+ this.activeRenders++;
325
+ try {
326
+ return await render();
327
+ }
328
+ finally {
329
+ this.activeRenders--;
330
+ }
331
+ }
332
+ async loadState() {
333
+ try {
334
+ const state = JSON.parse(await (0, promises_1.readFile)(this.statePath, 'utf8'));
335
+ if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.seenStages) || !Array.isArray(state.pending)) {
336
+ throw new Error('状态文件格式无效');
337
+ }
338
+ this.initialized = state.initialized === true;
339
+ for (const key of state.seenStages) {
340
+ if (typeof key === 'string')
341
+ this.seenStages.add(key);
342
+ }
343
+ for (const item of state.pending) {
344
+ if (!isRecord(item) || typeof item.key !== 'string' || typeof item.message !== 'string'
345
+ || typeof item.createdAt !== 'number' || !Array.isArray(item.deliveredBindings))
346
+ continue;
347
+ this.pending.set(item.key, {
348
+ key: item.key,
349
+ message: item.message,
350
+ createdAt: item.createdAt,
351
+ deliveredBindings: item.deliveredBindings.filter((key) => typeof key === 'string'),
352
+ });
353
+ }
354
+ }
355
+ catch (error) {
356
+ if (isRecord(error) && error.code === 'ENOENT')
357
+ return;
358
+ this.ctx.logger('codex-reset').warn(`读取推送状态失败,将重新建立基线:${error instanceof Error ? error.message : String(error)}`);
359
+ }
239
360
  }
240
- async fetchSnapshot(ignoreEtag = false) {
361
+ async saveState() {
362
+ const state = {
363
+ version: 1,
364
+ initialized: this.initialized,
365
+ seenStages: [...this.seenStages],
366
+ pending: [...this.pending.values()],
367
+ };
368
+ const temporaryPath = `${this.statePath}.${process.pid}.tmp`;
369
+ try {
370
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.statePath), { recursive: true });
371
+ await (0, promises_1.writeFile)(temporaryPath, JSON.stringify(state), 'utf8');
372
+ await (0, promises_1.rename)(temporaryPath, this.statePath);
373
+ }
374
+ catch (error) {
375
+ this.ctx.logger('codex-reset').warn(`保存推送状态失败:${error instanceof Error ? error.message : String(error)}`);
376
+ throw error;
377
+ }
378
+ }
379
+ markSeen(event) {
380
+ for (const key of stageKeys(event))
381
+ this.seenStages.add(key);
382
+ }
383
+ captureChanges(current, now) {
384
+ for (const event of current.events) {
385
+ const keys = stageKeys(event);
386
+ const alreadySeen = keys.some((key) => this.seenStages.has(key));
387
+ const wasAnnounced = this.seenStages.has(`id:announced:${event.id}`);
388
+ this.markSeen(event);
389
+ if (alreadySeen || !isFreshNotification(event, now, wasAnnounced))
390
+ continue;
391
+ if (event.status === 'announced' && !this.config.pushAnnouncements)
392
+ continue;
393
+ if (event.status === 'confirmed' && !this.config.pushConfirmedResets)
394
+ continue;
395
+ const message = event.status === 'announced'
396
+ ? `📢 Codex 重置预告\n${eventSummary(event)}`
397
+ : `✅ Codex 重置事件\n${eventSummary(event)}`;
398
+ if (!this.config.targets.length) {
399
+ this.ctx.logger('codex-reset').info(`检测到新事件,但未配置推送目标:\n${message}`);
400
+ continue;
401
+ }
402
+ const key = keys[0];
403
+ this.pending.set(key, { key, message, createdAt: now, deliveredBindings: [] });
404
+ }
405
+ }
406
+ async flushPending() {
407
+ // Persist the observed stages and queue before sending. This also repairs
408
+ // a previous write failure when the next API response is 304.
409
+ await this.saveState();
410
+ let changed = false;
411
+ for (const [key, item] of this.pending) {
412
+ if (Date.now() - item.createdAt > MAX_PENDING_AGE_MS) {
413
+ this.ctx.logger('codex-reset').warn(`推送超过 48 小时仍未完成,已放弃:${key}`);
414
+ this.pending.delete(key);
415
+ changed = true;
416
+ continue;
417
+ }
418
+ const content = this.config.mentionAll
419
+ ? [(0, koishi_1.h)('at', { type: 'all' }), koishi_1.h.text(`\n${item.message}`)]
420
+ : item.message;
421
+ const deliveredCount = item.deliveredBindings.length;
422
+ const complete = await sendToTargets(this.ctx, this.config.targets, content, item.deliveredBindings, () => this.saveState());
423
+ if (complete)
424
+ this.pending.delete(key);
425
+ if (complete || deliveredCount !== item.deliveredBindings.length)
426
+ changed = true;
427
+ }
428
+ if (changed)
429
+ await this.saveState();
430
+ }
431
+ async fetchSnapshot() {
241
432
  const controller = new AbortController();
242
433
  const timeout = setTimeout(() => controller.abort(), this.config.requestTimeout);
243
434
  try {
244
435
  const headers = {
245
436
  Accept: 'application/json',
246
- 'User-Agent': 'koishi-plugin-codex-reset/0.1.3',
437
+ 'User-Agent': 'koishi-plugin-codex-reset/0.1.5',
247
438
  };
248
- if (!ignoreEtag && this.etag && this.snapshot)
439
+ if (this.etag && this.snapshot)
249
440
  headers['If-None-Match'] = this.etag;
250
441
  const response = await fetch(this.config.apiUrl, {
251
442
  method: 'GET',
@@ -256,12 +447,29 @@ class CodexResetMonitor {
256
447
  return { snapshot: null, etag: this.etag, notModified: true };
257
448
  }
258
449
  if (response.status === 304)
259
- return this.fetchSnapshot(true);
260
- if (!response.ok) {
261
- const detail = (await response.text()).slice(0, 300).replace(/\s+/g, ' ');
262
- throw new Error(`HTTP ${response.status}${detail ? `:${detail}` : ''}`);
450
+ throw new Error('服务端在没有本地快照时返回了 304');
451
+ if (!response.ok)
452
+ throw new Error(`HTTP ${response.status}`);
453
+ const declaredLength = Number(response.headers.get('content-length'));
454
+ if (declaredLength > MAX_API_RESPONSE_BYTES)
455
+ throw new Error('API 响应超过 4 MiB');
456
+ if (!response.body)
457
+ throw new Error('API 响应没有内容');
458
+ const reader = response.body.getReader();
459
+ const chunks = [];
460
+ let size = 0;
461
+ while (true) {
462
+ const { done, value } = await reader.read();
463
+ if (done)
464
+ break;
465
+ size += value.byteLength;
466
+ if (size > MAX_API_RESPONSE_BYTES) {
467
+ await reader.cancel();
468
+ throw new Error('API 响应超过 4 MiB');
469
+ }
470
+ chunks.push(value);
263
471
  }
264
- const body = await response.json();
472
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
265
473
  if (!isSnapshot(body))
266
474
  throw new Error('响应格式不符合 Codex 重置 API v1');
267
475
  return {
@@ -274,29 +482,6 @@ class CodexResetMonitor {
274
482
  clearTimeout(timeout);
275
483
  }
276
484
  }
277
- async notifyChanges(previous, current) {
278
- const previousById = new Map(previous.events.map((event) => [event.id, event]));
279
- const messages = [];
280
- for (const event of current.events) {
281
- const oldEvent = previousById.get(event.id);
282
- if (event.status === 'announced' && this.config.pushAnnouncements && !oldEvent) {
283
- messages.push(`📢 Codex 重置预告\n${eventSummary(event)}`);
284
- }
285
- if (event.status === 'confirmed' && this.config.pushConfirmedResets && (!oldEvent || oldEvent.status === 'announced')) {
286
- messages.push(`✅ Codex 重置事件\n${eventSummary(event)}`);
287
- }
288
- }
289
- for (const message of messages) {
290
- if (!this.config.targets.length) {
291
- this.ctx.logger('codex-reset').info(`检测到新事件,但未配置推送目标:\n${message}`);
292
- continue;
293
- }
294
- const content = this.config.mentionAll
295
- ? [(0, koishi_1.h)('at', { type: 'all' }), koishi_1.h.text(`\n${message}`)]
296
- : message;
297
- await sendToTargets(this.ctx, this.config.targets, content);
298
- }
299
- }
300
485
  }
301
486
  function apply(ctx, config) {
302
487
  const monitor = new CodexResetMonitor(ctx, config);
@@ -321,6 +506,8 @@ function apply(ctx, config) {
321
506
  return koishi_1.h.image(image, 'image/png');
322
507
  }
323
508
  catch (error) {
509
+ if (error instanceof RenderBusyError)
510
+ return (0, stats_1.formatProbability)(summary);
324
511
  ctx.logger('codex-reset').warn(`生成概率卡片失败:${error instanceof Error ? error.message : String(error)}`);
325
512
  return (0, stats_1.formatProbability)(summary);
326
513
  }
@@ -344,6 +531,8 @@ function apply(ctx, config) {
344
531
  return koishi_1.h.image(image, 'image/png');
345
532
  }
346
533
  catch (error) {
534
+ if (error instanceof RenderBusyError)
535
+ return (0, stats_1.formatLatestReset)(snapshot, now);
347
536
  ctx.logger('codex-reset').warn(`生成最近重置卡片失败:${error instanceof Error ? error.message : String(error)}`);
348
537
  return (0, stats_1.formatLatestReset)(snapshot, now);
349
538
  }
@@ -360,6 +549,8 @@ function apply(ctx, config) {
360
549
  return koishi_1.h.image(image, 'image/png');
361
550
  }
362
551
  catch (error) {
552
+ if (error instanceof RenderBusyError)
553
+ return '图片生成任务较多,请稍后再试。';
363
554
  ctx.logger('codex-reset').warn(`生成日历图片失败:${error instanceof Error ? error.message : String(error)}`);
364
555
  return '日历图片生成失败,请检查 Puppeteer 服务和中文字体。';
365
556
  }
@@ -379,12 +570,16 @@ function apply(ctx, config) {
379
570
  ? 'all'
380
571
  : Math.max(1, Math.min(120, Math.floor(Math.abs(range ?? 1))));
381
572
  const events = (0, history_1.filterHistoryEvents)(snapshot.events, now, historyRange);
573
+ if (events.length > MAX_HISTORY_EVENTS)
574
+ return `历史事件超过 ${MAX_HISTORY_EVENTS} 条,请缩小查询的月份范围。`;
382
575
  const image = await monitor.createHistoryImage(events, now);
383
576
  if (!image)
384
577
  return '暂时无法生成历史时间线图片,请稍后再试。';
385
578
  return koishi_1.h.image(image, 'image/png');
386
579
  }
387
580
  catch (error) {
581
+ if (error instanceof RenderBusyError)
582
+ return '图片生成任务较多,请稍后再试。';
388
583
  ctx.logger('codex-reset').warn(`生成历史时间线图片失败:${error instanceof Error ? error.message : String(error)}`);
389
584
  return '历史时间线图片生成失败,请检查 Puppeteer 服务和中文字体。';
390
585
  }
package/lib/stats.js CHANGED
@@ -54,16 +54,16 @@ function percentage(value) {
54
54
  return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
55
55
  }
56
56
  function eventTimestamp(event) {
57
- if (event.confirmedAt) {
58
- const timestamp = Date.parse(event.confirmedAt);
59
- if (Number.isFinite(timestamp))
60
- return { timestamp, approximate: false };
61
- }
62
57
  if (event.occurredOn) {
63
58
  const timestamp = Date.parse(`${event.occurredOn}T12:00:00+08:00`);
64
59
  if (Number.isFinite(timestamp))
65
60
  return { timestamp, approximate: true };
66
61
  }
62
+ if (event.confirmedAt) {
63
+ const timestamp = Date.parse(event.confirmedAt);
64
+ if (Number.isFinite(timestamp))
65
+ return { timestamp, approximate: false };
66
+ }
67
67
  for (const value of [event.schedule?.from, event.createdAt]) {
68
68
  const timestamp = parseTimestamp(value);
69
69
  if (timestamp !== null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-codex-reset",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Monitor AIHOT Codex reset events and notify Koishi channels.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",