codex-reset-alerts/index.js

540 lines
11 KiB
JavaScript

import { spawn } from "node:child_process";
import readline from "node:readline";
import fs from "node:fs";
function loadEnv() {
if (!fs.existsSync(".env")) return;
for (const line of fs.readFileSync(".env", "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const index = trimmed.indexOf("=");
if (index === -1) continue;
const key = trimmed.slice(0, index).trim();
const value = trimmed.slice(index + 1).trim();
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
loadEnv();
const NTFY_URL = process.env.NTFY_URL ?? "https://ntfy.sh";
const NTFY_TOPIC = process.env.NTFY_TOPIC;
const NTFY_TOKEN = process.env.NTFY_TOKEN;
const TEST_NTFY = process.argv.includes("--test-ntfy");
// Toutes les 60 secondes par défaut.
const POLL_INTERVAL_MS =
Number(process.env.POLL_INTERVAL_SECONDS ?? 60) * 1000;
if (!NTFY_TOPIC) {
console.error("❌ NTFY_TOPIC manquant dans .env");
process.exit(1);
}
let nextId = 1;
const pending = new Map();
let codex = null;
let lines = null;
let previousState = null;
let refreshInProgress = false;
let shuttingDown = false;
let pollingTimer = null;
let theoreticalResetTimer = null;
function send(message) {
codex.stdin.write(JSON.stringify(message) + "\n");
}
function request(method, params = undefined) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
send({
method,
id,
...(params === undefined ? {} : { params }),
});
});
}
function getCodexLimit(result) {
return (
result?.rateLimitsByLimitId?.codex ??
result?.rateLimits ??
null
);
}
function getGlobalLimitWindow(limit) {
const windows = [
limit?.primary,
limit?.secondary,
].filter(Boolean);
return (
windows.find(
(window) => Number(window?.windowDurationMins) === 10080
) ??
windows.sort(
(a, b) =>
Number(b?.windowDurationMins ?? 0) -
Number(a?.windowDurationMins ?? 0)
)[0] ??
null
);
}
function getState(limit) {
const globalWindow = getGlobalLimitWindow(limit);
const usedPercent = Number(globalWindow?.usedPercent ?? 0);
const blocked = usedPercent >= 100;
return {
usedPercent,
blocked,
resetsAt: globalWindow?.resetsAt ?? null,
windowDurationMins:
globalWindow?.windowDurationMins ?? null,
primaryUsedPercent:
limit?.primary?.usedPercent ?? null,
primaryWindowDurationMins:
limit?.primary?.windowDurationMins ?? null,
reachedType: limit?.rateLimitReachedType ?? null,
};
}
function formatDate(timestamp) {
if (!timestamp) return "inconnu";
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "full",
timeStyle: "medium",
}).format(new Date(timestamp * 1000));
}
function formatDuration(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return [
days > 0 ? `${days}j` : null,
`${hours}h`,
`${minutes}min`,
]
.filter(Boolean)
.join(" ");
}
async function sendNtfyNotification({
title,
tags,
priority = "default",
body,
}) {
const url =
`${NTFY_URL.replace(/\/$/, "")}/${encodeURIComponent(NTFY_TOPIC)}`;
const headers = {
Title: title,
Tags: tags,
Priority: priority,
"Content-Type": "text/plain; charset=utf-8",
...(NTFY_TOKEN ? { Authorization: `Bearer ${NTFY_TOKEN}` } : {}),
};
const response = await fetch(url, {
method: "POST",
headers,
body,
});
if (!response.ok) {
throw new Error(`ntfy HTTP ${response.status}`);
}
}
async function sendStartupNotification() {
await sendNtfyNotification({
title: "Codex Reset Alerts lancé",
tags: "robot,eyes",
priority: "default",
body:
`👀 Codex Reset Alerts vient de démarrer.\n\n` +
`Surveillance du quota global Codex en cours.`,
});
}
async function sendResetNotification(oldState, newState) {
await sendNtfyNotification({
title: "Codex disponible",
tags: "robot,white_check_mark",
priority: "high",
body:
`🚀 Ton quota global Codex est de nouveau disponible !\n\n` +
`Avant : ${oldState.usedPercent}% utilisé\n` +
`Maintenant : ${newState.usedPercent}% utilisé`,
});
}
function stop(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
if (pollingTimer) {
clearInterval(pollingTimer);
}
if (theoreticalResetTimer) {
clearTimeout(theoreticalResetTimer);
}
try {
codex?.kill();
} catch {
// Rien.
}
process.exit(exitCode);
}
function scheduleTheoreticalResetCheck(state) {
if (!state.resetsAt) return;
if (theoreticalResetTimer) {
clearTimeout(theoreticalResetTimer);
}
const delay =
state.resetsAt * 1000 -
Date.now() +
5000;
if (delay <= 0) return;
console.log(
`⏰ Vérification supplémentaire dans ${formatDuration(delay)}`
);
theoreticalResetTimer = setTimeout(async () => {
theoreticalResetTimer = null;
console.log("");
console.log("⏰ Heure de reset théorique atteinte.");
console.log("🔎 Vérification du quota réel...");
await refreshQuota("reset théorique");
}, delay);
}
async function processLimit(limit, source) {
if (!limit) {
console.error("❌ Quota Codex introuvable.");
return;
}
const state = getState(limit);
console.log("");
console.log(`🔎 Source : ${source}`);
console.log(`📊 Global utilisé : ${state.usedPercent}%`);
if (
state.primaryUsedPercent !== null &&
state.primaryWindowDurationMins !== null &&
state.primaryWindowDurationMins !== state.windowDurationMins
) {
console.log(
`📊 Fenêtre ${state.primaryWindowDurationMins}min ignorée : ` +
`${state.primaryUsedPercent}%`
);
}
console.log(
state.blocked
? "🔴 État : quota global épuisé"
: "🟢 État : quota global disponible"
);
if (state.resetsAt) {
console.log(
`🕒 Reset global annoncé : ${formatDate(state.resetsAt)}`
);
const remaining =
state.resetsAt * 1000 -
Date.now();
if (remaining > 0) {
console.log(
`⏳ Temps théorique global : ${formatDuration(remaining)}`
);
}
}
// Première lecture au démarrage.
if (!previousState) {
previousState = state;
if (!state.blocked) {
console.log("");
console.log("✅ Ton quota global Codex est encore disponible.");
console.log("👋 Rien à surveiller, arrêt.");
setTimeout(() => stop(0), 100);
return;
}
console.log("");
console.log("🔴 Quota global épuisé.");
console.log("👀 Surveillance du reset global activée.");
scheduleTheoreticalResetCheck(state);
return;
}
// La transition importante :
//
// quota global épuisé -> quota global disponible
if (
previousState.blocked === true &&
state.blocked === false
) {
console.log("");
console.log("🎉 RESET GLOBAL RÉEL DÉTECTÉ !");
console.log(
`📉 ${previousState.usedPercent}% → ${state.usedPercent}%`
);
try {
await sendResetNotification(
previousState,
state
);
console.log("📱 Notification ntfy envoyée.");
console.log("✅ Mission terminée.");
console.log("👋 Arrêt du watcher.");
setTimeout(() => stop(0), 250);
return;
} catch (error) {
console.error(
"❌ Échec de la notification ntfy :",
error.message
);
// On ne quitte pas :
// le watcher retentera au prochain poll.
return;
}
}
if (state.usedPercent < previousState.usedPercent) {
console.log(
`📉 Utilisation globale en baisse : ` +
`${previousState.usedPercent}% → ${state.usedPercent}%`
);
}
if (
previousState.resetsAt &&
state.resetsAt &&
previousState.resetsAt !== state.resetsAt
) {
console.log("🔄 Date de reset global modifiée par Codex.");
console.log(
` Avant : ${formatDate(previousState.resetsAt)}`
);
console.log(
` Après : ${formatDate(state.resetsAt)}`
);
}
previousState = state;
if (state.blocked) {
scheduleTheoreticalResetCheck(state);
}
}
async function refreshQuota(source = "polling") {
if (refreshInProgress || shuttingDown) return;
refreshInProgress = true;
try {
const result =
await request("account/rateLimits/read");
await processLimit(
getCodexLimit(result),
source
);
} catch (error) {
console.error(
"❌ Impossible de lire le quota :",
error.message
);
} finally {
refreshInProgress = false;
}
}
function startCodexServer() {
codex = spawn("codex", ["app-server", "--stdio"], {
stdio: ["pipe", "pipe", "inherit"],
});
lines = readline.createInterface({
input: codex.stdout,
crlfDelay: Infinity,
});
lines.on("line", async (line) => {
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (
message.id !== undefined &&
pending.has(message.id)
) {
const { resolve, reject } =
pending.get(message.id);
pending.delete(message.id);
if (message.error) {
reject(
new Error(JSON.stringify(message.error))
);
} else {
resolve(message.result);
}
return;
}
if (
message.method ===
"account/rateLimits/updated"
) {
console.log("");
console.log(
"⚡ Codex signale une modification du quota."
);
await refreshQuota("événement Codex");
}
});
codex.on("exit", (code) => {
if (!shuttingDown && code !== 0) {
console.error(
`❌ Codex App Server s'est arrêté avec le code ${code}`
);
}
});
}
async function main() {
try {
await sendStartupNotification();
console.log("📱 Notification ntfy de démarrage envoyée.");
} catch (error) {
console.error(
"❌ Échec de la notification ntfy de démarrage :",
error.message
);
}
console.log(
"🔌 Connexion à Codex App Server..."
);
startCodexServer();
await request("initialize", {
clientInfo: {
name: "codex-reset-alerts",
title: "Codex Reset Alerts",
version: "1.0.0",
},
});
send({
method: "initialized",
params: {},
});
console.log("✅ Connecté.");
// Première lecture.
await refreshQuota("démarrage");
// Le programme a peut-être décidé de quitter.
if (shuttingDown) return;
// Si on est arrivé ici et qu'on était bloqué,
// on surveille toutes les N secondes.
if (previousState?.blocked) {
console.log("");
console.log(
`👀 Polling toutes les ${POLL_INTERVAL_MS / 1000}s.`
);
pollingTimer = setInterval(() => {
refreshQuota("polling");
}, POLL_INTERVAL_MS);
}
}
process.on("SIGINT", () => {
console.log("\n👋 Arrêt manuel.");
stop(0);
});
process.on("SIGTERM", () => {
stop(0);
});
if (TEST_NTFY) {
sendResetNotification(
{ usedPercent: 100 },
{ usedPercent: 0 }
)
.then(() => {
console.log("📱 Notification ntfy de test envoyée.");
})
.catch((error) => {
console.error(
"❌ Échec de la notification ntfy de test :",
error.message
);
process.exitCode = 1;
});
} else {
main().catch((error) => {
console.error("❌", error);
stop(1);
});
}