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; // 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); } const codex = spawn("codex", ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "inherit"], }); const lines = readline.createInterface({ input: codex.stdout, crlfDelay: Infinity, }); let nextId = 1; const pending = new Map(); 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 getState(limit) { const usedPercent = Number(limit?.primary?.usedPercent ?? 0); const blocked = limit?.rateLimitReachedType === "rate_limit_reached" || usedPercent >= 100; return { usedPercent, blocked, resetsAt: limit?.primary?.resetsAt ?? 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 sendResetNotification(oldState, newState) { const url = `${NTFY_URL.replace(/\/$/, "")}/${encodeURIComponent(NTFY_TOPIC)}`; const response = await fetch(url, { method: "POST", headers: { Title: "Codex disponible", Tags: "robot,white_check_mark", Priority: "high", "Content-Type": "text/plain; charset=utf-8", }, body: `🚀 Ton quota Codex est de nouveau disponible !\n\n` + `Avant : ${oldState.usedPercent}% utilisé\n` + `Maintenant : ${newState.usedPercent}% utilisé`, }); if (!response.ok) { throw new Error(`ntfy HTTP ${response.status}`); } } 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(`📊 Utilisé : ${state.usedPercent}%`); console.log( state.blocked ? "🔴 État : quota épuisé" : "🟢 État : quota disponible" ); if (state.resetsAt) { console.log( `🕒 Reset annoncé : ${formatDate(state.resetsAt)}` ); const remaining = state.resetsAt * 1000 - Date.now(); if (remaining > 0) { console.log( `⏳ Temps théorique : ${formatDuration(remaining)}` ); } } // Première lecture au démarrage. if (!previousState) { previousState = state; if (!state.blocked) { console.log(""); console.log("✅ Ton quota Codex est encore disponible."); console.log("👋 Rien à surveiller, arrêt."); setTimeout(() => stop(0), 100); return; } console.log(""); console.log("🔴 Quota épuisé."); console.log("👀 Surveillance du reset activée."); scheduleTheoreticalResetCheck(state); return; } // La transition importante : // // bloqué -> disponible if ( previousState.blocked === true && state.blocked === false ) { console.log(""); console.log("🎉 RESET 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 en baisse : ` + `${previousState.usedPercent}% → ${state.usedPercent}%` ); } if ( previousState.resetsAt && state.resetsAt && previousState.resetsAt !== state.resetsAt ) { console.log("🔄 Date de reset 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; } } 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"); } }); async function main() { console.log( "🔌 Connexion à Codex App Server..." ); 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); }); codex.on("exit", (code) => { if (!shuttingDown && code !== 0) { console.error( `❌ Codex App Server s'est arrêté avec le code ${code}` ); } }); main().catch((error) => { console.error("❌", error); stop(1); });