koishi-plugin-codex-reset 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/lib/cache.d.ts +36 -0
- package/lib/cache.js +108 -0
- package/lib/calendar.js +2 -2
- package/lib/event.js +1 -1
- package/lib/history.js +1 -1
- package/lib/index.d.ts +2 -0
- package/lib/index.js +127 -51
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -24,11 +24,17 @@
|
|
|
24
24
|
|
|
25
25
|
概率、最近重置、日历和历史时间线命令始终启用;安装并启用 `koishi-plugin-puppeteer` 后会发送对应图片,没有 Puppeteer 时概率和最近重置会回退为纯文本,日历和历史时间线会提示安装 Puppeteer。
|
|
26
26
|
|
|
27
|
-
日历、月度统计及右侧动态仅展示已确认且日期不晚于今天的事件,不展示预告。日期优先使用实际发生日期,缺失时使用北京时间的确认日期;两者均缺失时不展示,不使用预计时间窗口代替实际日期。
|
|
28
|
-
|
|
29
|
-
首次安装时的第一份快照只建立基线,不会把历史事件全部推送。插件将已见事件状态和待发送通知保存在 Koishi 工作目录的 `data/codex-reset/` 中;重启后继续去重并重试未成功的目标。API 暂时漏掉旧事件、随后又补回时,也不会把它再次当作新事件。只有最近 48 小时内发布的预告或确认事件才会新建推送;超过 48 小时仍未送达的通知会放弃。更改 API 地址或推送目标后会建立新的基线。
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
日历、月度统计及右侧动态仅展示已确认且日期不晚于今天的事件,不展示预告。日期优先使用实际发生日期,缺失时使用北京时间的确认日期;两者均缺失时不展示,不使用预计时间窗口代替实际日期。
|
|
28
|
+
|
|
29
|
+
首次安装时的第一份快照只建立基线,不会把历史事件全部推送。插件将已见事件状态和待发送通知保存在 Koishi 工作目录的 `data/codex-reset/` 中;重启后继续去重并重试未成功的目标。API 暂时漏掉旧事件、随后又补回时,也不会把它再次当作新事件。只有最近 48 小时内发布的预告或确认事件才会新建推送;超过 48 小时仍未送达的通知会放弃。更改 API 地址或推送目标后会建立新的基线。
|
|
30
|
+
|
|
31
|
+
从 0.1.6 起,需要启用 Koishi 数据库服务(例如 SQLite、MySQL 或 PostgreSQL)。完整 API 快照、ETag、最近同步时间和下次允许请求的时间保存在 `codex_reset_cache` 表中,相同 API 地址共用一份记录。所有查询命令只读数据库,不直接请求上游;首次同步尚未成功时会提示稍后再试。重启、重载插件后仍可查询已保存的数据,并继续遵守冷却时间。同步失败时保留上次成功的数据,可能暂时不是最新状态。
|
|
32
|
+
|
|
33
|
+
插件启动时立即检查并尝试同步,无需先等待 11 分钟;如果数据库中仍有未结束的冷却时间,则等待剩余时间后同步。从 0.1.7 起,首次同步期间的查询会等待正在进行的那次请求,不额外发请求。尚无快照且处于冷却时,命令会显示剩余等待秒数;启动日志也会说明当前数据和下次同步时间。同步按已保存的截止时间调度,重启不会重新等满一个轮询周期。
|
|
34
|
+
|
|
35
|
+
后台默认每 660 秒同步一次;旧配置小于 660 秒时,实际请求也不会快于 660 秒。请求前先保存冷却时间,收到 HTTP 429 或 503 时遵循 `Retry-After`(也支持响应正文的 `retryAfter` 秒数),冷却期间不会因重启或手动查询再次请求。数据库写入失败时不会继续发起新的上游请求。
|
|
36
|
+
|
|
37
|
+
图片生成最多同时进行 2 项,新的图片任务至少间隔 3 秒;历史图片超过 120 条事件时会提示缩小查询范围。图片中的长篇来源文字会截短。API 响应上限为 4 MiB。
|
|
32
38
|
|
|
33
39
|
## 安装
|
|
34
40
|
|
|
@@ -36,12 +42,12 @@
|
|
|
36
42
|
npm install koishi-plugin-codex-reset
|
|
37
43
|
```
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
先在 Koishi 中启用一个数据库插件,再启用本插件;如果已经使用数据库服务,无需另建数据库。如果需要概率卡片、最近重置卡片、日历或历史时间线图片,再安装并启用 `koishi-plugin-puppeteer`。不安装 Puppeteer 时,监控、最近重置和概率分析仍然可以使用,概率与最近重置命令回退为纯文本,日历和历史命令会给出提示。
|
|
40
46
|
|
|
41
47
|
## 配置
|
|
42
48
|
|
|
43
49
|
- `监控接口地址`:监控 API 地址,默认 `https://aihot.news/api/v1/codex-resets`。
|
|
44
|
-
- `监控间隔`:轮询间隔,单位为秒,默认
|
|
50
|
+
- `监控间隔`:轮询间隔,单位为秒,默认 660 秒(11 分钟)。实际请求间隔至少 660 秒,并遵守服务端更长的缓存及重试等待时间。
|
|
45
51
|
- `请求超时时间`:单次 API 请求超时时间,单位为毫秒,默认 15000 毫秒。
|
|
46
52
|
- `推送目标`:推送目标表,填写“平台”和“频道或群组 ID”;平台留空会按频道尝试在线 Bot,同一平台/频道只发送一次。
|
|
47
53
|
- `推送重置预告`:是否推送预告。
|
package/lib/cache.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Context } from 'koishi';
|
|
2
|
+
import type { CodexResetsResponse } from './types';
|
|
3
|
+
export declare const MIN_POLL_INTERVAL_MS = 660000;
|
|
4
|
+
export interface SnapshotCache {
|
|
5
|
+
id: string;
|
|
6
|
+
snapshot: CodexResetsResponse | null;
|
|
7
|
+
etag: string | null;
|
|
8
|
+
fetchedAt: number;
|
|
9
|
+
nextFetchAt: number;
|
|
10
|
+
requestId: string;
|
|
11
|
+
}
|
|
12
|
+
declare module 'koishi' {
|
|
13
|
+
interface Tables {
|
|
14
|
+
codex_reset_cache: SnapshotCache;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export interface FetchResult {
|
|
18
|
+
snapshot: CodexResetsResponse | null;
|
|
19
|
+
etag: string | null;
|
|
20
|
+
notModified: boolean;
|
|
21
|
+
maxAgeMs: number;
|
|
22
|
+
}
|
|
23
|
+
export declare class SnapshotRequestError extends Error {
|
|
24
|
+
readonly retryAfterMs: number;
|
|
25
|
+
constructor(message: string, retryAfterMs: number);
|
|
26
|
+
}
|
|
27
|
+
export declare function extendCacheModel(ctx: Context): void;
|
|
28
|
+
export declare class SnapshotStore {
|
|
29
|
+
private readonly ctx;
|
|
30
|
+
private readonly validate;
|
|
31
|
+
private readonly id;
|
|
32
|
+
constructor(ctx: Context, apiUrl: string, validate: (value: unknown) => value is CodexResetsResponse);
|
|
33
|
+
read(): Promise<SnapshotCache>;
|
|
34
|
+
refresh(intervalMs: number, timeoutMs: number, fetchSnapshot: (cache: SnapshotCache) => Promise<FetchResult>): Promise<SnapshotCache>;
|
|
35
|
+
private performRefresh;
|
|
36
|
+
}
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SnapshotStore = exports.SnapshotRequestError = exports.MIN_POLL_INTERVAL_MS = void 0;
|
|
4
|
+
exports.extendCacheModel = extendCacheModel;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
// Leave a little room beyond AIHOT's ten-minute full-history limit.
|
|
7
|
+
exports.MIN_POLL_INTERVAL_MS = 660_000;
|
|
8
|
+
const TABLE = 'codex_reset_cache';
|
|
9
|
+
const inFlight = new WeakMap();
|
|
10
|
+
class SnapshotRequestError extends Error {
|
|
11
|
+
retryAfterMs;
|
|
12
|
+
constructor(message, retryAfterMs) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.retryAfterMs = retryAfterMs;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.SnapshotRequestError = SnapshotRequestError;
|
|
18
|
+
function extendCacheModel(ctx) {
|
|
19
|
+
ctx.model.extend(TABLE, {
|
|
20
|
+
id: 'string',
|
|
21
|
+
snapshot: { type: 'json', nullable: true, initial: null },
|
|
22
|
+
etag: { type: 'string', nullable: true, initial: null },
|
|
23
|
+
fetchedAt: { type: 'double', initial: 0 },
|
|
24
|
+
nextFetchAt: { type: 'double', initial: 0 },
|
|
25
|
+
requestId: { type: 'string', initial: '' },
|
|
26
|
+
}, { primary: 'id' });
|
|
27
|
+
}
|
|
28
|
+
class SnapshotStore {
|
|
29
|
+
ctx;
|
|
30
|
+
validate;
|
|
31
|
+
id;
|
|
32
|
+
constructor(ctx, apiUrl, validate) {
|
|
33
|
+
this.ctx = ctx;
|
|
34
|
+
this.validate = validate;
|
|
35
|
+
// Targets do not affect API data or the upstream request quota.
|
|
36
|
+
this.id = (0, node_crypto_1.createHash)('sha256').update(new URL(apiUrl).href).digest('hex');
|
|
37
|
+
}
|
|
38
|
+
async read() {
|
|
39
|
+
let [cache] = await this.ctx.database.get(TABLE, this.id);
|
|
40
|
+
if (!cache) {
|
|
41
|
+
try {
|
|
42
|
+
cache = await this.ctx.database.create(TABLE, { id: this.id });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Another instance may have created the same API cache concurrently.
|
|
46
|
+
const existing = await this.ctx.database.get(TABLE, this.id);
|
|
47
|
+
cache = existing[0];
|
|
48
|
+
if (!cache)
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!this.validate(cache.snapshot))
|
|
53
|
+
return { ...cache, snapshot: null, etag: null };
|
|
54
|
+
return cache;
|
|
55
|
+
}
|
|
56
|
+
refresh(intervalMs, timeoutMs, fetchSnapshot) {
|
|
57
|
+
const root = this.ctx.root ?? this.ctx;
|
|
58
|
+
let requests = inFlight.get(root);
|
|
59
|
+
if (!requests)
|
|
60
|
+
inFlight.set(root, requests = new Map());
|
|
61
|
+
const pending = requests.get(this.id);
|
|
62
|
+
if (pending)
|
|
63
|
+
return pending;
|
|
64
|
+
const request = this.performRefresh(intervalMs, timeoutMs, fetchSnapshot)
|
|
65
|
+
.finally(() => requests.delete(this.id));
|
|
66
|
+
requests.set(this.id, request);
|
|
67
|
+
return request;
|
|
68
|
+
}
|
|
69
|
+
async performRefresh(intervalMs, timeoutMs, fetchSnapshot) {
|
|
70
|
+
const cache = await this.read();
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (now < cache.nextFetchAt)
|
|
73
|
+
return cache;
|
|
74
|
+
const delay = Math.max(exports.MIN_POLL_INTERVAL_MS, intervalMs);
|
|
75
|
+
const requestId = (0, node_crypto_1.randomUUID)();
|
|
76
|
+
// Reserve the next request before network I/O. A restart or another plugin
|
|
77
|
+
// instance must observe this reservation, even if the request fails.
|
|
78
|
+
await this.ctx.database.set(TABLE, {
|
|
79
|
+
id: this.id,
|
|
80
|
+
nextFetchAt: cache.nextFetchAt,
|
|
81
|
+
requestId: cache.requestId,
|
|
82
|
+
}, {
|
|
83
|
+
requestId,
|
|
84
|
+
nextFetchAt: now + Math.max(delay, timeoutMs + 1000),
|
|
85
|
+
});
|
|
86
|
+
const reserved = await this.read();
|
|
87
|
+
if (reserved.requestId !== requestId)
|
|
88
|
+
return reserved;
|
|
89
|
+
try {
|
|
90
|
+
const result = await fetchSnapshot(cache);
|
|
91
|
+
await this.ctx.database.set(TABLE, { id: this.id, requestId }, {
|
|
92
|
+
snapshot: result.notModified ? cache.snapshot : result.snapshot,
|
|
93
|
+
etag: result.etag,
|
|
94
|
+
fetchedAt: Date.now(),
|
|
95
|
+
nextFetchAt: Math.max(now + delay, Date.now() + result.maxAgeMs),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const retryAfterMs = error instanceof SnapshotRequestError ? error.retryAfterMs : 0;
|
|
100
|
+
await this.ctx.database.set(TABLE, { id: this.id, requestId }, {
|
|
101
|
+
nextFetchAt: Date.now() + Math.max(delay, retryAfterMs),
|
|
102
|
+
});
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return this.read();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
exports.SnapshotStore = SnapshotStore;
|
package/lib/calendar.js
CHANGED
|
@@ -175,7 +175,7 @@ function renderDetails(events) {
|
|
|
175
175
|
const statusClass = event.status === 'confirmed' ? 'confirmed-state' : 'announced-state';
|
|
176
176
|
return `<section class="event-group">
|
|
177
177
|
<div class="event-state ${statusClass}"><span class="state-icon">${event.status === 'confirmed' ? '✓' : '◷'}</span>${status}</div>
|
|
178
|
-
<h2>${escapeHtml(event.title.slice(0, 200))}</h2>
|
|
178
|
+
<h2>${escapeHtml(event.title.slice(0, 200))}</h2>
|
|
179
179
|
${renderPostCard(event, post)}
|
|
180
180
|
</section>`;
|
|
181
181
|
}).join('');
|
|
@@ -270,7 +270,7 @@ function renderCalendarHtml(events, now = new Date()) {
|
|
|
270
270
|
</header>
|
|
271
271
|
<div class="weekday-row">${WEEK_NAMES.map((name) => `<span>${name}</span>`).join('')}</div>
|
|
272
272
|
<div class="calendar-grid">${renderCalendarGrid(month, marks, selectedDate, today)}</div>
|
|
273
|
-
<div class="calendar-note">仅显示已确认事件;按北京时间记录实际发生日期,缺失时按确认日期展示;不含预告。</div>
|
|
273
|
+
<div class="calendar-note">仅显示已确认事件;按北京时间记录实际发生日期,缺失时按确认日期展示;不含预告。</div>
|
|
274
274
|
</section>
|
|
275
275
|
${renderDetails(events)}
|
|
276
276
|
</main>
|
package/lib/event.js
CHANGED
|
@@ -121,7 +121,7 @@ function renderLatestResetHtml(summary, now = new Date()) {
|
|
|
121
121
|
</section>
|
|
122
122
|
<section class="source">
|
|
123
123
|
<header class="source-header"><h2>事件来源</h2><span>北京时间</span></header>
|
|
124
|
-
<h2 class="event-title">${escapeHtml(event.title.slice(0, 200))}</h2>
|
|
124
|
+
<h2 class="event-title">${escapeHtml(event.title.slice(0, 200))}</h2>
|
|
125
125
|
${renderSourcePost(summary)}
|
|
126
126
|
<p class="source-note">${escapeHtml(event.scope ? `范围:${event.scope}` : '公开来源帖子记录')}</p>
|
|
127
127
|
<div class="mark">RESET MONITOR</div>
|
package/lib/history.js
CHANGED
|
@@ -114,7 +114,7 @@ function renderTimelineItem(event) {
|
|
|
114
114
|
<div class="timeline-dot ${statusClass}"></div>
|
|
115
115
|
<div class="event-card">
|
|
116
116
|
<div class="event-meta"><span class="type-label">${typeLabel(event)}</span><span class="status ${statusClass}">${statusLabel(event)}</span></div>
|
|
117
|
-
<h2>${escapeHtml(event.title.slice(0, 200))}</h2>
|
|
117
|
+
<h2>${escapeHtml(event.title.slice(0, 200))}</h2>
|
|
118
118
|
${schedule}
|
|
119
119
|
${renderPostPreview(event)}
|
|
120
120
|
</div>
|
package/lib/index.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { Context, Schema } from 'koishi';
|
|
|
2
2
|
import type { Config as PluginConfig } from './types';
|
|
3
3
|
export declare const name = "codex-reset";
|
|
4
4
|
export declare const inject: {
|
|
5
|
+
required: string[];
|
|
5
6
|
optional: string[];
|
|
6
7
|
};
|
|
7
8
|
export type Config = PluginConfig;
|
|
9
|
+
export type { SnapshotCache } from './cache';
|
|
8
10
|
export declare const Config: Schema<Config>;
|
|
9
11
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const calendar_1 = require("./calendar");
|
|
|
10
10
|
const history_1 = require("./history");
|
|
11
11
|
const event_1 = require("./event");
|
|
12
12
|
const probability_1 = require("./probability");
|
|
13
|
+
const cache_1 = require("./cache");
|
|
13
14
|
const stats_1 = require("./stats");
|
|
14
15
|
exports.name = 'codex-reset';
|
|
15
16
|
const MAX_NOTIFICATION_AGE_MS = 48 * 60 * 60 * 1000;
|
|
@@ -21,6 +22,7 @@ const RENDER_START_INTERVAL_MS = 3000;
|
|
|
21
22
|
class RenderBusyError extends Error {
|
|
22
23
|
}
|
|
23
24
|
exports.inject = {
|
|
25
|
+
required: ['database'],
|
|
24
26
|
optional: ['puppeteer'],
|
|
25
27
|
};
|
|
26
28
|
exports.Config = koishi_1.Schema.object({
|
|
@@ -29,8 +31,8 @@ exports.Config = koishi_1.Schema.object({
|
|
|
29
31
|
.description('监控接口地址。通常不需要修改。'),
|
|
30
32
|
pollInterval: koishi_1.Schema.number()
|
|
31
33
|
.min(60)
|
|
32
|
-
.default(
|
|
33
|
-
.description('
|
|
34
|
+
.default(660)
|
|
35
|
+
.description('监控间隔,单位为秒,默认 660 秒。兼容旧配置,实际请求间隔至少 660 秒;重启后继续遵守已保存的冷却时间。'),
|
|
34
36
|
requestTimeout: koishi_1.Schema.number()
|
|
35
37
|
.min(5000)
|
|
36
38
|
.default(15000)
|
|
@@ -219,8 +221,7 @@ async function sendToTargets(ctx, targets, content, deliveredBindings, onDeliver
|
|
|
219
221
|
class CodexResetMonitor {
|
|
220
222
|
ctx;
|
|
221
223
|
config;
|
|
222
|
-
|
|
223
|
-
etag = null;
|
|
224
|
+
snapshots;
|
|
224
225
|
pollPromise = null;
|
|
225
226
|
disposed = false;
|
|
226
227
|
statePath;
|
|
@@ -233,22 +234,45 @@ class CodexResetMonitor {
|
|
|
233
234
|
constructor(ctx, config) {
|
|
234
235
|
this.ctx = ctx;
|
|
235
236
|
this.config = config;
|
|
237
|
+
this.snapshots = new cache_1.SnapshotStore(ctx, config.apiUrl, isSnapshot);
|
|
236
238
|
const instanceId = (0, node_crypto_1.createHash)('sha256')
|
|
237
239
|
.update(JSON.stringify({ apiUrl: config.apiUrl, targets: config.targets }))
|
|
238
240
|
.digest('hex').slice(0, 16);
|
|
239
241
|
this.statePath = (0, node_path_1.join)(ctx.baseDir, 'data', 'codex-reset', `${instanceId}.json`);
|
|
240
242
|
this.stateLoaded = this.loadState();
|
|
241
243
|
}
|
|
242
|
-
get currentSnapshot() {
|
|
243
|
-
return this.snapshot;
|
|
244
|
-
}
|
|
245
244
|
get hasPuppeteer() {
|
|
246
245
|
return Boolean(this.ctx.puppeteer);
|
|
247
246
|
}
|
|
248
247
|
async ensureSnapshot() {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
248
|
+
// Commands only read the database. A missing snapshot must not turn every
|
|
249
|
+
// user command into another upstream request during a rate-limit window.
|
|
250
|
+
try {
|
|
251
|
+
let cache = await this.snapshots.read();
|
|
252
|
+
if (!cache.snapshot && this.pollPromise) {
|
|
253
|
+
// Startup sync may still be receiving its first response. Wait for the
|
|
254
|
+
// existing poll instead of replying before it can populate the table.
|
|
255
|
+
await this.pollPromise;
|
|
256
|
+
cache = await this.snapshots.read();
|
|
257
|
+
}
|
|
258
|
+
return cache.snapshot;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.ctx.logger('codex-reset').warn(`读取重置数据失败:${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async unavailableMessage() {
|
|
266
|
+
try {
|
|
267
|
+
const cache = await this.snapshots.read();
|
|
268
|
+
const seconds = Math.ceil((cache.nextFetchAt - Date.now()) / 1000);
|
|
269
|
+
if (seconds > 0)
|
|
270
|
+
return `暂时无法获取 Codex 重置事件数据:尚无可用缓存,约 ${seconds} 秒后自动重试同步。`;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// ensureSnapshot already records database read failures.
|
|
274
|
+
}
|
|
275
|
+
return '暂时无法获取 Codex 重置事件数据:首次同步尚未完成,请稍后再试。';
|
|
252
276
|
}
|
|
253
277
|
async poll() {
|
|
254
278
|
if (this.disposed)
|
|
@@ -263,24 +287,53 @@ class CodexResetMonitor {
|
|
|
263
287
|
this.pollPromise = null;
|
|
264
288
|
}
|
|
265
289
|
}
|
|
290
|
+
async start() {
|
|
291
|
+
this.ctx.logger('codex-reset').info('启动重置事件同步检查。');
|
|
292
|
+
await this.poll();
|
|
293
|
+
await this.scheduleNextPoll();
|
|
294
|
+
}
|
|
295
|
+
async scheduleNextPoll() {
|
|
296
|
+
if (this.disposed)
|
|
297
|
+
return;
|
|
298
|
+
let delay = Math.max(cache_1.MIN_POLL_INTERVAL_MS, this.config.pollInterval * 1000);
|
|
299
|
+
try {
|
|
300
|
+
const cache = await this.snapshots.read();
|
|
301
|
+
if (cache.nextFetchAt > Date.now())
|
|
302
|
+
delay = cache.nextFetchAt - Date.now();
|
|
303
|
+
this.ctx.logger('codex-reset').info(cache.snapshot
|
|
304
|
+
? `重置数据已就绪(${cache.snapshot.events.length} 条事件),约 ${Math.ceil(delay / 1000)} 秒后同步。`
|
|
305
|
+
: `尚无可用重置数据,约 ${Math.ceil(delay / 1000)} 秒后自动重试同步。`);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
this.ctx.logger('codex-reset').warn(`读取同步计划失败:${error instanceof Error ? error.message : String(error)}`);
|
|
309
|
+
}
|
|
310
|
+
if (this.disposed)
|
|
311
|
+
return;
|
|
312
|
+
this.ctx.setTimeout(async () => {
|
|
313
|
+
await this.poll();
|
|
314
|
+
await this.scheduleNextPoll();
|
|
315
|
+
}, Math.max(1000, Math.min(delay, 2_147_483_647)));
|
|
316
|
+
}
|
|
266
317
|
dispose() {
|
|
267
318
|
this.disposed = true;
|
|
268
319
|
}
|
|
269
320
|
async performPoll() {
|
|
270
321
|
try {
|
|
271
322
|
await this.stateLoaded;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
323
|
+
if (this.disposed)
|
|
324
|
+
return;
|
|
325
|
+
const cache = await this.snapshots.refresh(this.config.pollInterval * 1000, this.config.requestTimeout, (cached) => this.fetchSnapshot(cached));
|
|
326
|
+
if (this.disposed)
|
|
327
|
+
return;
|
|
328
|
+
if (cache.snapshot) {
|
|
276
329
|
if (!this.initialized) {
|
|
277
330
|
// A fresh installation starts with the entire current snapshot as its baseline.
|
|
278
|
-
for (const event of
|
|
331
|
+
for (const event of cache.snapshot.events)
|
|
279
332
|
this.markSeen(event);
|
|
280
333
|
this.initialized = true;
|
|
281
334
|
}
|
|
282
335
|
else {
|
|
283
|
-
this.captureChanges(
|
|
336
|
+
this.captureChanges(cache.snapshot, Date.now());
|
|
284
337
|
}
|
|
285
338
|
}
|
|
286
339
|
await this.flushPending();
|
|
@@ -428,74 +481,97 @@ class CodexResetMonitor {
|
|
|
428
481
|
if (changed)
|
|
429
482
|
await this.saveState();
|
|
430
483
|
}
|
|
431
|
-
async fetchSnapshot() {
|
|
484
|
+
async fetchSnapshot(cache) {
|
|
432
485
|
const controller = new AbortController();
|
|
433
486
|
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeout);
|
|
434
487
|
try {
|
|
435
488
|
const headers = {
|
|
436
489
|
Accept: 'application/json',
|
|
437
|
-
'User-Agent': 'koishi-plugin-codex-reset/0.1.
|
|
490
|
+
'User-Agent': 'koishi-plugin-codex-reset/0.1.7',
|
|
438
491
|
};
|
|
439
|
-
if (
|
|
440
|
-
headers['If-None-Match'] =
|
|
492
|
+
if (cache.etag && cache.snapshot)
|
|
493
|
+
headers['If-None-Match'] = cache.etag;
|
|
441
494
|
const response = await fetch(this.config.apiUrl, {
|
|
442
495
|
method: 'GET',
|
|
443
496
|
headers,
|
|
444
497
|
signal: controller.signal,
|
|
445
498
|
});
|
|
446
|
-
|
|
447
|
-
|
|
499
|
+
const maxAgeMs = Math.max(0, ...Array.from((response.headers.get('cache-control') ?? '').matchAll(/(?:^|[,\s])(?:s-maxage|max-age)=(\d+)/gi), (match) => Number(match[1]) * 1000).filter(Number.isFinite));
|
|
500
|
+
if (response.status === 304 && cache.snapshot) {
|
|
501
|
+
return { snapshot: null, etag: response.headers.get('etag') ?? cache.etag, notModified: true, maxAgeMs };
|
|
448
502
|
}
|
|
449
503
|
if (response.status === 304)
|
|
450
504
|
throw new Error('服务端在没有本地快照时返回了 304');
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
break;
|
|
465
|
-
size += value.byteLength;
|
|
466
|
-
if (size > MAX_API_RESPONSE_BYTES) {
|
|
467
|
-
await reader.cancel();
|
|
468
|
-
throw new Error('API 响应超过 4 MiB');
|
|
505
|
+
if (response.status === 429 || response.status === 503) {
|
|
506
|
+
const retryAfter = response.headers.get('retry-after');
|
|
507
|
+
let retryAfterMs = 0;
|
|
508
|
+
if (retryAfter) {
|
|
509
|
+
retryAfterMs = /^\d+$/.test(retryAfter.trim())
|
|
510
|
+
? Number(retryAfter) * 1000
|
|
511
|
+
: Date.parse(retryAfter) - Date.now();
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const problem = await this.readResponseBody(response);
|
|
515
|
+
if (isRecord(problem) && typeof problem.retryAfter === 'number' && Number.isFinite(problem.retryAfter)) {
|
|
516
|
+
retryAfterMs = Math.max(Number.isFinite(retryAfterMs) ? retryAfterMs : 0, problem.retryAfter * 1000);
|
|
517
|
+
}
|
|
469
518
|
}
|
|
470
|
-
|
|
519
|
+
catch {
|
|
520
|
+
// An HTML or malformed error body must still honor Retry-After.
|
|
521
|
+
}
|
|
522
|
+
retryAfterMs = Math.max(cache_1.MIN_POLL_INTERVAL_MS, Number.isFinite(retryAfterMs) ? retryAfterMs : 0);
|
|
523
|
+
throw new cache_1.SnapshotRequestError(`HTTP ${response.status},至少等待 ${Math.ceil(retryAfterMs / 1000)} 秒后重试`, retryAfterMs);
|
|
471
524
|
}
|
|
472
|
-
|
|
525
|
+
if (!response.ok)
|
|
526
|
+
throw new Error(`HTTP ${response.status}`);
|
|
527
|
+
const body = await this.readResponseBody(response);
|
|
473
528
|
if (!isSnapshot(body))
|
|
474
529
|
throw new Error('响应格式不符合 Codex 重置 API v1');
|
|
475
530
|
return {
|
|
476
531
|
snapshot: body,
|
|
477
532
|
etag: response.headers.get('etag'),
|
|
478
533
|
notModified: false,
|
|
534
|
+
maxAgeMs,
|
|
479
535
|
};
|
|
480
536
|
}
|
|
481
537
|
finally {
|
|
482
538
|
clearTimeout(timeout);
|
|
483
539
|
}
|
|
484
540
|
}
|
|
541
|
+
async readResponseBody(response) {
|
|
542
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
543
|
+
if (declaredLength > MAX_API_RESPONSE_BYTES)
|
|
544
|
+
throw new Error('API 响应超过 4 MiB');
|
|
545
|
+
if (!response.body)
|
|
546
|
+
throw new Error('API 响应没有内容');
|
|
547
|
+
const reader = response.body.getReader();
|
|
548
|
+
const chunks = [];
|
|
549
|
+
let size = 0;
|
|
550
|
+
while (true) {
|
|
551
|
+
const { done, value } = await reader.read();
|
|
552
|
+
if (done)
|
|
553
|
+
break;
|
|
554
|
+
size += value.byteLength;
|
|
555
|
+
if (size > MAX_API_RESPONSE_BYTES) {
|
|
556
|
+
await reader.cancel();
|
|
557
|
+
throw new Error('API 响应超过 4 MiB');
|
|
558
|
+
}
|
|
559
|
+
chunks.push(value);
|
|
560
|
+
}
|
|
561
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
562
|
+
}
|
|
485
563
|
}
|
|
486
564
|
function apply(ctx, config) {
|
|
565
|
+
(0, cache_1.extendCacheModel)(ctx);
|
|
487
566
|
const monitor = new CodexResetMonitor(ctx, config);
|
|
488
|
-
ctx.on('ready', () =>
|
|
489
|
-
void monitor.poll();
|
|
490
|
-
ctx.setInterval(() => void monitor.poll(), config.pollInterval * 1000);
|
|
491
|
-
});
|
|
567
|
+
ctx.on('ready', () => monitor.start());
|
|
492
568
|
ctx.on('dispose', () => monitor.dispose());
|
|
493
569
|
ctx.command('codex.probability', '分析 Codex 重置事件的历史概率')
|
|
494
570
|
.alias('重置概率')
|
|
495
571
|
.action(async () => {
|
|
496
572
|
const snapshot = await monitor.ensureSnapshot();
|
|
497
573
|
if (!snapshot)
|
|
498
|
-
return
|
|
574
|
+
return monitor.unavailableMessage();
|
|
499
575
|
const summary = (0, stats_1.analyzeProbability)(snapshot);
|
|
500
576
|
if (!monitor.hasPuppeteer)
|
|
501
577
|
return (0, stats_1.formatProbability)(summary);
|
|
@@ -517,7 +593,7 @@ function apply(ctx, config) {
|
|
|
517
593
|
.action(async () => {
|
|
518
594
|
const snapshot = await monitor.ensureSnapshot();
|
|
519
595
|
if (!snapshot)
|
|
520
|
-
return
|
|
596
|
+
return monitor.unavailableMessage();
|
|
521
597
|
const now = new Date();
|
|
522
598
|
const latest = (0, stats_1.findLatestConfirmedReset)(snapshot, now);
|
|
523
599
|
if (!latest)
|
|
@@ -545,7 +621,7 @@ function apply(ctx, config) {
|
|
|
545
621
|
try {
|
|
546
622
|
const image = await monitor.createCalendarImage();
|
|
547
623
|
if (!image)
|
|
548
|
-
return
|
|
624
|
+
return monitor.unavailableMessage();
|
|
549
625
|
return koishi_1.h.image(image, 'image/png');
|
|
550
626
|
}
|
|
551
627
|
catch (error) {
|
|
@@ -564,7 +640,7 @@ function apply(ctx, config) {
|
|
|
564
640
|
try {
|
|
565
641
|
const snapshot = await monitor.ensureSnapshot();
|
|
566
642
|
if (!snapshot)
|
|
567
|
-
return
|
|
643
|
+
return monitor.unavailableMessage();
|
|
568
644
|
const now = new Date();
|
|
569
645
|
const historyRange = argv.options?.all
|
|
570
646
|
? 'all'
|
|
@@ -574,7 +650,7 @@ function apply(ctx, config) {
|
|
|
574
650
|
return `历史事件超过 ${MAX_HISTORY_EVENTS} 条,请缩小查询的月份范围。`;
|
|
575
651
|
const image = await monitor.createHistoryImage(events, now);
|
|
576
652
|
if (!image)
|
|
577
|
-
return
|
|
653
|
+
return monitor.unavailableMessage();
|
|
578
654
|
return koishi_1.h.image(image, 'image/png');
|
|
579
655
|
}
|
|
580
656
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-codex-reset",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
+
"@minatojs/driver-sqlite": "4.7.0",
|
|
47
48
|
"@types/node": "^22.0.0",
|
|
48
49
|
"koishi": "^4.18.9",
|
|
49
50
|
"typescript": "^5.7.0"
|