koishi-plugin-codex-reset 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lumia1998
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # koishi-plugin-codex-reset
2
+
3
+ 监控 [AIHOT Codex 重置 API](https://aihot.news/api/v1/codex-resets) 的 Koishi 插件。
4
+
5
+ ## 功能
6
+
7
+ - 定时轮询 AIHOT 的 Codex / ChatGPT Work 重置事件快照。
8
+ - 支持推送新的重置预告、已确认的全员重置和重置卡事件。
9
+ - 目标支持平台 + 频道配置,例如 `onebot` + `123456789`。
10
+ - `codex probability` / `重置概率`:基于历史快照计算经验概率。
11
+ - `codex event` / `最近重置`:统计上一次已确认重置距今多少小时。
12
+ - `codex calendar` / `重置日历`:生成本月的 PNG 日历图片,右侧详情固定为命令执行当天。
13
+ - `codex history` / `重置历史`:生成公开重置事件的历史时间线图片。
14
+ - 日历中的 Tibo 头像作为本地资源随插件发布,不依赖用户访问 X 或运行时下载图片。
15
+
16
+ ## 命令
17
+
18
+ 命令目前都不接收额外参数:
19
+
20
+ - `codex probability`,别名 `重置概率`:读取当前 API 快照,统计历史事件日率,并估算未来 24 小时和 7 天出现公开重置事件的概率。
21
+ - `codex event`,别名 `最近重置`:显示上一次已确认重置发生时间、距今小时数、类型和事件名称。
22
+ - `codex calendar`,别名 `重置日历`:发送本月日历 PNG;右侧详情固定显示命令执行当天(北京时间)的事件。
23
+ - `codex history`,别名 `重置历史`:发送按北京时间排列的历史重置时间线 PNG。
24
+
25
+ 日历和历史时间线命令需要安装并启用 `koishi-plugin-puppeteer`;概率、日历和历史命令都可以在配置中单独关闭。
26
+
27
+ 首次轮询只建立本地基线,不会把 API 返回的历史事件全部推送;后续轮询只处理新增事件或 `announced` → `confirmed` 的状态变化。插件重启后会重新建立基线。
28
+
29
+ ## 安装
30
+
31
+ ```bash
32
+ npm install koishi-plugin-codex-reset
33
+ ```
34
+
35
+ 如果需要日历或历史时间线图片,再安装并启用 `koishi-plugin-puppeteer`。不安装 Puppeteer 时,监控、最近重置和概率分析仍然可以使用,图片命令会给出提示。
36
+
37
+ ## 配置
38
+
39
+ - `监控接口地址`:监控 API 地址,默认 `https://aihot.news/api/v1/codex-resets`。
40
+ - `监控间隔`:轮询间隔,单位为秒,默认 300 秒。
41
+ - `请求超时时间`:单次 API 请求超时时间,单位为毫秒,默认 15000 毫秒。
42
+ - `推送目标`:推送目标表,填写“平台”和“频道或群组 ID”;平台留空会按频道尝试在线 Bot,同一平台/频道只发送一次。
43
+ - `推送重置预告`:是否推送预告。
44
+ - `推送已确认重置`:是否推送已确认的重置/重置卡事件。
45
+ - `艾特全体`:推送通知时是否艾特全体,默认关闭;需要目标平台支持全体艾特。
46
+ - `启用重置概率命令`:是否启用概率命令。
47
+ - `启用重置日历命令`:是否启用日历图片命令。
48
+ - `启用重置历史命令`:是否启用历史时间线图片命令。
49
+
50
+ API 是只读公开接口,不需要 API Key。插件使用 `ETag` 条件请求,并保留来源链接。请遵守 [AIHOT 公开使用规则](https://aihot.news/terms):公开可访问不等于自动获得对外商业使用、镜像或转售授权。
51
+
52
+ ## 开发
53
+
54
+ ```bash
55
+ npm install
56
+ npm test
57
+ ```
@@ -0,0 +1,23 @@
1
+ import type { CodexResetEvent } from './types';
2
+ export declare function getTiboAvatarDataUri(): string;
3
+ export interface CalendarPage {
4
+ setViewport(options: {
5
+ width: number;
6
+ height: number;
7
+ deviceScaleFactor?: number;
8
+ }): Promise<void>;
9
+ setContent(content: string, options?: {
10
+ waitUntil?: string;
11
+ timeout?: number;
12
+ }): Promise<void>;
13
+ screenshot(options: {
14
+ type: 'png';
15
+ fullPage: true;
16
+ }): Promise<Uint8Array>;
17
+ close(): Promise<void>;
18
+ }
19
+ export interface CalendarBrowser {
20
+ page(): Promise<CalendarPage>;
21
+ }
22
+ export declare function renderCalendarHtml(events: CodexResetEvent[], now?: Date): string;
23
+ export declare function renderCalendarImage(browser: CalendarBrowser, events: CodexResetEvent[], now?: Date): Promise<Buffer>;
@@ -0,0 +1,300 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getTiboAvatarDataUri = getTiboAvatarDataUri;
4
+ exports.renderCalendarHtml = renderCalendarHtml;
5
+ exports.renderCalendarImage = renderCalendarImage;
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const MONTH_NAMES = [
9
+ '一月', '二月', '三月', '四月', '五月', '六月',
10
+ '七月', '八月', '九月', '十月', '十一月', '十二月',
11
+ ];
12
+ const WEEK_NAMES = ['一', '二', '三', '四', '五', '六', '日'];
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ function getTiboAvatarDataUri() {
15
+ const candidates = [
16
+ (0, node_path_1.join)(__dirname, '..', 'resources', 'tibo-avatar.jpg'),
17
+ (0, node_path_1.join)(process.cwd(), 'resources', 'tibo-avatar.jpg'),
18
+ ];
19
+ for (const file of candidates) {
20
+ if ((0, node_fs_1.existsSync)(file)) {
21
+ return `data:image/jpeg;base64,${(0, node_fs_1.readFileSync)(file).toString('base64')}`;
22
+ }
23
+ }
24
+ return '';
25
+ }
26
+ const TIBO_AVATAR = getTiboAvatarDataUri();
27
+ function pad(value) {
28
+ return String(value).padStart(2, '0');
29
+ }
30
+ function dateKey(year, month, day) {
31
+ return `${year}-${pad(month + 1)}-${pad(day)}`;
32
+ }
33
+ function parseDateKey(value) {
34
+ const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
35
+ if (!match)
36
+ return null;
37
+ const [, year, month, day] = match.map(Number);
38
+ const result = new Date(Date.UTC(year, month - 1, day));
39
+ if (Number.isNaN(result.getTime()))
40
+ return null;
41
+ if (result.getUTCFullYear() !== year || result.getUTCMonth() !== month - 1 || result.getUTCDate() !== day)
42
+ return null;
43
+ return result;
44
+ }
45
+ function timestampDateKey(value) {
46
+ if (!value)
47
+ return null;
48
+ return value.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? null;
49
+ }
50
+ function beijingDateKey(now) {
51
+ const parts = new Intl.DateTimeFormat('en-US', {
52
+ timeZone: 'Asia/Shanghai',
53
+ year: 'numeric',
54
+ month: '2-digit',
55
+ day: '2-digit',
56
+ }).formatToParts(now);
57
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
58
+ return `${values.year}-${values.month}-${values.day}`;
59
+ }
60
+ function monthStart(date, offset = 0) {
61
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + offset, 1));
62
+ }
63
+ function escapeHtml(value) {
64
+ return value
65
+ .replaceAll('&', '&amp;')
66
+ .replaceAll('<', '&lt;')
67
+ .replaceAll('>', '&gt;')
68
+ .replaceAll('"', '&quot;')
69
+ .replaceAll("'", '&#39;');
70
+ }
71
+ function getEventDateRange(event) {
72
+ if (event.status === 'confirmed' && event.occurredOn)
73
+ return [event.occurredOn, event.occurredOn];
74
+ const from = timestampDateKey(event.schedule?.from) ?? timestampDateKey(event.confirmedAt) ?? timestampDateKey(event.createdAt);
75
+ if (!from)
76
+ return null;
77
+ const through = timestampDateKey(event.schedule?.through) ?? from;
78
+ return [from, through];
79
+ }
80
+ function buildMonthMarks(events, start) {
81
+ const end = monthStart(start, 1);
82
+ const marks = new Map();
83
+ for (const event of events) {
84
+ const range = getEventDateRange(event);
85
+ const from = range ? parseDateKey(range[0]) : null;
86
+ const through = range ? parseDateKey(range[1]) : null;
87
+ if (!from || !through || through < from)
88
+ continue;
89
+ const cursor = new Date(Math.max(start.getTime(), from.getTime()));
90
+ const last = new Date(Math.min(end.getTime() - DAY_MS, through.getTime()));
91
+ while (cursor <= last) {
92
+ const key = dateKey(cursor.getUTCFullYear(), cursor.getUTCMonth(), cursor.getUTCDate());
93
+ const list = marks.get(key) ?? [];
94
+ list.push({ event, type: event.type, status: event.status, label: event.label });
95
+ marks.set(key, list);
96
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
97
+ }
98
+ }
99
+ return marks;
100
+ }
101
+ function formatDateHeader(value) {
102
+ const date = parseDateKey(value);
103
+ if (!date)
104
+ return { day: value, year: '' };
105
+ return {
106
+ day: `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日`,
107
+ year: String(date.getUTCFullYear()),
108
+ };
109
+ }
110
+ function formatPublishedAt(value) {
111
+ const date = new Date(value);
112
+ if (Number.isNaN(date.getTime()))
113
+ return '';
114
+ const parts = new Intl.DateTimeFormat('zh-CN', {
115
+ timeZone: 'Asia/Shanghai',
116
+ month: 'numeric',
117
+ day: 'numeric',
118
+ hour: '2-digit',
119
+ minute: '2-digit',
120
+ }).formatToParts(date);
121
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
122
+ return `${values.month}/${values.day} ${values.hour}:${values.minute} 发布`;
123
+ }
124
+ function renderDayCell(date, month, marks, selectedDate, today) {
125
+ const key = dateKey(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
126
+ const inMonth = date.getUTCMonth() === month.getUTCMonth();
127
+ const dayMarks = marks.get(key) ?? [];
128
+ const classes = ['day'];
129
+ if (!inMonth)
130
+ classes.push('outside');
131
+ if (key === selectedDate)
132
+ classes.push('selected');
133
+ if (key === today)
134
+ classes.push('today');
135
+ if (dayMarks.some((mark) => mark.type === 'direct_reset'))
136
+ classes.push('has-reset');
137
+ if (dayMarks.some((mark) => mark.type === 'reset_credit'))
138
+ classes.push('has-credit');
139
+ const labels = dayMarks.map((mark) => `${mark.label}(${mark.status === 'confirmed' ? '已确认' : '预告'})`).join('、');
140
+ const marksHtml = dayMarks.slice(0, 2).map((mark) => {
141
+ const label = mark.type === 'direct_reset' ? '全员重置' : '发重置卡';
142
+ const className = mark.type === 'direct_reset' ? 'reset-mark' : 'credit-mark';
143
+ return `<span class="${className}">${label}</span>`;
144
+ }).join('');
145
+ return `<div class="${classes.join(' ')}" aria-label="${escapeHtml(labels)}">
146
+ <span class="number">${date.getUTCDate()}</span>
147
+ ${key === today ? '<span class="today-dot"></span>' : ''}
148
+ <div class="marks">${marksHtml}</div>
149
+ </div>`;
150
+ }
151
+ function renderCalendarGrid(month, marks, selectedDate, today) {
152
+ const firstDayOffset = (month.getUTCDay() + 6) % 7;
153
+ const daysInMonth = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth() + 1, 0)).getUTCDate();
154
+ const cellCount = Math.ceil((firstDayOffset + daysInMonth) / 7) * 7;
155
+ const firstCell = new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), 1 - firstDayOffset));
156
+ const cells = [];
157
+ for (let index = 0; index < cellCount; index++) {
158
+ const date = new Date(firstCell.getTime() + index * DAY_MS);
159
+ cells.push(renderDayCell(date, month, marks, selectedDate, today));
160
+ }
161
+ return cells.join('');
162
+ }
163
+ function renderPostCard(event, post) {
164
+ const text = post.text || post.originalText;
165
+ const schedule = event.schedule?.label ? `<div class="schedule">原预告:${escapeHtml(event.schedule.label)}</div>` : '';
166
+ const avatar = TIBO_AVATAR
167
+ ? `<img class="avatar-image" src="${TIBO_AVATAR}" alt="">`
168
+ : '<span class="avatar-fallback">T</span>';
169
+ return `<article class="post-card">
170
+ <div class="post-head">
171
+ <div class="avatar">${avatar}</div>
172
+ <div class="author"><strong>Tibo</strong><span>@thsottiaux</span></div>
173
+ <time>${escapeHtml(formatPublishedAt(post.publishedAt))}</time>
174
+ </div>
175
+ <p class="post-text">${escapeHtml(text)}</p>
176
+ ${schedule}
177
+ <div class="post-foot">${escapeHtml(post.stage)}</div>
178
+ </article>`;
179
+ }
180
+ function renderDetails(selectedDate, marks) {
181
+ const header = formatDateHeader(selectedDate);
182
+ const selectedEvents = [...new Map((marks.get(selectedDate) ?? []).map((mark) => [mark.event.id, mark.event])).values()];
183
+ const groups = selectedEvents.map((event) => {
184
+ const status = event.status === 'confirmed' ? '已重置' : '待确认';
185
+ const statusClass = event.status === 'confirmed' ? 'confirmed-state' : 'announced-state';
186
+ const posts = event.posts.slice(0, 3).map((post) => renderPostCard(event, post)).join('');
187
+ return `<section class="event-group">
188
+ <div class="event-state ${statusClass}"><span class="state-icon">${event.status === 'confirmed' ? '✓' : '◷'}</span>${status}</div>
189
+ <h2>${escapeHtml(event.title)}</h2>
190
+ ${posts || '<div class="post-card empty-post">暂无来源帖子内容</div>'}
191
+ </section>`;
192
+ }).join('');
193
+ return `<aside class="details-pane">
194
+ <div class="details-header"><h2>${header.day}</h2><span>${header.year}</span></div>
195
+ <div class="details-body">${groups || '<div class="empty-state">这一天没有记录</div>'}</div>
196
+ </aside>`;
197
+ }
198
+ function renderCalendarHtml(events, now = new Date()) {
199
+ const today = beijingDateKey(now);
200
+ const todayDate = parseDateKey(today) ?? new Date();
201
+ const month = monthStart(todayDate);
202
+ const marks = buildMonthMarks(events, month);
203
+ // A static image has no interactive selection. The detail pane always represents
204
+ // the Beijing calendar date on which the command was invoked.
205
+ const selectedDate = today;
206
+ const monthEvents = [...new Map([...marks.values()].flat().map((mark) => [mark.event.id, mark.event])).values()];
207
+ const resetCount = monthEvents.filter((event) => event.type === 'direct_reset').length;
208
+ const creditCount = monthEvents.filter((event) => event.type === 'reset_credit').length;
209
+ const monthLabel = `${month.getUTCFullYear()} 年 ${MONTH_NAMES[month.getUTCMonth()]}`;
210
+ return `<!doctype html>
211
+ <html lang="zh-CN">
212
+ <head>
213
+ <meta charset="utf-8">
214
+ <style>
215
+ * { box-sizing: border-box; }
216
+ body { margin: 0; padding: 16px; background: #f5f6f4; color: #25313a; font-family: Inter, "Microsoft YaHei", "Noto Sans CJK SC", sans-serif; }
217
+ .calendar-card { width: 1248px; min-height: 578px; display: grid; grid-template-columns: 1.46fr 1fr; overflow: hidden; border: 1px solid #d2dadd; border-radius: 14px; background: #fff; }
218
+ .calendar-pane { padding: 30px 24px 22px; }
219
+ .calendar-header { display: flex; justify-content: space-between; align-items: flex-start; padding: 0 0 18px; }
220
+ .calendar-header h1 { margin: 0; font-size: 23px; line-height: 1.3; letter-spacing: .2px; }
221
+ .month-summary { margin-top: 20px; color: #617079; font-size: 12px; }
222
+ .calendar-nav { display: flex; align-items: center; gap: 22px; padding-top: 3px; color: #617079; font-size: 11px; }
223
+ .calendar-nav .arrow { color: #617079; font-size: 20px; font-weight: 300; line-height: 1; }
224
+ .weekday-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
225
+ .weekday-row { margin-bottom: 6px; color: #65737b; font-size: 12px; text-align: center; }
226
+ .weekday-row span { padding: 8px 0; }
227
+ .calendar-grid { grid-auto-rows: 70px; }
228
+ .day { position: relative; min-width: 0; padding: 11px 12px 7px; border: 1px solid transparent; border-radius: 10px; background: #fafafa; }
229
+ .day.outside { color: #adb6b9; background: #fcfcfc; }
230
+ .day.selected { border-color: #147487; background: #edf7f6; box-shadow: inset 0 0 0 1px rgba(20, 116, 135, .12); }
231
+ .day.has-reset { background: #eef6f2; }
232
+ .day.has-credit { background: #f7f3ec; }
233
+ .day.selected.has-reset, .day.selected.has-credit { background: #e8f3f2; }
234
+ .number { display: inline-block; font-size: 14px; line-height: 1; }
235
+ .outside .number { color: #aeb7ba; }
236
+ .today-dot { position: absolute; top: 11px; right: 13px; width: 4px; height: 4px; border-radius: 50%; background: #147487; }
237
+ .marks { display: flex; flex-direction: column; gap: 2px; margin-top: 12px; overflow: hidden; }
238
+ .marks span { overflow: hidden; color: #347465; font-size: 9px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
239
+ .marks .credit-mark { color: #967044; }
240
+ .outside .marks span { color: #b6d0c9; }
241
+ .outside .marks .credit-mark { color: #cbbda6; }
242
+ .calendar-note { margin-top: 18px; color: #617079; font-size: 11px; }
243
+ .details-pane { min-width: 0; padding: 26px 24px 20px; border-left: 1px solid #d9dfe0; }
244
+ .details-header { display: flex; justify-content: space-between; align-items: center; padding: 0 0 17px; border-bottom: 1px solid #d9dfe0; }
245
+ .details-header h2 { margin: 0; font-size: 18px; }
246
+ .details-header span { color: #65737b; font-size: 11px; }
247
+ .details-body { padding-top: 18px; }
248
+ .event-group + .event-group { margin-top: 22px; }
249
+ .event-state { display: flex; align-items: center; gap: 7px; margin-bottom: 12px; font-size: 11px; font-weight: 700; }
250
+ .confirmed-state { color: #29735f; }
251
+ .announced-state { color: #98713d; }
252
+ .state-icon { font-size: 16px; font-weight: 500; }
253
+ .event-group h2 { margin: 0 0 16px; font-size: 18px; line-height: 1.35; }
254
+ .post-card { margin-bottom: 12px; padding: 16px; border: 1px solid #d2dadd; border-radius: 13px; background: #fff; }
255
+ .post-head { display: flex; align-items: center; min-width: 0; }
256
+ .avatar { display: grid; flex: 0 0 auto; width: 36px; height: 36px; place-items: center; overflow: hidden; border-radius: 50%; background: linear-gradient(145deg, #a57954, #d3aa7d); color: #fff; font-size: 18px; font-weight: 700; }
257
+ .avatar-image { display: block; width: 100%; height: 100%; object-fit: cover; }
258
+ .avatar-fallback { display: block; }
259
+ .author { display: flex; flex-direction: column; margin-left: 9px; line-height: 1.25; }
260
+ .author strong { font-size: 13px; }
261
+ .author span { color: #6f7d83; font-size: 10px; }
262
+ .post-head time { margin-left: auto; color: #6f7d83; font-size: 10px; }
263
+ .post-text { margin: 14px 0 10px; color: #27343c; font-size: 14px; line-height: 1.65; }
264
+ .schedule { margin: 0 0 10px; color: #08768b; font-size: 11px; }
265
+ .post-foot { color: #6f7d83; font-size: 10px; }
266
+ .empty-state, .empty-post { color: #7a888d; font-size: 13px; text-align: center; }
267
+ .empty-state { padding: 56px 0; }
268
+ .empty-post { padding: 24px 12px; }
269
+ </style>
270
+ </head>
271
+ <body>
272
+ <main class="calendar-card">
273
+ <section class="calendar-pane">
274
+ <header class="calendar-header">
275
+ <div><h1>${monthLabel}</h1><div class="month-summary">本月:${resetCount} 条全员重置&nbsp;&nbsp;·&nbsp;&nbsp;${creditCount} 张重置卡</div></div>
276
+ <div class="calendar-nav"><span>回到最近</span><span class="arrow">‹</span><span class="arrow">›</span></div>
277
+ </header>
278
+ <div class="weekday-row">${WEEK_NAMES.map((name) => `<span>${name}</span>`).join('')}</div>
279
+ <div class="calendar-grid">${renderCalendarGrid(month, marks, selectedDate, today)}</div>
280
+ <div class="calendar-note">按北京时间记录;发放日期不明确的事件,按来源帖子日期展示。</div>
281
+ </section>
282
+ ${renderDetails(selectedDate, marks)}
283
+ </main>
284
+ </body>
285
+ </html>`;
286
+ }
287
+ async function renderCalendarImage(browser, events, now = new Date()) {
288
+ let page = null;
289
+ try {
290
+ page = await browser.page();
291
+ await page.setViewport({ width: 1280, height: 620, deviceScaleFactor: 1 });
292
+ await page.setContent(renderCalendarHtml(events, now), { waitUntil: 'domcontentloaded', timeout: 15000 });
293
+ const image = await page.screenshot({ type: 'png', fullPage: true });
294
+ return Buffer.from(image);
295
+ }
296
+ finally {
297
+ if (page)
298
+ await page.close();
299
+ }
300
+ }
@@ -0,0 +1,4 @@
1
+ import type { CodexResetEvent } from './types';
2
+ import { CalendarBrowser } from './calendar';
3
+ export declare function renderHistoryHtml(events: CodexResetEvent[], now?: Date): string;
4
+ export declare function renderHistoryImage(browser: CalendarBrowser, events: CodexResetEvent[], now?: Date): Promise<Buffer>;
package/lib/history.js ADDED
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderHistoryHtml = renderHistoryHtml;
4
+ exports.renderHistoryImage = renderHistoryImage;
5
+ const calendar_1 = require("./calendar");
6
+ function escapeHtml(value) {
7
+ return value
8
+ .replaceAll('&', '&amp;')
9
+ .replaceAll('<', '&lt;')
10
+ .replaceAll('>', '&gt;')
11
+ .replaceAll('"', '&quot;')
12
+ .replaceAll("'", '&#39;');
13
+ }
14
+ function parseTimestamp(value) {
15
+ if (!value)
16
+ return null;
17
+ const timestamp = Date.parse(value);
18
+ return Number.isFinite(timestamp) ? timestamp : null;
19
+ }
20
+ function eventTimestamp(event) {
21
+ const values = [
22
+ event.confirmedAt,
23
+ event.occurredOn ? `${event.occurredOn}T12:00:00+08:00` : null,
24
+ event.schedule?.from,
25
+ event.createdAt,
26
+ ];
27
+ for (const value of values) {
28
+ const timestamp = parseTimestamp(value);
29
+ if (timestamp !== null)
30
+ return timestamp;
31
+ }
32
+ return 0;
33
+ }
34
+ function dateLabel(timestamp) {
35
+ const parts = new Intl.DateTimeFormat('zh-CN', {
36
+ timeZone: 'Asia/Shanghai',
37
+ year: 'numeric',
38
+ month: '2-digit',
39
+ day: '2-digit',
40
+ }).formatToParts(new Date(timestamp));
41
+ const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
42
+ return `${Number(values.year)}年${Number(values.month)}月${Number(values.day)}日`;
43
+ }
44
+ function timeLabel(timestamp) {
45
+ return new Intl.DateTimeFormat('zh-CN', {
46
+ timeZone: 'Asia/Shanghai',
47
+ hour: '2-digit',
48
+ minute: '2-digit',
49
+ hour12: false,
50
+ }).format(new Date(timestamp));
51
+ }
52
+ function formatGeneratedAt(now) {
53
+ return new Intl.DateTimeFormat('zh-CN', {
54
+ timeZone: 'Asia/Shanghai',
55
+ year: 'numeric',
56
+ month: '2-digit',
57
+ day: '2-digit',
58
+ hour: '2-digit',
59
+ minute: '2-digit',
60
+ hour12: false,
61
+ }).format(now);
62
+ }
63
+ function typeLabel(event) {
64
+ return event.type === 'direct_reset' ? '全员重置' : '重置卡';
65
+ }
66
+ function statusLabel(event) {
67
+ return event.status === 'confirmed' ? '已确认' : '预告';
68
+ }
69
+ function renderPostPreview(event) {
70
+ const post = event.posts[0];
71
+ if (!post)
72
+ return '<div class="post-preview empty">暂无来源帖子内容</div>';
73
+ const text = post.text || post.originalText;
74
+ const avatar = (0, calendar_1.getTiboAvatarDataUri)()
75
+ ? `<img class="avatar-image" src="${(0, calendar_1.getTiboAvatarDataUri)()}" alt="">`
76
+ : '<span class="avatar-fallback">T</span>';
77
+ return `<div class="post-preview">
78
+ <div class="post-head">
79
+ <div class="avatar">${avatar}</div>
80
+ <div class="author"><strong>Tibo</strong><span>@thsottiaux</span></div>
81
+ <time>${escapeHtml(timeLabel(parseTimestamp(post.publishedAt) ?? eventTimestamp(event)))} 发布</time>
82
+ </div>
83
+ <p>${escapeHtml(text)}</p>
84
+ </div>`;
85
+ }
86
+ function renderTimelineItem(event) {
87
+ const timestamp = eventTimestamp(event);
88
+ const statusClass = event.status === 'confirmed' ? 'confirmed' : 'announced';
89
+ const schedule = event.schedule?.label ? `<div class="schedule">时间:${escapeHtml(event.schedule.label)}</div>` : '';
90
+ return `<article class="timeline-item">
91
+ <div class="timeline-date"><strong>${escapeHtml(dateLabel(timestamp))}</strong><span>${escapeHtml(timeLabel(timestamp))}</span></div>
92
+ <div class="timeline-dot ${statusClass}"></div>
93
+ <div class="event-card">
94
+ <div class="event-meta"><span class="type-label">${typeLabel(event)}</span><span class="status ${statusClass}">${statusLabel(event)}</span></div>
95
+ <h2>${escapeHtml(event.title)}</h2>
96
+ ${schedule}
97
+ ${renderPostPreview(event)}
98
+ </div>
99
+ </article>`;
100
+ }
101
+ function renderHistoryHtml(events, now = new Date()) {
102
+ const sorted = [...events].sort((a, b) => eventTimestamp(b) - eventTimestamp(a));
103
+ const confirmed = events.filter((event) => event.status === 'confirmed');
104
+ const directResetCount = confirmed.filter((event) => event.type === 'direct_reset').length;
105
+ const resetCreditCount = confirmed.filter((event) => event.type === 'reset_credit').length;
106
+ const announcedCount = events.filter((event) => event.status === 'announced').length;
107
+ const firstTimestamp = sorted.at(-1) ? eventTimestamp(sorted.at(-1)) : now.getTime();
108
+ const lastTimestamp = sorted[0] ? eventTimestamp(sorted[0]) : now.getTime();
109
+ const period = `${dateLabel(firstTimestamp)} 至 ${dateLabel(lastTimestamp)}`;
110
+ return `<!doctype html>
111
+ <html lang="zh-CN">
112
+ <head>
113
+ <meta charset="utf-8">
114
+ <style>
115
+ * { box-sizing: border-box; }
116
+ html, body { margin: 0; padding: 0; background: #f4f6f5; color: #27343c; font-family: "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif; }
117
+ body { padding: 24px; }
118
+ .history-shell { max-width: 1280px; margin: 0 auto; padding: 28px 30px 34px; border: 1px solid #d2dadd; border-radius: 16px; background: #fff; }
119
+ .page-header { display: flex; align-items: flex-start; justify-content: space-between; padding-bottom: 20px; border-bottom: 1px solid #e0e5e6; }
120
+ .eyebrow { color: #08768b; font-size: 12px; letter-spacing: .08em; }
121
+ h1 { margin: 7px 0 8px; font-size: 28px; line-height: 1.2; }
122
+ .subtitle { margin: 0; color: #6f7d83; font-size: 13px; }
123
+ .generated { color: #6f7d83; font-size: 11px; text-align: right; line-height: 1.6; }
124
+ .layout { display: grid; grid-template-columns: minmax(0, 1fr) 286px; gap: 28px; padding-top: 26px; }
125
+ .timeline { position: relative; }
126
+ .timeline::before { content: ""; position: absolute; left: 112px; top: 16px; bottom: 16px; width: 1px; background: #cbd7d9; }
127
+ .timeline-item { position: relative; display: grid; grid-template-columns: 94px 36px minmax(0, 1fr); gap: 0; margin-bottom: 18px; }
128
+ .timeline-date { padding-top: 17px; padding-right: 14px; text-align: right; line-height: 1.5; }
129
+ .timeline-date strong { display: block; color: #27343c; font-size: 11px; white-space: nowrap; }
130
+ .timeline-date span { color: #89969a; font-size: 10px; }
131
+ .timeline-dot { position: relative; z-index: 1; width: 12px; height: 12px; margin: 22px auto 0; border: 3px solid #fff; border-radius: 50%; background: #08768b; box-shadow: 0 0 0 1px #08768b; }
132
+ .timeline-dot.announced { background: #b8863b; box-shadow: 0 0 0 1px #b8863b; }
133
+ .event-card { padding: 15px 17px 16px; border: 1px solid #d2dadd; border-radius: 13px; background: #fff; }
134
+ .event-meta { display: flex; gap: 7px; align-items: center; margin-bottom: 7px; }
135
+ .type-label, .status { display: inline-block; padding: 3px 7px; border-radius: 999px; font-size: 10px; }
136
+ .type-label { color: #08768b; background: #edf7f8; }
137
+ .status.confirmed { color: #177255; background: #edf8f1; }
138
+ .status.announced { color: #8b6426; background: #fbf4e6; }
139
+ .event-card h2 { margin: 0 0 10px; font-size: 17px; line-height: 1.35; }
140
+ .schedule { margin-bottom: 10px; color: #08768b; font-size: 11px; }
141
+ .post-preview { padding: 11px 12px; border: 1px solid #e0e5e6; border-radius: 10px; background: #fbfcfc; }
142
+ .post-preview.empty { color: #89969a; font-size: 12px; }
143
+ .post-head { display: flex; align-items: center; min-width: 0; }
144
+ .avatar { display: grid; flex: 0 0 auto; width: 28px; height: 28px; place-items: center; overflow: hidden; border-radius: 50%; background: linear-gradient(145deg, #a57954, #d3aa7d); color: #fff; font-size: 14px; font-weight: 700; }
145
+ .avatar-image { display: block; width: 100%; height: 100%; object-fit: cover; }
146
+ .avatar-fallback { display: block; }
147
+ .author { display: flex; flex-direction: column; margin-left: 8px; line-height: 1.2; }
148
+ .author strong { font-size: 11px; }
149
+ .author span { color: #89969a; font-size: 9px; }
150
+ .post-head time { margin-left: auto; color: #89969a; font-size: 10px; }
151
+ .post-preview p { margin: 10px 0 0; color: #27343c; font-size: 12px; line-height: 1.55; }
152
+ .summary { align-self: start; padding: 18px; border: 1px solid #d2dadd; border-radius: 13px; background: #fbfcfc; }
153
+ .summary h2 { margin: 0 0 15px; font-size: 17px; }
154
+ .summary-period { margin-bottom: 18px; color: #6f7d83; font-size: 11px; line-height: 1.5; }
155
+ .stat { display: flex; align-items: baseline; justify-content: space-between; padding: 11px 0; border-top: 1px solid #e0e5e6; }
156
+ .stat span { color: #6f7d83; font-size: 12px; }
157
+ .stat strong { color: #27343c; font-size: 22px; }
158
+ .summary-note { margin: 17px 0 0; color: #89969a; font-size: 10px; line-height: 1.6; }
159
+ .empty { padding: 54px 0; color: #89969a; text-align: center; }
160
+ </style>
161
+ </head>
162
+ <body>
163
+ <main class="history-shell">
164
+ <header class="page-header">
165
+ <div><div class="eyebrow">CODEX RESET MONITOR</div><h1>Codex 重置历史</h1><p class="subtitle">按北京时间排列的公开重置事件时间线</p></div>
166
+ <div class="generated">生成时间<br>${escapeHtml(formatGeneratedAt(now))}</div>
167
+ </header>
168
+ <div class="layout">
169
+ <section class="timeline">
170
+ ${sorted.length ? sorted.map(renderTimelineItem).join('') : '<div class="empty">暂无历史事件</div>'}
171
+ </section>
172
+ <aside class="summary">
173
+ <h2>历史概览</h2>
174
+ <div class="summary-period">统计区间<br>${escapeHtml(period)}</div>
175
+ <div class="stat"><span>事件总数</span><strong>${events.length}</strong></div>
176
+ <div class="stat"><span>已确认</span><strong>${confirmed.length}</strong></div>
177
+ <div class="stat"><span>全员重置</span><strong>${directResetCount}</strong></div>
178
+ <div class="stat"><span>重置卡</span><strong>${resetCreditCount}</strong></div>
179
+ <div class="stat"><span>待确认预告</span><strong>${announcedCount}</strong></div>
180
+ <p class="summary-note">数据来自 AIHOT 公开快照。时间线仅展示公开事件,不代表账户一定获得重置。</p>
181
+ </aside>
182
+ </div>
183
+ </main>
184
+ </body>
185
+ </html>`;
186
+ }
187
+ async function renderHistoryImage(browser, events, now = new Date()) {
188
+ let page = null;
189
+ try {
190
+ page = await browser.page();
191
+ await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: 1 });
192
+ await page.setContent(renderHistoryHtml(events, now), { waitUntil: 'domcontentloaded', timeout: 15000 });
193
+ const image = await page.screenshot({ type: 'png', fullPage: true });
194
+ return Buffer.from(image);
195
+ }
196
+ finally {
197
+ if (page)
198
+ await page.close();
199
+ }
200
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { Context, Schema } from 'koishi';
2
+ import type { Config as PluginConfig } from './types';
3
+ export declare const name = "codex-reset";
4
+ export declare const inject: {
5
+ optional: string[];
6
+ };
7
+ export type Config = PluginConfig;
8
+ export declare const Config: Schema<Config>;
9
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,364 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Config = exports.inject = exports.name = void 0;
4
+ exports.apply = apply;
5
+ const koishi_1 = require("koishi");
6
+ const calendar_1 = require("./calendar");
7
+ const history_1 = require("./history");
8
+ const stats_1 = require("./stats");
9
+ exports.name = 'codex-reset';
10
+ exports.inject = {
11
+ optional: ['puppeteer'],
12
+ };
13
+ exports.Config = koishi_1.Schema.object({
14
+ apiUrl: koishi_1.Schema.string()
15
+ .default('https://aihot.news/api/v1/codex-resets')
16
+ .description('监控接口地址。通常不需要修改。'),
17
+ pollInterval: koishi_1.Schema.number()
18
+ .min(60)
19
+ .default(300)
20
+ .description('监控间隔,单位为秒。AIHOT 推荐约 5 分钟轮询一次。'),
21
+ requestTimeout: koishi_1.Schema.number()
22
+ .min(5000)
23
+ .default(15000)
24
+ .description('请求超时时间,单位为毫秒。'),
25
+ targets: koishi_1.Schema.array(koishi_1.Schema.object({
26
+ platform: koishi_1.Schema.string()
27
+ .default('')
28
+ .description('平台,例如 onebot、discord。留空时会尝试所有在线 Bot。'),
29
+ channelId: koishi_1.Schema.string()
30
+ .required()
31
+ .description('频道或群组 ID。建议同时填写平台,避免多账号重复发送。'),
32
+ })).role('table').default([]).description('推送目标列表。'),
33
+ pushAnnouncements: koishi_1.Schema.boolean()
34
+ .default(true)
35
+ .description('发现新的重置预告时推送。'),
36
+ pushConfirmedResets: koishi_1.Schema.boolean()
37
+ .default(true)
38
+ .description('发现重置或重置卡已确认时推送。'),
39
+ mentionAll: koishi_1.Schema.boolean()
40
+ .default(false)
41
+ .description('推送通知时是否艾特全体。需要平台和频道支持全体艾特。'),
42
+ enableProbabilityCommand: koishi_1.Schema.boolean()
43
+ .default(true)
44
+ .description('启用 codex-reset.probability(别名:重置概率)。'),
45
+ enableCalendarCommand: koishi_1.Schema.boolean()
46
+ .default(true)
47
+ .description('启用日历图片命令,需要 Puppeteer 服务生成 PNG。'),
48
+ enableHistoryCommand: koishi_1.Schema.boolean()
49
+ .default(true)
50
+ .description('启用历史时间线图片命令,需要 Puppeteer 服务生成 PNG。'),
51
+ }).i18n({
52
+ 'zh-CN': {
53
+ apiUrl: '监控接口地址',
54
+ pollInterval: '监控间隔',
55
+ requestTimeout: '请求超时时间',
56
+ targets: {
57
+ $description: '推送目标',
58
+ platform: '平台',
59
+ channelId: '频道或群组 ID',
60
+ },
61
+ pushAnnouncements: '推送重置预告',
62
+ pushConfirmedResets: '推送已确认重置',
63
+ mentionAll: '艾特全体',
64
+ enableProbabilityCommand: '启用重置概率命令',
65
+ enableCalendarCommand: '启用重置日历命令',
66
+ enableHistoryCommand: '启用重置历史命令',
67
+ },
68
+ });
69
+ function isRecord(value) {
70
+ return typeof value === 'object' && value !== null;
71
+ }
72
+ function isDateTime(value) {
73
+ return typeof value === 'string' && Number.isFinite(Date.parse(value));
74
+ }
75
+ function isDateKey(value) {
76
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
77
+ return false;
78
+ const [year, month, day] = value.split('-').map(Number);
79
+ const date = new Date(Date.UTC(year, month - 1, day));
80
+ return date.getUTCFullYear() === year
81
+ && date.getUTCMonth() === month - 1
82
+ && date.getUTCDate() === day;
83
+ }
84
+ function isResetEvent(value) {
85
+ if (!isRecord(value))
86
+ return false;
87
+ if (typeof value.id !== 'string' || typeof value.label !== 'string' || typeof value.title !== 'string' || typeof value.scope !== 'string')
88
+ return false;
89
+ if (value.type !== 'direct_reset' && value.type !== 'reset_credit')
90
+ return false;
91
+ if (value.status !== 'announced' && value.status !== 'confirmed')
92
+ return false;
93
+ if (value.confirmationBasis !== null && value.confirmationBasis !== 'source_post' && value.confirmationBasis !== 'receipt_review')
94
+ return false;
95
+ if (typeof value.url !== 'string')
96
+ return false;
97
+ if (!isDateTime(value.createdAt) || !isDateTime(value.updatedAt))
98
+ return false;
99
+ if (value.confirmedAt !== null && !isDateTime(value.confirmedAt))
100
+ return false;
101
+ if (value.occurredOn !== null && !isDateKey(value.occurredOn))
102
+ return false;
103
+ if (value.schedule !== null) {
104
+ if (!isRecord(value.schedule)
105
+ || !['exact', 'approximate', 'deadline', 'date', 'window'].includes(String(value.schedule.precision))
106
+ || !isDateTime(value.schedule.from)
107
+ || !isDateTime(value.schedule.through)
108
+ || typeof value.schedule.label !== 'string')
109
+ return false;
110
+ }
111
+ if (!Array.isArray(value.posts))
112
+ return false;
113
+ return value.posts.every((post) => {
114
+ if (!isRecord(post))
115
+ return false;
116
+ return typeof post.id === 'string'
117
+ && isDateTime(post.publishedAt)
118
+ && typeof post.stage === 'string'
119
+ && typeof post.text === 'string'
120
+ && typeof post.originalText === 'string'
121
+ && typeof post.url === 'string';
122
+ });
123
+ }
124
+ function isSnapshot(value) {
125
+ if (!isRecord(value))
126
+ return false;
127
+ return value.schemaVersion === 1
128
+ && value.timezone === 'Asia/Shanghai'
129
+ && isDateTime(value.historyFrom)
130
+ && typeof value.count === 'number'
131
+ && (value.checkedAt === null || isDateTime(value.checkedAt))
132
+ && Array.isArray(value.events)
133
+ && value.events.every(isResetEvent);
134
+ }
135
+ function eventSummary(event) {
136
+ const kind = event.type === 'direct_reset' ? 'Codex 额度重置' : 'Codex 重置卡发放';
137
+ const status = event.status === 'announced' ? '预告' : '已确认';
138
+ const scope = event.scope ? `(范围:${event.scope})` : '';
139
+ const schedule = event.schedule?.label ? `\n时间:${event.schedule.label}` : '';
140
+ const source = event.posts[0]?.url ? `\n来源:${event.posts[0].url}` : '';
141
+ return `${kind}${status}${scope}${schedule}${source}`;
142
+ }
143
+ async function sendToTargets(ctx, targets, content) {
144
+ let sent = 0;
145
+ const sentBindings = new Set();
146
+ for (const target of targets) {
147
+ const channelId = target.channelId?.trim();
148
+ if (!channelId)
149
+ continue;
150
+ const bots = [...ctx.bots].filter((bot) => !target.platform?.trim() || bot.platform === target.platform.trim());
151
+ for (const bot of bots) {
152
+ // A blank-platform target already covers explicit platform targets. Send only once
153
+ // per platform/channel binding, while still trying another bot if the first fails.
154
+ const key = `${bot.platform}\u0000${channelId}`;
155
+ if (sentBindings.has(key))
156
+ continue;
157
+ try {
158
+ await bot.sendMessage(channelId, content);
159
+ sentBindings.add(key);
160
+ sent++;
161
+ }
162
+ catch (error) {
163
+ ctx.logger('codex-reset').warn(`发送到 ${bot.platform}:${target.channelId} 失败:${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ }
166
+ }
167
+ return sent;
168
+ }
169
+ class CodexResetMonitor {
170
+ ctx;
171
+ config;
172
+ snapshot = null;
173
+ etag = null;
174
+ pollPromise = null;
175
+ disposed = false;
176
+ constructor(ctx, config) {
177
+ this.ctx = ctx;
178
+ this.config = config;
179
+ }
180
+ get currentSnapshot() {
181
+ return this.snapshot;
182
+ }
183
+ get hasPuppeteer() {
184
+ return Boolean(this.ctx.puppeteer);
185
+ }
186
+ async ensureSnapshot() {
187
+ if (!this.snapshot)
188
+ await this.poll();
189
+ return this.snapshot;
190
+ }
191
+ async poll() {
192
+ if (this.disposed)
193
+ return;
194
+ if (this.pollPromise)
195
+ return this.pollPromise;
196
+ this.pollPromise = this.performPoll();
197
+ try {
198
+ await this.pollPromise;
199
+ }
200
+ finally {
201
+ this.pollPromise = null;
202
+ }
203
+ }
204
+ dispose() {
205
+ this.disposed = true;
206
+ }
207
+ async performPoll() {
208
+ try {
209
+ const result = await this.fetchSnapshot();
210
+ if (result.notModified || !result.snapshot)
211
+ return;
212
+ const previous = this.snapshot;
213
+ this.snapshot = result.snapshot;
214
+ this.etag = result.etag ?? this.etag;
215
+ // The API returns a complete, mutable snapshot. The first fetch is a baseline.
216
+ if (!previous)
217
+ return;
218
+ await this.notifyChanges(previous, result.snapshot);
219
+ }
220
+ catch (error) {
221
+ this.ctx.logger('codex-reset').warn(`获取 AIHOT 重置事件失败:${error instanceof Error ? error.message : String(error)}`);
222
+ }
223
+ }
224
+ async createCalendarImage() {
225
+ const snapshot = await this.ensureSnapshot();
226
+ const browser = this.ctx.puppeteer;
227
+ if (!snapshot || !browser)
228
+ return null;
229
+ return (0, calendar_1.renderCalendarImage)(browser, snapshot.events);
230
+ }
231
+ async createHistoryImage() {
232
+ const snapshot = await this.ensureSnapshot();
233
+ const browser = this.ctx.puppeteer;
234
+ if (!snapshot || !browser)
235
+ return null;
236
+ return (0, history_1.renderHistoryImage)(browser, snapshot.events);
237
+ }
238
+ async fetchSnapshot(ignoreEtag = false) {
239
+ const controller = new AbortController();
240
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeout);
241
+ try {
242
+ const headers = {
243
+ Accept: 'application/json',
244
+ 'User-Agent': 'koishi-plugin-codex-reset/0.1.0',
245
+ };
246
+ if (!ignoreEtag && this.etag && this.snapshot)
247
+ headers['If-None-Match'] = this.etag;
248
+ const response = await fetch(this.config.apiUrl, {
249
+ method: 'GET',
250
+ headers,
251
+ signal: controller.signal,
252
+ });
253
+ if (response.status === 304 && this.snapshot) {
254
+ return { snapshot: null, etag: this.etag, notModified: true };
255
+ }
256
+ if (response.status === 304)
257
+ return this.fetchSnapshot(true);
258
+ if (!response.ok) {
259
+ const detail = (await response.text()).slice(0, 300).replace(/\s+/g, ' ');
260
+ throw new Error(`HTTP ${response.status}${detail ? `:${detail}` : ''}`);
261
+ }
262
+ const body = await response.json();
263
+ if (!isSnapshot(body))
264
+ throw new Error('响应格式不符合 Codex 重置 API v1');
265
+ return {
266
+ snapshot: body,
267
+ etag: response.headers.get('etag'),
268
+ notModified: false,
269
+ };
270
+ }
271
+ finally {
272
+ clearTimeout(timeout);
273
+ }
274
+ }
275
+ async notifyChanges(previous, current) {
276
+ const previousById = new Map(previous.events.map((event) => [event.id, event]));
277
+ const messages = [];
278
+ for (const event of current.events) {
279
+ const oldEvent = previousById.get(event.id);
280
+ if (event.status === 'announced' && this.config.pushAnnouncements && !oldEvent) {
281
+ messages.push(`📢 Codex 重置预告\n${eventSummary(event)}`);
282
+ }
283
+ if (event.status === 'confirmed' && this.config.pushConfirmedResets && (!oldEvent || oldEvent.status === 'announced')) {
284
+ messages.push(`✅ Codex 重置事件\n${eventSummary(event)}`);
285
+ }
286
+ }
287
+ for (const message of messages) {
288
+ if (!this.config.targets.length) {
289
+ this.ctx.logger('codex-reset').info(`检测到新事件,但未配置推送目标:\n${message}`);
290
+ continue;
291
+ }
292
+ const content = this.config.mentionAll
293
+ ? [(0, koishi_1.h)('at', { type: 'all' }), koishi_1.h.text(`\n${message}`)]
294
+ : message;
295
+ await sendToTargets(this.ctx, this.config.targets, content);
296
+ }
297
+ }
298
+ }
299
+ function apply(ctx, config) {
300
+ const monitor = new CodexResetMonitor(ctx, config);
301
+ ctx.on('ready', () => {
302
+ void monitor.poll();
303
+ ctx.setInterval(() => void monitor.poll(), config.pollInterval * 1000);
304
+ });
305
+ ctx.on('dispose', () => monitor.dispose());
306
+ ctx.command('codex probability', '分析 Codex 重置事件的历史概率')
307
+ .alias('codex-reset.probability')
308
+ .alias('重置概率')
309
+ .action(async () => {
310
+ if (!config.enableProbabilityCommand)
311
+ return '重置概率命令已关闭。';
312
+ const snapshot = await monitor.ensureSnapshot();
313
+ if (!snapshot)
314
+ return '暂时无法获取 Codex 重置事件数据,请稍后再试。';
315
+ return (0, stats_1.formatProbability)((0, stats_1.analyzeProbability)(snapshot));
316
+ });
317
+ ctx.command('codex event', '查看上一次 Codex 重置距今多久')
318
+ .alias('codex-reset.event')
319
+ .alias('最近重置')
320
+ .action(async () => {
321
+ const snapshot = await monitor.ensureSnapshot();
322
+ if (!snapshot)
323
+ return '暂时无法获取 Codex 重置事件数据,请稍后再试。';
324
+ return (0, stats_1.formatLatestReset)(snapshot);
325
+ });
326
+ ctx.command('codex calendar', '发送本月的 Codex 重置日历图片')
327
+ .alias('codex-reset.calendar')
328
+ .alias('重置日历')
329
+ .action(async () => {
330
+ if (!config.enableCalendarCommand)
331
+ return '重置日历命令已关闭。';
332
+ if (!monitor.hasPuppeteer)
333
+ return '发送日历图片需要先安装并启用 koishi-plugin-puppeteer。';
334
+ try {
335
+ const image = await monitor.createCalendarImage();
336
+ if (!image)
337
+ return '暂时无法生成日历图片,请稍后再试。';
338
+ return koishi_1.h.image(image, 'image/png');
339
+ }
340
+ catch (error) {
341
+ ctx.logger('codex-reset').warn(`生成日历图片失败:${error instanceof Error ? error.message : String(error)}`);
342
+ return '日历图片生成失败,请检查 Puppeteer 服务和中文字体。';
343
+ }
344
+ });
345
+ ctx.command('codex history', '发送 Codex 重置历史时间线图片')
346
+ .alias('codex-reset.history')
347
+ .alias('重置历史')
348
+ .action(async () => {
349
+ if (!config.enableHistoryCommand)
350
+ return '重置历史命令已关闭。';
351
+ if (!monitor.hasPuppeteer)
352
+ return '发送历史时间线图片需要先安装并启用 koishi-plugin-puppeteer。';
353
+ try {
354
+ const image = await monitor.createHistoryImage();
355
+ if (!image)
356
+ return '暂时无法生成历史时间线图片,请稍后再试。';
357
+ return koishi_1.h.image(image, 'image/png');
358
+ }
359
+ catch (error) {
360
+ ctx.logger('codex-reset').warn(`生成历史时间线图片失败:${error instanceof Error ? error.message : String(error)}`);
361
+ return '历史时间线图片生成失败,请检查 Puppeteer 服务和中文字体。';
362
+ }
363
+ });
364
+ }
package/lib/stats.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { CodexResetEvent, CodexResetsResponse } from './types';
2
+ export interface ProbabilitySummary {
3
+ confirmedCount: number;
4
+ directResetCount: number;
5
+ resetCreditCount: number;
6
+ announcedCount: number;
7
+ activeDays: number;
8
+ observedDays: number;
9
+ dailyEventDayRate: number;
10
+ next24hProbability: number;
11
+ next7dProbability: number;
12
+ lastConfirmedDate: string | null;
13
+ }
14
+ export interface LatestResetSummary {
15
+ event: CodexResetEvent;
16
+ timestamp: number;
17
+ approximate: boolean;
18
+ }
19
+ export declare function findLatestConfirmedReset(response: CodexResetsResponse, now?: Date): LatestResetSummary | null;
20
+ export declare function formatLatestReset(response: CodexResetsResponse, now?: Date): string;
21
+ /**
22
+ * Estimate event-day probabilities from the public historical snapshot.
23
+ * This is deliberately an empirical Poisson estimate, not an official forecast.
24
+ */
25
+ export declare function analyzeProbability(response: CodexResetsResponse, now?: Date): ProbabilitySummary;
26
+ export declare function formatProbability(summary: ProbabilitySummary): string;
package/lib/stats.js ADDED
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findLatestConfirmedReset = findLatestConfirmedReset;
4
+ exports.formatLatestReset = formatLatestReset;
5
+ exports.analyzeProbability = analyzeProbability;
6
+ exports.formatProbability = formatProbability;
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ function dateKeyFromTimestamp(value) {
9
+ if (!value)
10
+ return null;
11
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
12
+ return match?.[1] ?? null;
13
+ }
14
+ function validDateKey(value) {
15
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value))
16
+ return false;
17
+ const [year, month, day] = value.split('-').map(Number);
18
+ const date = new Date(Date.UTC(year, month - 1, day));
19
+ return date.getUTCFullYear() === year
20
+ && date.getUTCMonth() === month - 1
21
+ && date.getUTCDate() === day;
22
+ }
23
+ function normalizedDateKey(value) {
24
+ return validDateKey(value) ? value : null;
25
+ }
26
+ function eventDateKey(event) {
27
+ return normalizedDateKey(event.occurredOn)
28
+ ?? normalizedDateKey(dateKeyFromTimestamp(event.confirmedAt))
29
+ ?? normalizedDateKey(dateKeyFromTimestamp(event.schedule?.from))
30
+ ?? normalizedDateKey(dateKeyFromTimestamp(event.createdAt));
31
+ }
32
+ function parseTimestamp(value) {
33
+ if (!value)
34
+ return null;
35
+ const timestamp = Date.parse(value);
36
+ return Number.isFinite(timestamp) ? timestamp : null;
37
+ }
38
+ function dateKeyTimestamp(value) {
39
+ const timestamp = Date.parse(`${value}T00:00:00+08:00`);
40
+ return Number.isFinite(timestamp) ? timestamp : null;
41
+ }
42
+ function inclusiveDays(start, end) {
43
+ const startTime = parseTimestamp(start);
44
+ const endTime = parseTimestamp(end);
45
+ if (startTime === null || endTime === null || endTime < startTime)
46
+ return 1;
47
+ return Math.max(1, Math.floor((endTime - startTime) / DAY_MS) + 1);
48
+ }
49
+ function percentage(value) {
50
+ if (!Number.isFinite(value))
51
+ return '—';
52
+ if (value < 0.01)
53
+ return '<1%';
54
+ return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
55
+ }
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
+ if (event.occurredOn) {
63
+ const timestamp = Date.parse(`${event.occurredOn}T12:00:00+08:00`);
64
+ if (Number.isFinite(timestamp))
65
+ return { timestamp, approximate: true };
66
+ }
67
+ for (const value of [event.schedule?.from, event.createdAt]) {
68
+ const timestamp = parseTimestamp(value);
69
+ if (timestamp !== null)
70
+ return { timestamp, approximate: true };
71
+ }
72
+ return null;
73
+ }
74
+ function findLatestConfirmedReset(response, now = new Date()) {
75
+ const nowTimestamp = now.getTime();
76
+ return response.events
77
+ .filter((event) => event.status === 'confirmed')
78
+ .map((event) => {
79
+ const value = eventTimestamp(event);
80
+ return value ? { event, ...value } : null;
81
+ })
82
+ .filter((value) => value !== null && value.timestamp <= nowTimestamp)
83
+ .sort((a, b) => b.timestamp - a.timestamp)[0] ?? null;
84
+ }
85
+ function formatLatestReset(response, now = new Date()) {
86
+ const latest = findLatestConfirmedReset(response, now);
87
+ if (!latest)
88
+ return '暂时没有已确认的 Codex 重置事件。';
89
+ const elapsedHours = Math.max(0, (now.getTime() - latest.timestamp) / (60 * 60 * 1000));
90
+ const elapsed = elapsedHours < 1
91
+ ? `约 ${Math.max(1, Math.round(elapsedHours * 60))} 分钟前`
92
+ : `约 ${Math.round(elapsedHours * 10) / 10} 小时前`;
93
+ const date = new Intl.DateTimeFormat('zh-CN', {
94
+ timeZone: 'Asia/Shanghai',
95
+ year: 'numeric',
96
+ month: '2-digit',
97
+ day: '2-digit',
98
+ hour: '2-digit',
99
+ minute: '2-digit',
100
+ hour12: false,
101
+ }).format(new Date(latest.timestamp));
102
+ const type = latest.event.type === 'direct_reset' ? '全员重置' : '重置卡';
103
+ const approximate = latest.approximate ? '(时间按公开日期估算)' : '';
104
+ return [
105
+ '最近一次 Codex 重置',
106
+ `上一次重置:${elapsed}${approximate}`,
107
+ `发生时间:${date}(北京时间)`,
108
+ `类型:${type}`,
109
+ `事件:${latest.event.title}`,
110
+ ].join('\n');
111
+ }
112
+ /**
113
+ * Estimate event-day probabilities from the public historical snapshot.
114
+ * This is deliberately an empirical Poisson estimate, not an official forecast.
115
+ */
116
+ function analyzeProbability(response, now = new Date()) {
117
+ const historyStart = parseTimestamp(response.historyFrom) ?? Number.NEGATIVE_INFINITY;
118
+ const nowTimestamp = now.getTime();
119
+ const confirmed = response.events.filter((event) => {
120
+ if (event.status !== 'confirmed')
121
+ return false;
122
+ const date = eventDateKey(event);
123
+ const timestamp = date ? dateKeyTimestamp(date) : null;
124
+ return timestamp !== null && timestamp >= historyStart && timestamp <= nowTimestamp;
125
+ });
126
+ const announcedCount = response.events.filter((event) => event.status === 'announced').length;
127
+ const activeDates = new Set(confirmed.map(eventDateKey).filter((value) => Boolean(value)));
128
+ const latest = now.toISOString();
129
+ const observedDays = inclusiveDays(response.historyFrom, latest);
130
+ const dailyEventDayRate = activeDates.size / observedDays;
131
+ return {
132
+ confirmedCount: confirmed.length,
133
+ directResetCount: confirmed.filter((event) => event.type === 'direct_reset').length,
134
+ resetCreditCount: confirmed.filter((event) => event.type === 'reset_credit').length,
135
+ announcedCount,
136
+ activeDays: activeDates.size,
137
+ observedDays,
138
+ dailyEventDayRate,
139
+ next24hProbability: 1 - Math.exp(-dailyEventDayRate),
140
+ next7dProbability: 1 - Math.exp(-dailyEventDayRate * 7),
141
+ lastConfirmedDate: [...activeDates].sort().at(-1) ?? null,
142
+ };
143
+ }
144
+ function formatProbability(summary) {
145
+ const lines = [
146
+ 'Codex 重置事件概率分析(历史统计)',
147
+ `样本:${summary.observedDays} 天,已确认事件 ${summary.confirmedCount} 条,发生过事件的日期 ${summary.activeDays} 天。`,
148
+ `其中:全员重置 ${summary.directResetCount} 条,重置卡 ${summary.resetCreditCount} 条,待确认预告 ${summary.announcedCount} 条。`,
149
+ ];
150
+ if (summary.confirmedCount === 0 || summary.activeDays === 0) {
151
+ lines.push('有效样本不足,暂时无法估算概率。');
152
+ }
153
+ else {
154
+ lines.push(`历史事件日率:${(summary.dailyEventDayRate * 100).toFixed(2)}%/天。`);
155
+ lines.push(`未来 24 小时至少出现一个公开重置事件的粗略概率:${percentage(summary.next24hProbability)}。`);
156
+ lines.push(`未来 7 天至少出现一个公开重置事件的粗略概率:${percentage(summary.next7dProbability)}。`);
157
+ lines.push(`最近确认日期:${summary.lastConfirmedDate ?? '未知'}。`);
158
+ }
159
+ lines.push('说明:这是基于 AIHOT 当前历史快照的经验估计,不是官方预测,也不代表你的账户一定会获得重置。');
160
+ return lines.join('\n');
161
+ }
package/lib/types.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ export type ResetEventType = 'direct_reset' | 'reset_credit';
2
+ export type ResetEventStatus = 'announced' | 'confirmed';
3
+ export interface ResetSchedule {
4
+ precision: 'exact' | 'approximate' | 'deadline' | 'date' | 'window';
5
+ from: string;
6
+ through: string;
7
+ label: string;
8
+ }
9
+ export interface ResetPost {
10
+ id: string;
11
+ publishedAt: string;
12
+ stage: string;
13
+ text: string;
14
+ originalText: string;
15
+ url: string;
16
+ }
17
+ export interface CodexResetEvent {
18
+ id: string;
19
+ type: ResetEventType;
20
+ label: string;
21
+ status: ResetEventStatus;
22
+ title: string;
23
+ scope: string;
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ confirmedAt: string | null;
27
+ occurredOn: string | null;
28
+ confirmationBasis: 'source_post' | 'receipt_review' | null;
29
+ schedule: ResetSchedule | null;
30
+ posts: ResetPost[];
31
+ url: string;
32
+ }
33
+ export interface CodexResetsResponse {
34
+ schemaVersion: 1;
35
+ timezone: 'Asia/Shanghai';
36
+ checkedAt: string | null;
37
+ historyFrom: string;
38
+ count: number;
39
+ events: CodexResetEvent[];
40
+ }
41
+ export interface TargetConfig {
42
+ platform: string;
43
+ channelId: string;
44
+ }
45
+ export interface Config {
46
+ apiUrl: string;
47
+ pollInterval: number;
48
+ requestTimeout: number;
49
+ targets: TargetConfig[];
50
+ pushAnnouncements: boolean;
51
+ pushConfirmedResets: boolean;
52
+ mentionAll: boolean;
53
+ enableProbabilityCommand: boolean;
54
+ enableCalendarCommand: boolean;
55
+ enableHistoryCommand: boolean;
56
+ }
package/lib/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "koishi-plugin-codex-reset",
3
+ "version": "0.1.0",
4
+ "description": "Monitor AIHOT Codex reset events and notify Koishi channels.",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "resources",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "koishi",
15
+ "koishi-plugin",
16
+ "codex",
17
+ "openai",
18
+ "reset"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/lumia1998/koishi-plugin-codex-reset.git"
23
+ },
24
+ "homepage": "https://github.com/lumia1998/koishi-plugin-codex-reset#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/lumia1998/koishi-plugin-codex-reset/issues"
27
+ },
28
+ "license": "MIT",
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "prepare": "npm run build",
35
+ "test": "npm run build && node --test tests/*.test.cjs"
36
+ },
37
+ "peerDependencies": {
38
+ "koishi": ">=4.18.0",
39
+ "koishi-plugin-puppeteer": "*"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "koishi-plugin-puppeteer": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.0.0",
48
+ "koishi": "^4.18.9",
49
+ "typescript": "^5.7.0"
50
+ }
51
+ }
Binary file