koishi-plugin-codex-reset 0.1.3 → 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/LICENSE +21 -21
- package/README.md +58 -54
- package/lib/calendar.js +105 -104
- package/lib/event.js +77 -76
- package/lib/history.js +98 -95
- package/lib/index.js +258 -63
- package/lib/probability.js +90 -90
- package/lib/stats.js +5 -5
- package/package.json +51 -51
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const key = `${
|
|
145
|
-
if (
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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.
|
|
437
|
+
'User-Agent': 'koishi-plugin-codex-reset/0.1.5',
|
|
247
438
|
};
|
|
248
|
-
if (
|
|
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
|
-
|
|
260
|
-
if (!response.ok)
|
|
261
|
-
|
|
262
|
-
|
|
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 =
|
|
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/probability.js
CHANGED
|
@@ -34,10 +34,10 @@ function formatGeneratedAt(now) {
|
|
|
34
34
|
}).format(now);
|
|
35
35
|
}
|
|
36
36
|
function metricCard(label, value, note, tone) {
|
|
37
|
-
return `<div class="metric ${tone}">
|
|
38
|
-
<div class="metric-label">${escapeHtml(label)}</div>
|
|
39
|
-
<div class="metric-value">${escapeHtml(value)}</div>
|
|
40
|
-
<div class="metric-note">${escapeHtml(note)}</div>
|
|
37
|
+
return `<div class="metric ${tone}">
|
|
38
|
+
<div class="metric-label">${escapeHtml(label)}</div>
|
|
39
|
+
<div class="metric-value">${escapeHtml(value)}</div>
|
|
40
|
+
<div class="metric-note">${escapeHtml(note)}</div>
|
|
41
41
|
</div>`;
|
|
42
42
|
}
|
|
43
43
|
function renderProbabilityHtml(summary, now = new Date()) {
|
|
@@ -45,92 +45,92 @@ function renderProbabilityHtml(summary, now = new Date()) {
|
|
|
45
45
|
const directRatio = ratio(summary.directResetCount, confirmedTotal);
|
|
46
46
|
const creditRatio = ratio(summary.resetCreditCount, confirmedTotal);
|
|
47
47
|
const recentDate = summary.lastConfirmedDate ?? '暂无记录';
|
|
48
|
-
return `<!doctype html>
|
|
49
|
-
<html lang="zh-CN">
|
|
50
|
-
<head>
|
|
51
|
-
<meta charset="utf-8">
|
|
52
|
-
<style>
|
|
53
|
-
* { box-sizing: border-box; }
|
|
54
|
-
html, body { margin: 0; padding: 0; background: #f4f6f5; color: #27343c; font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|
55
|
-
body { padding: 26px; }
|
|
56
|
-
.card { position: relative; width: 1120px; min-height: 680px; overflow: hidden; padding: 38px 44px 30px; border: 1px solid #d2dadd; border-radius: 16px; background: radial-gradient(circle at 82% 12%, rgba(8, 118, 139, .07), transparent 30%), radial-gradient(circle at 10% 96%, rgba(184, 134, 59, .07), transparent 28%), #fff; box-shadow: 0 16px 38px rgba(42, 58, 64, .10); }
|
|
57
|
-
.card::before { content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .32; background-image: linear-gradient(rgba(8, 118, 139, .05) 1px, transparent 1px), linear-gradient(90deg, rgba(8, 118, 139, .05) 1px, transparent 1px); background-size: 34px 34px; mask-image: linear-gradient(to bottom, rgba(0,0,0,.42), transparent 72%); }
|
|
58
|
-
.content { position: relative; z-index: 1; }
|
|
59
|
-
.topline { display: flex; align-items: flex-start; justify-content: space-between; }
|
|
60
|
-
.eyebrow { color: #08768b; font-size: 11px; font-weight: 700; letter-spacing: .22em; }
|
|
61
|
-
.source { color: #6f7d83; font-size: 11px; line-height: 1.6; text-align: right; }
|
|
62
|
-
h1 { margin: 22px 0 8px; color: #27343c; font-size: 34px; font-weight: 700; letter-spacing: -.04em; }
|
|
63
|
-
.subtitle { margin: 0; color: #6f7d83; font-size: 14px; }
|
|
64
|
-
.rule { width: 100%; height: 1px; margin: 26px 0 22px; background: linear-gradient(90deg, rgba(8, 118, 139, .36), rgba(8, 118, 139, .08), transparent); }
|
|
65
|
-
.metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
66
|
-
.metric { position: relative; min-height: 186px; padding: 22px 24px; overflow: hidden; border: 1px solid #c8dedd; border-radius: 13px; background: #eef7f6; }
|
|
67
|
-
.metric::after { content: ""; position: absolute; right: -28px; bottom: -58px; width: 180px; height: 180px; border: 1px solid rgba(8, 118, 139, .18); border-radius: 50%; box-shadow: 0 0 0 18px rgba(8, 118, 139, .035), 0 0 0 36px rgba(8, 118, 139, .018); }
|
|
68
|
-
.metric.seven { background: #faf6ee; border-color: #e2d8c5; }
|
|
69
|
-
.metric.seven::after { border-color: rgba(184, 134, 59, .22); box-shadow: 0 0 0 18px rgba(184, 134, 59, .035), 0 0 0 36px rgba(184, 134, 59, .018); }
|
|
70
|
-
.metric-label { color: #6f7d83; font-size: 13px; }
|
|
71
|
-
.metric-value { margin-top: 7px; color: #08768b; font-family: Georgia, "Times New Roman", serif; font-size: 66px; font-weight: 700; letter-spacing: -.07em; line-height: 1; }
|
|
72
|
-
.metric.seven .metric-value { color: #a27831; }
|
|
73
|
-
.metric-note { margin-top: 14px; color: #52646a; font-size: 12px; }
|
|
74
|
-
.lower { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; margin-top: 16px; }
|
|
75
|
-
.panel { padding: 18px 20px; border: 1px solid #d2dadd; border-radius: 13px; background: #fbfcfc; }
|
|
76
|
-
.panel h2 { margin: 0 0 15px; color: #27343c; font-size: 14px; }
|
|
77
|
-
.sample-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
78
|
-
.sample { padding-right: 10px; border-right: 1px solid #e0e5e6; }
|
|
79
|
-
.sample:last-child { border-right: 0; }
|
|
80
|
-
.sample-value { color: #27343c; font-size: 22px; font-weight: 700; }
|
|
81
|
-
.sample-label { margin-top: 4px; color: #6f7d83; font-size: 11px; }
|
|
82
|
-
.bar-row { display: grid; grid-template-columns: 76px 1fr 42px; align-items: center; gap: 9px; margin: 12px 0; color: #52646a; font-size: 11px; }
|
|
83
|
-
.bar { height: 7px; overflow: hidden; border-radius: 99px; background: #e4ecec; }
|
|
84
|
-
.bar span { display: block; height: 100%; border-radius: inherit; background: #71c8bd; }
|
|
85
|
-
.bar-row.credit .bar span { background: #d8ad5f; }
|
|
86
|
-
.bar-percent { color: #27343c; text-align: right; }
|
|
87
|
-
.recent { display: flex; align-items: center; justify-content: space-between; padding-top: 15px; margin-top: 16px; border-top: 1px solid #e0e5e6; }
|
|
88
|
-
.recent-label { color: #6f7d83; font-size: 11px; }
|
|
89
|
-
.recent-date { color: #a27831; font-size: 15px; font-weight: 700; }
|
|
90
|
-
.footer { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-top: 22px; color: #7a888d; font-size: 10px; line-height: 1.65; }
|
|
91
|
-
.footer strong { color: #52646a; font-weight: 600; }
|
|
92
|
-
.mark { color: #08768b; font-size: 11px; font-weight: 700; letter-spacing: .08em; white-space: nowrap; }
|
|
93
|
-
</style>
|
|
94
|
-
</head>
|
|
95
|
-
<body>
|
|
96
|
-
<main class="card">
|
|
97
|
-
<div class="content">
|
|
98
|
-
<header class="topline">
|
|
99
|
-
<div class="eyebrow">CODEX RESET / PUBLIC HISTORY</div>
|
|
100
|
-
<div class="source">AIHOT · ASIA/SHANGHAI<br>生成于 ${escapeHtml(formatGeneratedAt(now))}</div>
|
|
101
|
-
</header>
|
|
102
|
-
<h1>Codex 重置概率分析</h1>
|
|
103
|
-
<p class="subtitle">基于当前公开历史快照的经验统计,不是官方预测</p>
|
|
104
|
-
<div class="rule"></div>
|
|
105
|
-
|
|
106
|
-
<section class="metrics">
|
|
107
|
-
${metricCard('未来 24 小时', percentage(summary.next24hProbability), '至少出现一个公开重置事件', 'day')}
|
|
108
|
-
${metricCard('未来 7 天', percentage(summary.next7dProbability), '至少出现一个公开重置事件', 'seven')}
|
|
109
|
-
</section>
|
|
110
|
-
|
|
111
|
-
<section class="lower">
|
|
112
|
-
<div class="panel">
|
|
113
|
-
<h2>样本概览</h2>
|
|
114
|
-
<div class="sample-grid">
|
|
115
|
-
<div class="sample"><div class="sample-value">${summary.observedDays}</div><div class="sample-label">天观察样本</div></div>
|
|
116
|
-
<div class="sample"><div class="sample-value">${summary.confirmedCount}</div><div class="sample-label">条已确认</div></div>
|
|
117
|
-
<div class="sample"><div class="sample-value">${summary.activeDays}</div><div class="sample-label">个事件日</div></div>
|
|
118
|
-
<div class="sample"><div class="sample-value">${summary.announcedCount}</div><div class="sample-label">条未重置预告</div></div>
|
|
119
|
-
</div>
|
|
120
|
-
<div class="recent"><span class="recent-label">最近确认日期</span><span class="recent-date">${escapeHtml(recentDate)}</span></div>
|
|
121
|
-
</div>
|
|
122
|
-
<div class="panel">
|
|
123
|
-
<h2>已确认事件构成</h2>
|
|
124
|
-
<div class="bar-row"><span>全员重置</span><div class="bar"><span style="width:${directRatio}"></span></div><span class="bar-percent">${summary.directResetCount}</span></div>
|
|
125
|
-
<div class="bar-row credit"><span>重置卡</span><div class="bar"><span style="width:${creditRatio}"></span></div><span class="bar-percent">${summary.resetCreditCount}</span></div>
|
|
126
|
-
<div class="recent"><span class="recent-label">历史事件日率</span><span class="recent-date">${(summary.dailyEventDayRate * 100).toFixed(2)}% / 天</span></div>
|
|
127
|
-
</div>
|
|
128
|
-
</section>
|
|
129
|
-
|
|
130
|
-
<footer class="footer"><span><strong>说明:</strong>概率由历史事件日率进行经验估计,不代表你的账户一定会获得重置。</span><span class="mark">RESET MONITOR</span></footer>
|
|
131
|
-
</div>
|
|
132
|
-
</main>
|
|
133
|
-
</body>
|
|
48
|
+
return `<!doctype html>
|
|
49
|
+
<html lang="zh-CN">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8">
|
|
52
|
+
<style>
|
|
53
|
+
* { box-sizing: border-box; }
|
|
54
|
+
html, body { margin: 0; padding: 0; background: #f4f6f5; color: #27343c; font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|
55
|
+
body { padding: 26px; }
|
|
56
|
+
.card { position: relative; width: 1120px; min-height: 680px; overflow: hidden; padding: 38px 44px 30px; border: 1px solid #d2dadd; border-radius: 16px; background: radial-gradient(circle at 82% 12%, rgba(8, 118, 139, .07), transparent 30%), radial-gradient(circle at 10% 96%, rgba(184, 134, 59, .07), transparent 28%), #fff; box-shadow: 0 16px 38px rgba(42, 58, 64, .10); }
|
|
57
|
+
.card::before { content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .32; background-image: linear-gradient(rgba(8, 118, 139, .05) 1px, transparent 1px), linear-gradient(90deg, rgba(8, 118, 139, .05) 1px, transparent 1px); background-size: 34px 34px; mask-image: linear-gradient(to bottom, rgba(0,0,0,.42), transparent 72%); }
|
|
58
|
+
.content { position: relative; z-index: 1; }
|
|
59
|
+
.topline { display: flex; align-items: flex-start; justify-content: space-between; }
|
|
60
|
+
.eyebrow { color: #08768b; font-size: 11px; font-weight: 700; letter-spacing: .22em; }
|
|
61
|
+
.source { color: #6f7d83; font-size: 11px; line-height: 1.6; text-align: right; }
|
|
62
|
+
h1 { margin: 22px 0 8px; color: #27343c; font-size: 34px; font-weight: 700; letter-spacing: -.04em; }
|
|
63
|
+
.subtitle { margin: 0; color: #6f7d83; font-size: 14px; }
|
|
64
|
+
.rule { width: 100%; height: 1px; margin: 26px 0 22px; background: linear-gradient(90deg, rgba(8, 118, 139, .36), rgba(8, 118, 139, .08), transparent); }
|
|
65
|
+
.metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
66
|
+
.metric { position: relative; min-height: 186px; padding: 22px 24px; overflow: hidden; border: 1px solid #c8dedd; border-radius: 13px; background: #eef7f6; }
|
|
67
|
+
.metric::after { content: ""; position: absolute; right: -28px; bottom: -58px; width: 180px; height: 180px; border: 1px solid rgba(8, 118, 139, .18); border-radius: 50%; box-shadow: 0 0 0 18px rgba(8, 118, 139, .035), 0 0 0 36px rgba(8, 118, 139, .018); }
|
|
68
|
+
.metric.seven { background: #faf6ee; border-color: #e2d8c5; }
|
|
69
|
+
.metric.seven::after { border-color: rgba(184, 134, 59, .22); box-shadow: 0 0 0 18px rgba(184, 134, 59, .035), 0 0 0 36px rgba(184, 134, 59, .018); }
|
|
70
|
+
.metric-label { color: #6f7d83; font-size: 13px; }
|
|
71
|
+
.metric-value { margin-top: 7px; color: #08768b; font-family: Georgia, "Times New Roman", serif; font-size: 66px; font-weight: 700; letter-spacing: -.07em; line-height: 1; }
|
|
72
|
+
.metric.seven .metric-value { color: #a27831; }
|
|
73
|
+
.metric-note { margin-top: 14px; color: #52646a; font-size: 12px; }
|
|
74
|
+
.lower { display: grid; grid-template-columns: 1.15fr .85fr; gap: 16px; margin-top: 16px; }
|
|
75
|
+
.panel { padding: 18px 20px; border: 1px solid #d2dadd; border-radius: 13px; background: #fbfcfc; }
|
|
76
|
+
.panel h2 { margin: 0 0 15px; color: #27343c; font-size: 14px; }
|
|
77
|
+
.sample-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
|
78
|
+
.sample { padding-right: 10px; border-right: 1px solid #e0e5e6; }
|
|
79
|
+
.sample:last-child { border-right: 0; }
|
|
80
|
+
.sample-value { color: #27343c; font-size: 22px; font-weight: 700; }
|
|
81
|
+
.sample-label { margin-top: 4px; color: #6f7d83; font-size: 11px; }
|
|
82
|
+
.bar-row { display: grid; grid-template-columns: 76px 1fr 42px; align-items: center; gap: 9px; margin: 12px 0; color: #52646a; font-size: 11px; }
|
|
83
|
+
.bar { height: 7px; overflow: hidden; border-radius: 99px; background: #e4ecec; }
|
|
84
|
+
.bar span { display: block; height: 100%; border-radius: inherit; background: #71c8bd; }
|
|
85
|
+
.bar-row.credit .bar span { background: #d8ad5f; }
|
|
86
|
+
.bar-percent { color: #27343c; text-align: right; }
|
|
87
|
+
.recent { display: flex; align-items: center; justify-content: space-between; padding-top: 15px; margin-top: 16px; border-top: 1px solid #e0e5e6; }
|
|
88
|
+
.recent-label { color: #6f7d83; font-size: 11px; }
|
|
89
|
+
.recent-date { color: #a27831; font-size: 15px; font-weight: 700; }
|
|
90
|
+
.footer { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; margin-top: 22px; color: #7a888d; font-size: 10px; line-height: 1.65; }
|
|
91
|
+
.footer strong { color: #52646a; font-weight: 600; }
|
|
92
|
+
.mark { color: #08768b; font-size: 11px; font-weight: 700; letter-spacing: .08em; white-space: nowrap; }
|
|
93
|
+
</style>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<main class="card">
|
|
97
|
+
<div class="content">
|
|
98
|
+
<header class="topline">
|
|
99
|
+
<div class="eyebrow">CODEX RESET / PUBLIC HISTORY</div>
|
|
100
|
+
<div class="source">AIHOT · ASIA/SHANGHAI<br>生成于 ${escapeHtml(formatGeneratedAt(now))}</div>
|
|
101
|
+
</header>
|
|
102
|
+
<h1>Codex 重置概率分析</h1>
|
|
103
|
+
<p class="subtitle">基于当前公开历史快照的经验统计,不是官方预测</p>
|
|
104
|
+
<div class="rule"></div>
|
|
105
|
+
|
|
106
|
+
<section class="metrics">
|
|
107
|
+
${metricCard('未来 24 小时', percentage(summary.next24hProbability), '至少出现一个公开重置事件', 'day')}
|
|
108
|
+
${metricCard('未来 7 天', percentage(summary.next7dProbability), '至少出现一个公开重置事件', 'seven')}
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section class="lower">
|
|
112
|
+
<div class="panel">
|
|
113
|
+
<h2>样本概览</h2>
|
|
114
|
+
<div class="sample-grid">
|
|
115
|
+
<div class="sample"><div class="sample-value">${summary.observedDays}</div><div class="sample-label">天观察样本</div></div>
|
|
116
|
+
<div class="sample"><div class="sample-value">${summary.confirmedCount}</div><div class="sample-label">条已确认</div></div>
|
|
117
|
+
<div class="sample"><div class="sample-value">${summary.activeDays}</div><div class="sample-label">个事件日</div></div>
|
|
118
|
+
<div class="sample"><div class="sample-value">${summary.announcedCount}</div><div class="sample-label">条未重置预告</div></div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="recent"><span class="recent-label">最近确认日期</span><span class="recent-date">${escapeHtml(recentDate)}</span></div>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="panel">
|
|
123
|
+
<h2>已确认事件构成</h2>
|
|
124
|
+
<div class="bar-row"><span>全员重置</span><div class="bar"><span style="width:${directRatio}"></span></div><span class="bar-percent">${summary.directResetCount}</span></div>
|
|
125
|
+
<div class="bar-row credit"><span>重置卡</span><div class="bar"><span style="width:${creditRatio}"></span></div><span class="bar-percent">${summary.resetCreditCount}</span></div>
|
|
126
|
+
<div class="recent"><span class="recent-label">历史事件日率</span><span class="recent-date">${(summary.dailyEventDayRate * 100).toFixed(2)}% / 天</span></div>
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
|
|
130
|
+
<footer class="footer"><span><strong>说明:</strong>概率由历史事件日率进行经验估计,不代表你的账户一定会获得重置。</span><span class="mark">RESET MONITOR</span></footer>
|
|
131
|
+
</div>
|
|
132
|
+
</main>
|
|
133
|
+
</body>
|
|
134
134
|
</html>`;
|
|
135
135
|
}
|
|
136
136
|
async function renderProbabilityImage(browser, summary, now = new Date()) {
|