540 lines
11 KiB
JavaScript
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);
|
|
});
|
|
}
|