Initial commit
This commit is contained in:
commit
2c78bb9c76
201 changed files with 196806 additions and 0 deletions
442
index.js
Normal file
442
index.js
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue