koishi-plugin-codex-reset 0.1.4 → 0.1.6
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 +11 -5
- package/lib/cache.d.ts +36 -0
- package/lib/cache.js +108 -0
- package/lib/calendar.js +4 -3
- package/lib/event.js +3 -2
- package/lib/history.js +8 -5
- package/lib/index.d.ts +2 -0
- package/lib/index.js +307 -76
- package/lib/stats.js +5 -5
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -24,9 +24,15 @@
|
|
|
24
24
|
|
|
25
25
|
概率、最近重置、日历和历史时间线命令始终启用;安装并启用 `koishi-plugin-puppeteer` 后会发送对应图片,没有 Puppeteer 时概率和最近重置会回退为纯文本,日历和历史时间线会提示安装 Puppeteer。
|
|
26
26
|
|
|
27
|
-
日历、月度统计及右侧动态仅展示已确认且日期不晚于今天的事件,不展示预告。日期优先使用实际发生日期,缺失时使用北京时间的确认日期;两者均缺失时不展示,不使用预计时间窗口代替实际日期。
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
后台默认每 660 秒同步一次;旧配置小于 660 秒时,实际请求也不会快于 660 秒。请求前先保存冷却时间,收到 HTTP 429 或 503 时遵循 `Retry-After`(也支持响应正文的 `retryAfter` 秒数),冷却期间不会因重启或手动查询再次请求。数据库写入失败时不会继续发起新的上游请求。
|
|
34
|
+
|
|
35
|
+
图片生成最多同时进行 2 项,新的图片任务至少间隔 3 秒;历史图片超过 120 条事件时会提示缩小查询范围。图片中的长篇来源文字会截短。API 响应上限为 4 MiB。
|
|
30
36
|
|
|
31
37
|
## 安装
|
|
32
38
|
|
|
@@ -34,12 +40,12 @@
|
|
|
34
40
|
npm install koishi-plugin-codex-reset
|
|
35
41
|
```
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
先在 Koishi 中启用一个数据库插件,再启用本插件;如果已经使用数据库服务,无需另建数据库。如果需要概率卡片、最近重置卡片、日历或历史时间线图片,再安装并启用 `koishi-plugin-puppeteer`。不安装 Puppeteer 时,监控、最近重置和概率分析仍然可以使用,概率与最近重置命令回退为纯文本,日历和历史命令会给出提示。
|
|
38
44
|
|
|
39
45
|
## 配置
|
|
40
46
|
|
|
41
47
|
- `监控接口地址`:监控 API 地址,默认 `https://aihot.news/api/v1/codex-resets`。
|
|
42
|
-
- `监控间隔`:轮询间隔,单位为秒,默认
|
|
48
|
+
- `监控间隔`:轮询间隔,单位为秒,默认 660 秒(11 分钟)。实际请求间隔至少 660 秒,并遵守服务端更长的缓存及重试等待时间。
|
|
43
49
|
- `请求超时时间`:单次 API 请求超时时间,单位为毫秒,默认 15000 毫秒。
|
|
44
50
|
- `推送目标`:推送目标表,填写“平台”和“频道或群组 ID”;平台留空会按频道尝试在线 Bot,同一平台/频道只发送一次。
|
|
45
51
|
- `推送重置预告`:是否推送预告。
|
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
|
@@ -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
|
|
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('');
|
|
@@ -269,7 +270,7 @@ function renderCalendarHtml(events, now = new Date()) {
|
|
|
269
270
|
</header>
|
|
270
271
|
<div class="weekday-row">${WEEK_NAMES.map((name) => `<span>${name}</span>`).join('')}</div>
|
|
271
272
|
<div class="calendar-grid">${renderCalendarGrid(month, marks, selectedDate, today)}</div>
|
|
272
|
-
<div class="calendar-note">仅显示已确认事件;按北京时间记录实际发生日期,缺失时按确认日期展示;不含预告。</div>
|
|
273
|
+
<div class="calendar-note">仅显示已确认事件;按北京时间记录实际发生日期,缺失时按确认日期展示;不含预告。</div>
|
|
273
274
|
</section>
|
|
274
275
|
${renderDetails(events)}
|
|
275
276
|
</main>
|
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
|
|
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
|
|
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
|
|
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.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
|
@@ -3,13 +3,26 @@ 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");
|
|
13
|
+
const cache_1 = require("./cache");
|
|
10
14
|
const stats_1 = require("./stats");
|
|
11
15
|
exports.name = 'codex-reset';
|
|
16
|
+
const MAX_NOTIFICATION_AGE_MS = 48 * 60 * 60 * 1000;
|
|
17
|
+
const MAX_PENDING_AGE_MS = 48 * 60 * 60 * 1000;
|
|
18
|
+
const MAX_API_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
const MAX_HISTORY_EVENTS = 120;
|
|
20
|
+
const MAX_CONCURRENT_RENDERS = 2;
|
|
21
|
+
const RENDER_START_INTERVAL_MS = 3000;
|
|
22
|
+
class RenderBusyError extends Error {
|
|
23
|
+
}
|
|
12
24
|
exports.inject = {
|
|
25
|
+
required: ['database'],
|
|
13
26
|
optional: ['puppeteer'],
|
|
14
27
|
};
|
|
15
28
|
exports.Config = koishi_1.Schema.object({
|
|
@@ -18,8 +31,8 @@ exports.Config = koishi_1.Schema.object({
|
|
|
18
31
|
.description('监控接口地址。通常不需要修改。'),
|
|
19
32
|
pollInterval: koishi_1.Schema.number()
|
|
20
33
|
.min(60)
|
|
21
|
-
.default(
|
|
22
|
-
.description('
|
|
34
|
+
.default(660)
|
|
35
|
+
.description('监控间隔,单位为秒,默认 660 秒。兼容旧配置,实际请求间隔至少 660 秒;重启后继续遵守已保存的冷却时间。'),
|
|
23
36
|
requestTimeout: koishi_1.Schema.number()
|
|
24
37
|
.min(5000)
|
|
25
38
|
.default(15000)
|
|
@@ -125,58 +138,122 @@ function isSnapshot(value) {
|
|
|
125
138
|
function eventSummary(event) {
|
|
126
139
|
const kind = event.type === 'direct_reset' ? 'Codex 额度重置' : 'Codex 重置卡发放';
|
|
127
140
|
const status = event.status === 'announced' ? '预告' : '已确认';
|
|
128
|
-
const scope = event.scope ? `(范围:${event.scope})` : '';
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
const scope = event.scope ? `(范围:${event.scope.slice(0, 200)})` : '';
|
|
142
|
+
let schedule = '';
|
|
143
|
+
if (event.status === 'announced' && event.schedule?.label) {
|
|
144
|
+
schedule = `\n预计时间:${event.schedule.label.slice(0, 200)}`;
|
|
145
|
+
}
|
|
146
|
+
else if (event.status === 'confirmed' && event.occurredOn) {
|
|
147
|
+
schedule = `\n发生日期:${event.occurredOn}(北京时间)`;
|
|
148
|
+
}
|
|
149
|
+
else if (event.status === 'confirmed' && event.confirmedAt) {
|
|
150
|
+
const confirmedAt = new Intl.DateTimeFormat('zh-CN', {
|
|
151
|
+
timeZone: 'Asia/Shanghai', dateStyle: 'medium', timeStyle: 'short',
|
|
152
|
+
}).format(new Date(event.confirmedAt));
|
|
153
|
+
schedule = `\n确认时间:${confirmedAt}`;
|
|
154
|
+
}
|
|
155
|
+
const source = event.posts[0]?.url ? `\n来源:${event.posts[0].url.slice(0, 500)}` : '';
|
|
131
156
|
return `${kind}${status}${scope}${schedule}${source}`;
|
|
132
157
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
function stageKeys(event) {
|
|
159
|
+
return [`id:${event.status}:${event.id}`];
|
|
160
|
+
}
|
|
161
|
+
function isRecent(timestamp, now) {
|
|
162
|
+
return Number.isFinite(timestamp) && timestamp <= now + 5 * 60 * 1000 && now - timestamp <= MAX_NOTIFICATION_AGE_MS;
|
|
163
|
+
}
|
|
164
|
+
function isRecentEventDay(value, now) {
|
|
165
|
+
const timestamp = Date.parse(`${value}T12:00:00+08:00`);
|
|
166
|
+
// occurredOn has day precision. Today's noon may still be in the future
|
|
167
|
+
// when a morning confirmation arrives.
|
|
168
|
+
return Number.isFinite(timestamp) && timestamp <= now + 12 * 60 * 60 * 1000
|
|
169
|
+
&& now - timestamp <= MAX_NOTIFICATION_AGE_MS;
|
|
170
|
+
}
|
|
171
|
+
function isFreshNotification(event, now, wasAnnounced) {
|
|
172
|
+
if (event.status === 'announced')
|
|
173
|
+
return isRecent(Date.parse(event.createdAt), now);
|
|
174
|
+
// A fresh confirmation can be attached to a much older reset. Never revive
|
|
175
|
+
// that historical event merely because the API filled in confirmedAt today.
|
|
176
|
+
if (event.occurredOn && !isRecentEventDay(event.occurredOn, now))
|
|
177
|
+
return false;
|
|
178
|
+
if (!event.occurredOn && event.schedule?.through
|
|
179
|
+
&& now - Date.parse(event.schedule.through) > MAX_NOTIFICATION_AGE_MS)
|
|
180
|
+
return false;
|
|
181
|
+
if (event.confirmedAt)
|
|
182
|
+
return isRecent(Date.parse(event.confirmedAt), now);
|
|
183
|
+
if (event.occurredOn)
|
|
184
|
+
return isRecentEventDay(event.occurredOn, now);
|
|
185
|
+
return (wasAnnounced || isRecent(Date.parse(event.createdAt), now))
|
|
186
|
+
&& isRecent(Date.parse(event.updatedAt), now);
|
|
187
|
+
}
|
|
188
|
+
async function sendToTargets(ctx, targets, content, deliveredBindings, onDelivered) {
|
|
189
|
+
const delivered = new Set(deliveredBindings);
|
|
190
|
+
let complete = true;
|
|
136
191
|
for (const target of targets) {
|
|
137
192
|
const channelId = target.channelId?.trim();
|
|
138
193
|
if (!channelId)
|
|
139
194
|
continue;
|
|
140
195
|
const bots = [...ctx.bots].filter((bot) => !target.platform?.trim() || bot.platform === target.platform.trim());
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const key = `${
|
|
145
|
-
if (
|
|
196
|
+
if (!bots.length)
|
|
197
|
+
complete = false;
|
|
198
|
+
for (const platform of new Set(bots.map((bot) => bot.platform))) {
|
|
199
|
+
const key = `${platform}\u0000${channelId}`;
|
|
200
|
+
if (delivered.has(key))
|
|
146
201
|
continue;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
202
|
+
for (const bot of bots.filter((candidate) => candidate.platform === platform)) {
|
|
203
|
+
try {
|
|
204
|
+
await bot.sendMessage(channelId, content);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
ctx.logger('codex-reset').warn(`发送到 ${bot.platform}:${target.channelId} 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
delivered.add(key);
|
|
211
|
+
deliveredBindings.push(key);
|
|
212
|
+
await onDelivered();
|
|
213
|
+
break;
|
|
154
214
|
}
|
|
215
|
+
if (!delivered.has(key))
|
|
216
|
+
complete = false;
|
|
155
217
|
}
|
|
156
218
|
}
|
|
157
|
-
return
|
|
219
|
+
return complete;
|
|
158
220
|
}
|
|
159
221
|
class CodexResetMonitor {
|
|
160
222
|
ctx;
|
|
161
223
|
config;
|
|
162
|
-
|
|
163
|
-
etag = null;
|
|
224
|
+
snapshots;
|
|
164
225
|
pollPromise = null;
|
|
165
226
|
disposed = false;
|
|
227
|
+
statePath;
|
|
228
|
+
stateLoaded;
|
|
229
|
+
seenStages = new Set();
|
|
230
|
+
pending = new Map();
|
|
231
|
+
initialized = false;
|
|
232
|
+
activeRenders = 0;
|
|
233
|
+
nextRenderAllowedAt = 0;
|
|
166
234
|
constructor(ctx, config) {
|
|
167
235
|
this.ctx = ctx;
|
|
168
236
|
this.config = config;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
237
|
+
this.snapshots = new cache_1.SnapshotStore(ctx, config.apiUrl, isSnapshot);
|
|
238
|
+
const instanceId = (0, node_crypto_1.createHash)('sha256')
|
|
239
|
+
.update(JSON.stringify({ apiUrl: config.apiUrl, targets: config.targets }))
|
|
240
|
+
.digest('hex').slice(0, 16);
|
|
241
|
+
this.statePath = (0, node_path_1.join)(ctx.baseDir, 'data', 'codex-reset', `${instanceId}.json`);
|
|
242
|
+
this.stateLoaded = this.loadState();
|
|
172
243
|
}
|
|
173
244
|
get hasPuppeteer() {
|
|
174
245
|
return Boolean(this.ctx.puppeteer);
|
|
175
246
|
}
|
|
176
247
|
async ensureSnapshot() {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
return (await this.snapshots.read()).snapshot;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.ctx.logger('codex-reset').warn(`读取重置数据失败:${error instanceof Error ? error.message : String(error)}`);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
180
257
|
}
|
|
181
258
|
async poll() {
|
|
182
259
|
if (this.disposed)
|
|
@@ -191,21 +268,35 @@ class CodexResetMonitor {
|
|
|
191
268
|
this.pollPromise = null;
|
|
192
269
|
}
|
|
193
270
|
}
|
|
271
|
+
async start() {
|
|
272
|
+
await this.poll();
|
|
273
|
+
if (!this.disposed) {
|
|
274
|
+
this.ctx.setInterval(() => this.poll(), Math.max(cache_1.MIN_POLL_INTERVAL_MS, this.config.pollInterval * 1000));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
194
277
|
dispose() {
|
|
195
278
|
this.disposed = true;
|
|
196
279
|
}
|
|
197
280
|
async performPoll() {
|
|
198
281
|
try {
|
|
199
|
-
|
|
200
|
-
if (
|
|
282
|
+
await this.stateLoaded;
|
|
283
|
+
if (this.disposed)
|
|
201
284
|
return;
|
|
202
|
-
const
|
|
203
|
-
this.
|
|
204
|
-
this.etag = result.etag ?? this.etag;
|
|
205
|
-
// The API returns a complete, mutable snapshot. The first fetch is a baseline.
|
|
206
|
-
if (!previous)
|
|
285
|
+
const cache = await this.snapshots.refresh(this.config.pollInterval * 1000, this.config.requestTimeout, (cached) => this.fetchSnapshot(cached));
|
|
286
|
+
if (this.disposed)
|
|
207
287
|
return;
|
|
208
|
-
|
|
288
|
+
if (cache.snapshot) {
|
|
289
|
+
if (!this.initialized) {
|
|
290
|
+
// A fresh installation starts with the entire current snapshot as its baseline.
|
|
291
|
+
for (const event of cache.snapshot.events)
|
|
292
|
+
this.markSeen(event);
|
|
293
|
+
this.initialized = true;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
this.captureChanges(cache.snapshot, Date.now());
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
await this.flushPending();
|
|
209
300
|
}
|
|
210
301
|
catch (error) {
|
|
211
302
|
this.ctx.logger('codex-reset').warn(`获取 AIHOT 重置事件失败:${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -216,94 +307,224 @@ class CodexResetMonitor {
|
|
|
216
307
|
const browser = this.ctx.puppeteer;
|
|
217
308
|
if (!snapshot || !browser)
|
|
218
309
|
return null;
|
|
219
|
-
return (0, calendar_1.renderCalendarImage)(browser, snapshot.events);
|
|
310
|
+
return this.withRenderSlot(() => (0, calendar_1.renderCalendarImage)(browser, snapshot.events));
|
|
220
311
|
}
|
|
221
312
|
async createHistoryImage(events, now = new Date()) {
|
|
222
313
|
const snapshot = await this.ensureSnapshot();
|
|
223
314
|
const browser = this.ctx.puppeteer;
|
|
224
315
|
if (!snapshot || !browser)
|
|
225
316
|
return null;
|
|
226
|
-
return (0, history_1.renderHistoryImage)(browser, events ?? snapshot.events, now);
|
|
317
|
+
return this.withRenderSlot(() => (0, history_1.renderHistoryImage)(browser, events ?? snapshot.events, now));
|
|
227
318
|
}
|
|
228
319
|
async createProbabilityImage(summary) {
|
|
229
320
|
const browser = this.ctx.puppeteer;
|
|
230
321
|
if (!browser)
|
|
231
322
|
return null;
|
|
232
|
-
return (0, probability_1.renderProbabilityImage)(browser, summary);
|
|
323
|
+
return this.withRenderSlot(() => (0, probability_1.renderProbabilityImage)(browser, summary));
|
|
233
324
|
}
|
|
234
325
|
async createLatestResetImage(summary, now = new Date()) {
|
|
235
326
|
const browser = this.ctx.puppeteer;
|
|
236
327
|
if (!browser)
|
|
237
328
|
return null;
|
|
238
|
-
return (0, event_1.renderLatestResetImage)(browser, summary, now);
|
|
329
|
+
return this.withRenderSlot(() => (0, event_1.renderLatestResetImage)(browser, summary, now));
|
|
330
|
+
}
|
|
331
|
+
async withRenderSlot(render) {
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
if (this.activeRenders >= MAX_CONCURRENT_RENDERS || now < this.nextRenderAllowedAt) {
|
|
334
|
+
throw new RenderBusyError('图片生成任务较多');
|
|
335
|
+
}
|
|
336
|
+
this.nextRenderAllowedAt = now + RENDER_START_INTERVAL_MS;
|
|
337
|
+
this.activeRenders++;
|
|
338
|
+
try {
|
|
339
|
+
return await render();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
this.activeRenders--;
|
|
343
|
+
}
|
|
239
344
|
}
|
|
240
|
-
async
|
|
345
|
+
async loadState() {
|
|
346
|
+
try {
|
|
347
|
+
const state = JSON.parse(await (0, promises_1.readFile)(this.statePath, 'utf8'));
|
|
348
|
+
if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.seenStages) || !Array.isArray(state.pending)) {
|
|
349
|
+
throw new Error('状态文件格式无效');
|
|
350
|
+
}
|
|
351
|
+
this.initialized = state.initialized === true;
|
|
352
|
+
for (const key of state.seenStages) {
|
|
353
|
+
if (typeof key === 'string')
|
|
354
|
+
this.seenStages.add(key);
|
|
355
|
+
}
|
|
356
|
+
for (const item of state.pending) {
|
|
357
|
+
if (!isRecord(item) || typeof item.key !== 'string' || typeof item.message !== 'string'
|
|
358
|
+
|| typeof item.createdAt !== 'number' || !Array.isArray(item.deliveredBindings))
|
|
359
|
+
continue;
|
|
360
|
+
this.pending.set(item.key, {
|
|
361
|
+
key: item.key,
|
|
362
|
+
message: item.message,
|
|
363
|
+
createdAt: item.createdAt,
|
|
364
|
+
deliveredBindings: item.deliveredBindings.filter((key) => typeof key === 'string'),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (isRecord(error) && error.code === 'ENOENT')
|
|
370
|
+
return;
|
|
371
|
+
this.ctx.logger('codex-reset').warn(`读取推送状态失败,将重新建立基线:${error instanceof Error ? error.message : String(error)}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async saveState() {
|
|
375
|
+
const state = {
|
|
376
|
+
version: 1,
|
|
377
|
+
initialized: this.initialized,
|
|
378
|
+
seenStages: [...this.seenStages],
|
|
379
|
+
pending: [...this.pending.values()],
|
|
380
|
+
};
|
|
381
|
+
const temporaryPath = `${this.statePath}.${process.pid}.tmp`;
|
|
382
|
+
try {
|
|
383
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(this.statePath), { recursive: true });
|
|
384
|
+
await (0, promises_1.writeFile)(temporaryPath, JSON.stringify(state), 'utf8');
|
|
385
|
+
await (0, promises_1.rename)(temporaryPath, this.statePath);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
this.ctx.logger('codex-reset').warn(`保存推送状态失败:${error instanceof Error ? error.message : String(error)}`);
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
markSeen(event) {
|
|
393
|
+
for (const key of stageKeys(event))
|
|
394
|
+
this.seenStages.add(key);
|
|
395
|
+
}
|
|
396
|
+
captureChanges(current, now) {
|
|
397
|
+
for (const event of current.events) {
|
|
398
|
+
const keys = stageKeys(event);
|
|
399
|
+
const alreadySeen = keys.some((key) => this.seenStages.has(key));
|
|
400
|
+
const wasAnnounced = this.seenStages.has(`id:announced:${event.id}`);
|
|
401
|
+
this.markSeen(event);
|
|
402
|
+
if (alreadySeen || !isFreshNotification(event, now, wasAnnounced))
|
|
403
|
+
continue;
|
|
404
|
+
if (event.status === 'announced' && !this.config.pushAnnouncements)
|
|
405
|
+
continue;
|
|
406
|
+
if (event.status === 'confirmed' && !this.config.pushConfirmedResets)
|
|
407
|
+
continue;
|
|
408
|
+
const message = event.status === 'announced'
|
|
409
|
+
? `📢 Codex 重置预告\n${eventSummary(event)}`
|
|
410
|
+
: `✅ Codex 重置事件\n${eventSummary(event)}`;
|
|
411
|
+
if (!this.config.targets.length) {
|
|
412
|
+
this.ctx.logger('codex-reset').info(`检测到新事件,但未配置推送目标:\n${message}`);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const key = keys[0];
|
|
416
|
+
this.pending.set(key, { key, message, createdAt: now, deliveredBindings: [] });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async flushPending() {
|
|
420
|
+
// Persist the observed stages and queue before sending. This also repairs
|
|
421
|
+
// a previous write failure when the next API response is 304.
|
|
422
|
+
await this.saveState();
|
|
423
|
+
let changed = false;
|
|
424
|
+
for (const [key, item] of this.pending) {
|
|
425
|
+
if (Date.now() - item.createdAt > MAX_PENDING_AGE_MS) {
|
|
426
|
+
this.ctx.logger('codex-reset').warn(`推送超过 48 小时仍未完成,已放弃:${key}`);
|
|
427
|
+
this.pending.delete(key);
|
|
428
|
+
changed = true;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const content = this.config.mentionAll
|
|
432
|
+
? [(0, koishi_1.h)('at', { type: 'all' }), koishi_1.h.text(`\n${item.message}`)]
|
|
433
|
+
: item.message;
|
|
434
|
+
const deliveredCount = item.deliveredBindings.length;
|
|
435
|
+
const complete = await sendToTargets(this.ctx, this.config.targets, content, item.deliveredBindings, () => this.saveState());
|
|
436
|
+
if (complete)
|
|
437
|
+
this.pending.delete(key);
|
|
438
|
+
if (complete || deliveredCount !== item.deliveredBindings.length)
|
|
439
|
+
changed = true;
|
|
440
|
+
}
|
|
441
|
+
if (changed)
|
|
442
|
+
await this.saveState();
|
|
443
|
+
}
|
|
444
|
+
async fetchSnapshot(cache) {
|
|
241
445
|
const controller = new AbortController();
|
|
242
446
|
const timeout = setTimeout(() => controller.abort(), this.config.requestTimeout);
|
|
243
447
|
try {
|
|
244
448
|
const headers = {
|
|
245
449
|
Accept: 'application/json',
|
|
246
|
-
'User-Agent': 'koishi-plugin-codex-reset/0.1.
|
|
450
|
+
'User-Agent': 'koishi-plugin-codex-reset/0.1.6',
|
|
247
451
|
};
|
|
248
|
-
if (
|
|
249
|
-
headers['If-None-Match'] =
|
|
452
|
+
if (cache.etag && cache.snapshot)
|
|
453
|
+
headers['If-None-Match'] = cache.etag;
|
|
250
454
|
const response = await fetch(this.config.apiUrl, {
|
|
251
455
|
method: 'GET',
|
|
252
456
|
headers,
|
|
253
457
|
signal: controller.signal,
|
|
254
458
|
});
|
|
255
|
-
|
|
256
|
-
|
|
459
|
+
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));
|
|
460
|
+
if (response.status === 304 && cache.snapshot) {
|
|
461
|
+
return { snapshot: null, etag: response.headers.get('etag') ?? cache.etag, notModified: true, maxAgeMs };
|
|
257
462
|
}
|
|
258
463
|
if (response.status === 304)
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
const
|
|
262
|
-
|
|
464
|
+
throw new Error('服务端在没有本地快照时返回了 304');
|
|
465
|
+
if (response.status === 429 || response.status === 503) {
|
|
466
|
+
const retryAfter = response.headers.get('retry-after');
|
|
467
|
+
let retryAfterMs = 0;
|
|
468
|
+
if (retryAfter) {
|
|
469
|
+
retryAfterMs = /^\d+$/.test(retryAfter.trim())
|
|
470
|
+
? Number(retryAfter) * 1000
|
|
471
|
+
: Date.parse(retryAfter) - Date.now();
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const problem = await this.readResponseBody(response);
|
|
475
|
+
if (isRecord(problem) && typeof problem.retryAfter === 'number' && Number.isFinite(problem.retryAfter)) {
|
|
476
|
+
retryAfterMs = Math.max(Number.isFinite(retryAfterMs) ? retryAfterMs : 0, problem.retryAfter * 1000);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// An HTML or malformed error body must still honor Retry-After.
|
|
481
|
+
}
|
|
482
|
+
retryAfterMs = Math.max(cache_1.MIN_POLL_INTERVAL_MS, Number.isFinite(retryAfterMs) ? retryAfterMs : 0);
|
|
483
|
+
throw new cache_1.SnapshotRequestError(`HTTP ${response.status},至少等待 ${Math.ceil(retryAfterMs / 1000)} 秒后重试`, retryAfterMs);
|
|
263
484
|
}
|
|
264
|
-
|
|
485
|
+
if (!response.ok)
|
|
486
|
+
throw new Error(`HTTP ${response.status}`);
|
|
487
|
+
const body = await this.readResponseBody(response);
|
|
265
488
|
if (!isSnapshot(body))
|
|
266
489
|
throw new Error('响应格式不符合 Codex 重置 API v1');
|
|
267
490
|
return {
|
|
268
491
|
snapshot: body,
|
|
269
492
|
etag: response.headers.get('etag'),
|
|
270
493
|
notModified: false,
|
|
494
|
+
maxAgeMs,
|
|
271
495
|
};
|
|
272
496
|
}
|
|
273
497
|
finally {
|
|
274
498
|
clearTimeout(timeout);
|
|
275
499
|
}
|
|
276
500
|
}
|
|
277
|
-
async
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
501
|
+
async readResponseBody(response) {
|
|
502
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
503
|
+
if (declaredLength > MAX_API_RESPONSE_BYTES)
|
|
504
|
+
throw new Error('API 响应超过 4 MiB');
|
|
505
|
+
if (!response.body)
|
|
506
|
+
throw new Error('API 响应没有内容');
|
|
507
|
+
const reader = response.body.getReader();
|
|
508
|
+
const chunks = [];
|
|
509
|
+
let size = 0;
|
|
510
|
+
while (true) {
|
|
511
|
+
const { done, value } = await reader.read();
|
|
512
|
+
if (done)
|
|
513
|
+
break;
|
|
514
|
+
size += value.byteLength;
|
|
515
|
+
if (size > MAX_API_RESPONSE_BYTES) {
|
|
516
|
+
await reader.cancel();
|
|
517
|
+
throw new Error('API 响应超过 4 MiB');
|
|
293
518
|
}
|
|
294
|
-
|
|
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);
|
|
519
|
+
chunks.push(value);
|
|
298
520
|
}
|
|
521
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
299
522
|
}
|
|
300
523
|
}
|
|
301
524
|
function apply(ctx, config) {
|
|
525
|
+
(0, cache_1.extendCacheModel)(ctx);
|
|
302
526
|
const monitor = new CodexResetMonitor(ctx, config);
|
|
303
|
-
ctx.on('ready', () =>
|
|
304
|
-
void monitor.poll();
|
|
305
|
-
ctx.setInterval(() => void monitor.poll(), config.pollInterval * 1000);
|
|
306
|
-
});
|
|
527
|
+
ctx.on('ready', () => monitor.start());
|
|
307
528
|
ctx.on('dispose', () => monitor.dispose());
|
|
308
529
|
ctx.command('codex.probability', '分析 Codex 重置事件的历史概率')
|
|
309
530
|
.alias('重置概率')
|
|
@@ -321,6 +542,8 @@ function apply(ctx, config) {
|
|
|
321
542
|
return koishi_1.h.image(image, 'image/png');
|
|
322
543
|
}
|
|
323
544
|
catch (error) {
|
|
545
|
+
if (error instanceof RenderBusyError)
|
|
546
|
+
return (0, stats_1.formatProbability)(summary);
|
|
324
547
|
ctx.logger('codex-reset').warn(`生成概率卡片失败:${error instanceof Error ? error.message : String(error)}`);
|
|
325
548
|
return (0, stats_1.formatProbability)(summary);
|
|
326
549
|
}
|
|
@@ -344,6 +567,8 @@ function apply(ctx, config) {
|
|
|
344
567
|
return koishi_1.h.image(image, 'image/png');
|
|
345
568
|
}
|
|
346
569
|
catch (error) {
|
|
570
|
+
if (error instanceof RenderBusyError)
|
|
571
|
+
return (0, stats_1.formatLatestReset)(snapshot, now);
|
|
347
572
|
ctx.logger('codex-reset').warn(`生成最近重置卡片失败:${error instanceof Error ? error.message : String(error)}`);
|
|
348
573
|
return (0, stats_1.formatLatestReset)(snapshot, now);
|
|
349
574
|
}
|
|
@@ -360,6 +585,8 @@ function apply(ctx, config) {
|
|
|
360
585
|
return koishi_1.h.image(image, 'image/png');
|
|
361
586
|
}
|
|
362
587
|
catch (error) {
|
|
588
|
+
if (error instanceof RenderBusyError)
|
|
589
|
+
return '图片生成任务较多,请稍后再试。';
|
|
363
590
|
ctx.logger('codex-reset').warn(`生成日历图片失败:${error instanceof Error ? error.message : String(error)}`);
|
|
364
591
|
return '日历图片生成失败,请检查 Puppeteer 服务和中文字体。';
|
|
365
592
|
}
|
|
@@ -379,12 +606,16 @@ function apply(ctx, config) {
|
|
|
379
606
|
? 'all'
|
|
380
607
|
: Math.max(1, Math.min(120, Math.floor(Math.abs(range ?? 1))));
|
|
381
608
|
const events = (0, history_1.filterHistoryEvents)(snapshot.events, now, historyRange);
|
|
609
|
+
if (events.length > MAX_HISTORY_EVENTS)
|
|
610
|
+
return `历史事件超过 ${MAX_HISTORY_EVENTS} 条,请缩小查询的月份范围。`;
|
|
382
611
|
const image = await monitor.createHistoryImage(events, now);
|
|
383
612
|
if (!image)
|
|
384
613
|
return '暂时无法生成历史时间线图片,请稍后再试。';
|
|
385
614
|
return koishi_1.h.image(image, 'image/png');
|
|
386
615
|
}
|
|
387
616
|
catch (error) {
|
|
617
|
+
if (error instanceof RenderBusyError)
|
|
618
|
+
return '图片生成任务较多,请稍后再试。';
|
|
388
619
|
ctx.logger('codex-reset').warn(`生成历史时间线图片失败:${error instanceof Error ? error.message : String(error)}`);
|
|
389
620
|
return '历史时间线图片生成失败,请检查 Puppeteer 服务和中文字体。';
|
|
390
621
|
}
|
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.
|
|
3
|
+
"version": "0.1.6",
|
|
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"
|