AUDIT / src /lib /agentTelegram.ts
Arypulka98's picture
feat(audit): deploy full backend cluster node (part 3)
95eb75a verified
Raw
History Blame Contribute Delete
18.8 kB
/**
* agentTelegram.ts — Notifiche Telegram per task E coordinamento agenti
*
* Configurazione runtime (nessun rideploy necessario):
* Settings → tab Notifiche → Bot Token + Chat ID → salvati in localStorage
* Fallback: VITE_TELEGRAM_BOT_TOKEN / VITE_TELEGRAM_CHAT_ID (env compile-time)
*
* Politica notifiche — solo l'essenziale:
* ✅ taskDone — solo se pagina nascosta + task >90s (eri lontano dalla schermata)
* ✅ taskError — errore definitivo: richiede attenzione
* ✅ taskApproval — richiesta approvazione con bottoni [Approva] [Rifiuta]
* ✅ taskRegression — problema ricorrente: richiede analisi
* ✅ agentConflict — conflitto su file: critico, inviato sempre
*
* Silenziati (rumore non azionabile):
* ❌ taskStart — l'utente sa già che ha avviato il task
* ❌ taskCancelled — l'utente lo ha cancellato deliberatamente
* ❌ stepLong — dettaglio interno, taskDone copre già il caso
* ❌ capGap — metrica interna, non azionabile dall'utente
*
* Safety net: digest mode automatico se >8 msg/60s (es. burst di errori)
*
* Coordinamento interno silenziato (agentJoined/Left/Claim/Release/Chat):
* → abilitabile via localStorage "agent_telegram_coord_events"="true" per debug
*
* Tutte le funzioni sono fire-and-forget: non throwono mai, non bloccano.
*/
// ─── Config runtime ───────────────────────────────────────────────────────────
/** Legge il Bot Token a runtime: localStorage → env var → "" */
export function getTgToken(): string {
try {
const ls = localStorage.getItem("agent_telegram_token");
if (ls?.trim()) return ls.trim();
} catch { /* Safari Private / SSR */ }
return (import.meta.env["VITE_TELEGRAM_BOT_TOKEN"] as string | undefined) ?? "";
}
/** Legge il Chat ID a runtime: localStorage → env var → "" */
export function getTgChatId(): string {
try {
const ls = localStorage.getItem("agent_telegram_chat_id");
if (ls?.trim()) return ls.trim();
} catch { /* Safari Private / SSR */ }
return (import.meta.env["VITE_TELEGRAM_CHAT_ID"] as string | undefined) ?? "";
}
/** true se Telegram è configurato */
export function isTgEnabled(): boolean {
return !!(getTgToken() && getTgChatId());
}
/**
* Controlla se le notifiche di coordinamento interno agenti sono abilitate.
* Default: false — eventi interni (join/leave/claim/release/chat) silenziati
* nel canale principale. Abilitare solo per debug/sviluppo.
*/
function _agentCoordEnabled(): boolean {
try {
return localStorage.getItem("agent_telegram_coord_events") === "true";
} catch { return false; }
}
// ─── Core send ────────────────────────────────────────────────────────────────
// TG-429: retry-once on rate-limit — legge Retry-After header (max 30s wait)
async function _send(text: string, _retryLeft = 1): Promise<boolean> {
const token = getTgToken();
const chatId = getTgChatId();
if (!token || !chatId) return false;
try {
const r = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
});
if (r.status === 429 && _retryLeft > 0) {
// Telegram rate limit: aspetta Retry-After poi riprova UNA sola volta
const wait = parseInt(r.headers.get("Retry-After") ?? "5", 10) || 5;
await new Promise(res => setTimeout(res, Math.min(wait, 30) * 1_000));
return _send(text, _retryLeft - 1);
}
if (!r.ok) {
try {
const body = await r.json() as { description?: string };
console.warn(`[agentTelegram] ✗ Telegram API ${r.status}: ${body?.description ?? r.statusText}`);
} catch {
console.warn(`[agentTelegram] ✗ Telegram API ${r.status}: ${r.statusText}`);
}
}
return r.ok;
} catch (e) {
console.warn("[agentTelegram] ✗ fetch error:", e);
return false;
}
}
/** Invia messaggio con inline keyboard (bottoni) */
async function _sendWithButtons(
text: string,
buttons: Array<{ text: string; callback_data: string }[]>,
): Promise<boolean> {
const token = getTgToken();
const chatId = getTgChatId();
if (!token || !chatId) return false;
try {
const r = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
reply_markup: { inline_keyboard: buttons },
}),
});
if (!r.ok) {
console.warn(`[agentTelegram] ✗ sendWithButtons ${r.status}`);
}
return r.ok;
} catch (e) {
console.warn("[agentTelegram] ✗ sendWithButtons error:", e);
return false;
}
}
// ─── Digest / Summary Mode ────────────────────────────────────────────────────
// Se arrivano >_RATE_MAX messaggi in 60s → modalità riepilogo:
// - buffer tutti gli eventi successivi
// - invia un unico "📋 Riepilogo" ogni 60s
// Previene flood da: SSE reconnect storms, repair loop, capGap auditor, ecc.
const _RATE_MAX = 8; // soglia (msg/finestra) prima del riepilogo
const _RATE_WIN_MS = 60_000; // finestra rate 60s
const _DIGEST_INT_MS = 60_000; // intervallo flush digest
const _rateWindow: number[] = [];
const _digestBuf: Array<{ icon: string; label: string }> = [];
let _digestTimer: ReturnType<typeof setTimeout> | null = null;
let _inDigest = false;
/** Smista: invia direttamente se sotto soglia, altrimenti bufferizza nel digest. */
function _sendOrBuffer(icon: string, label: string, fullText: string): void {
const now = Date.now();
// Prune finestra scorrevole
while (_rateWindow.length && now - _rateWindow[0] > _RATE_WIN_MS) _rateWindow.shift();
if (!_inDigest && _rateWindow.length < _RATE_MAX) {
_rateWindow.push(now);
void _send(fullText);
return;
}
// Primo overflow → annuncia modalità riepilogo
if (!_inDigest) {
_inDigest = true;
void _send(
`⚡ <b>Modalità Riepilogo attivata</b>\n` +
`<i>Molti eventi in poco tempo — riceverai un riepilogo ogni minuto invece di singole notifiche.</i>`
);
}
_digestBuf.push({ icon, label });
if (!_digestTimer) {
_digestTimer = setTimeout(_flushDigest, _DIGEST_INT_MS);
}
}
function _flushDigest(): void {
_digestTimer = null;
const entries = _digestBuf.splice(0);
if (!entries.length) { _inDigest = false; return; }
// Raggruppa per tipo (icon)
const counts = new Map<string, number>();
for (const e of entries) counts.set(e.icon, (counts.get(e.icon) ?? 0) + 1);
const overview = [...counts.entries()]
.map(([icon, n]) => `${icon} ×${n}`)
.join(' · ');
// Dettaglio ultimi 5
const recent = entries.slice(-5)
.map(e => `• ${e.label}`)
.join('\n');
void _send(
`📋 <b>Riepilogo notifiche</b> (${entries.length} eventi)\n\n` +
`${overview}\n\n` +
`<b>Ultimi eventi:</b>\n${recent}`
);
// Torna in modalità normale
_inDigest = false;
_rateWindow.length = 0;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function _fmt(ms: number): string {
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
function _trunc(s: string, max = 200): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
function _fileList(files: string[]): string {
if (!files.length) return "—";
return files.map(f => `<code>${f.split("/").pop() ?? f}</code>`).slice(0, 4).join(", ") +
(files.length > 4 ? ` +${files.length - 4}` : "");
}
function _id(taskId: string | null | undefined): string {
return (taskId ?? "—").slice(0, 8);
}
// ─── Task notifications ───────────────────────────────────────────────────────
// TG-DEDUP: previene doppia notifica su SSE reconnect/retry dello stesso task
const _taskStartNotified = new Set<string>(); void _taskStartNotified;
/** 📋 Task avviato — silenziato: l'utente ha già inviato il goal, sa cosa sta facendo.
* La firma rimane per compatibilità con i caller esistenti. */
export function taskStart(_goal: string, _taskId: string | null | undefined): void {
// Silenziato — nessuna notifica all'avvio del task
}
/**
* ✅ Task completato — avvisa sempre.
* Parametri arricchiti: filesModified, stepsCount, result.
*/
export function taskDone(
taskId: string | null | undefined,
goal: string,
durationMs: number,
result?: string,
filesModified?: number,
stepsCount?: number,
): void {
if (!isTgEnabled()) return;
// Notifica solo se: pagina nascosta (utente non sta guardando) + task non banale (>90s)
const pageHidden = typeof document !== "undefined" ? document.hidden : true;
if (!pageHidden || durationMs < 90_000) return;
const summary = result ? `\n<b>Risultato:</b> ${_trunc(result, 220)}` : "";
const filesStr = (filesModified != null && filesModified > 0)
? `\n<b>File modificati:</b> ${filesModified}` : "";
const stepsStr = (stepsCount != null && stepsCount > 0)
? `\n<b>Step eseguiti:</b> ${stepsCount}` : "";
_sendOrBuffer(
'✅',
_trunc(goal, 60),
`✅ <b>Risposta pronta!</b>\n\n` +
`<b>Goal:</b> ${_trunc(goal, 120)}\n` +
`<b>Durata:</b> ${_fmt(durationMs)}` +
filesStr +
stepsStr +
`\n<code>${_id(taskId)}</code>` +
summary
);
}
/** ❌ Errore definitivo (tutti i retry esauriti) */
export function taskError(taskId: string | null | undefined, goal: string, error: string): void {
if (!isTgEnabled()) return;
_sendOrBuffer(
'❌',
_trunc(goal, 60),
`❌ <b>Errore agente</b>\n\n` +
`<b>Goal:</b> ${_trunc(goal, 120)}\n` +
`<b>Errore:</b> <code>${_trunc(error, 200)}</code>\n` +
`<code>${_id(taskId)}</code>`
);
}
/** ⏹ Task cancellato */
/** Task cancellato — silenziato: l'utente lo ha cancellato lui stesso. */
export function taskCancelled(_taskId: string | null | undefined, _goal: string): void {
// Silenziato — l'utente ha cancellato deliberatamente, non serve conferma
}
// B7-FIX: per-task debounce for stepLong — max 1 notification per 5 min per task
const _stepLongLast = new Map<string, number>(); void _stepLongLast;
const _STEP_LONG_INTERVAL = 5 * 60 * 1_000; void _STEP_LONG_INTERVAL;
/** ⏳ stepLong — silenziato: taskDone (pagina nascosta + >90s) già copre questo caso.
* Firma mantenuta per compatibilità. */
export function stepLong(
_taskId: string | null | undefined,
_stepName: string,
_stepNum: number,
_elapsedMs: number,
): void {
// Silenziato — taskDone informa già quando il task lungo finisce con pagina nascosta
}
// ─── Notifiche intelligenti (nuove) ──────────────────────────────────────────
/**
* 🔔 Richiesta approvazione con bottoni inline.
* Invia al canale Telegram un messaggio con [Approva] e [Ignora].
* Il daemon session-daemon.mjs deve gestire i callback_data corrispondenti.
*
* @param taskId — ID task (opzionale)
* @param goal — descrizione dell'azione proposta
* @param confidence — confidenza 0-100
* @param risk — "basso" | "medio" | "alto"
* @param approveData — callback_data per [Approva] (default "approve_<taskId>")
* @param ignoreData — callback_data per [Ignora] (default "ignore_<taskId>")
*/
// Dedup taskApproval: stessa proposta non inviata 2 volte
const _approvalSent = new Set<string>();
export function taskApproval(
taskId: string | null | undefined,
goal: string,
confidence: number,
risk: "basso" | "medio" | "alto",
approveData?: string,
ignoreData?: string,
/** TG-WEBHOOK: chiave stabile della proposta — usata da reject_confirm:{key} */
confirmKey?: string,
): void {
if (!isTgEnabled()) return;
const dedupKey = confirmKey ?? (taskId ? `approval:${taskId}` : `goal:${goal.slice(0, 80)}`);
if (_approvalSent.has(dedupKey)) return;
_approvalSent.add(dedupKey);
if (_approvalSent.size > 100) _approvalSent.clear();
const id = _id(taskId);
const riskIcon = risk === "alto" ? "🔴" : risk === "medio" ? "🟡" : "🟢";
// TG-WEBHOOK: se confirmKey presente → bottone Rifiuta registra in decisionMemory via Realtime
const rejectData = confirmKey
? `reject_confirm:${confirmKey}`
: (ignoreData ?? `ignore_${id}`);
const rejectLabel = confirmKey ? "🚫 Rifiuta" : "❌ Ignora";
void _sendWithButtons(
`🔔 <b>Fix disponibile</b>\n\n` +
`<b>Azione:</b> ${_trunc(goal, 180)}\n` +
`<b>Confidenza:</b> ${confidence}%\n` +
`<b>Rischio:</b> ${riskIcon} ${risk}\n` +
(confirmKey ? `<b>Key:</b> <code>${confirmKey.slice(0, 40)}</code>\n` : "") +
`<code>${id}</code>`,
[[
{ text: "✅ Approva", callback_data: approveData ?? `approve_${id}` },
{ text: rejectLabel, callback_data: rejectData },
]],
);
}
/**
* 🔁 Regressione — problema già visto N volte negli ultimi X giorni.
*
* @param goal — descrizione del problema
* @param occurrences — numero di volte visto
* @param daysPeriod — finestra temporale in giorni
*/
export function taskRegression(goal: string, occurrences: number, daysPeriod: number): void {
if (!isTgEnabled()) return;
_sendOrBuffer(
'🔁',
`${occurrences}× ${goal.slice(0, 50)}`,
`🔁 <b>Problema ricorrente</b>\n\n` +
`<b>Problema:</b> ${_trunc(goal, 160)}\n` +
`<b>Visto:</b> ${occurrences} volte negli ultimi ${daysPeriod} giorni\n\n` +
`<i>Richiede analisi della causa radice.</i>`
);
}
/**
* 🧩 Gap di capability rilevato dal CapabilityGapAuditor.
*
* @param gapTitle — nome breve del gap
* @param description — descrizione di cosa manca e perché è importante
*/
// Dedup capGap: stesso gap → max 1 notifica ogni 30 minuti
const _capGapCooldown = new Map<string, number>(); void _capGapCooldown;
const _CAP_GAP_COOLDOWN_MS = 30 * 60_000; void _CAP_GAP_COOLDOWN_MS;
/** capGap — silenziato: gap interni non richiedono attenzione utente, non azionabili. */
export function capGap(_gapTitle: string, _description: string): void {
// Silenziato — i gap di capability sono metriche interne, non notifiche utente
}
// ─── Agent coordination (eventi interni — silenziati di default) ──────────────
/**
* 🟢 Nuovo agente connesso.
* Silenziato di default (_agentCoordEnabled() = false).
* Abilitare via: localStorage.setItem("agent_telegram_coord_events", "true")
*/
export function agentJoined(sessionName: string, sprint: string | null): void {
if (!isTgEnabled() || !_agentCoordEnabled()) return;
void _send(
`🟢 <b>Agente connesso</b>\n` +
`<b>Sessione:</b> <code>${sessionName}</code>\n` +
(sprint && sprint !== "—" ? `<b>Sprint:</b> ${sprint}\n` : "") +
`<i>Ora attivo nel repo.</i>`
);
}
/**
* 🔴 Agente disconnesso.
* Silenziato di default.
*/
export function agentLeft(sessionName: string): void {
if (!isTgEnabled() || !_agentCoordEnabled()) return;
void _send(
`🔴 <b>Agente disconnesso</b>\n` +
`<b>Sessione:</b> <code>${sessionName}</code>`
);
}
/**
* 🔒 Agente ha claimato file.
* Silenziato di default.
*/
export function agentClaim(sessionName: string, sprint: string | null, files: string[]): void {
if (!isTgEnabled() || !_agentCoordEnabled()) return;
void _send(
`🔒 <b>Claim</b> — <code>${sessionName}</code>\n` +
(sprint && sprint !== "—" ? `<b>Sprint:</b> ${sprint}\n` : "") +
(files.length > 0 ? `<b>File:</b> ${_fileList(files)}` : "")
);
}
/**
* 🔓 Agente ha rilasciato i file.
* Silenziato di default.
*/
export function agentRelease(sessionName: string): void {
if (!isTgEnabled() || !_agentCoordEnabled()) return;
void _send(
`🔓 <b>Release</b> — <code>${sessionName}</code>\n` +
`<i>File liberi per il prossimo agente.</i>`
);
}
/** ⚠️ CONFLITTO rilevato — inviato SEMPRE (critico, ignora _agentCoordEnabled) */
export function agentConflict(
mySession: string,
otherSession: string,
conflictFiles: string[],
sprint?: string | null,
): void {
if (!isTgEnabled()) return;
void _send(
`⚠️ <b>CONFLITTO RILEVATO</b>\n\n` +
`<b>${mySession}</b> e <b>${otherSession}</b> vogliono gli stessi file!\n\n` +
`<b>File in conflitto:</b> ${_fileList(conflictFiles)}\n` +
(sprint ? `<b>Sprint:</b> ${sprint}\n` : "") +
`\n<b>Azione richiesta:</b> uno dei due deve aspettare o scegliere file diversi.`
);
}
/**
* 💬 Messaggio di coordinamento tra agenti.
* Silenziato di default.
*/
export function agentChat(fromSession: string, message: string): void {
if (!isTgEnabled() || !_agentCoordEnabled()) return;
void _send(
`💬 <b>${fromSession}:</b>\n${_trunc(message, 300)}`
);
}
/** 🔔 Test configurazione — usato da Settings */
export async function sendTestMessage(): Promise<boolean> {
return _send(
`🔔 <b>Agente AI — Test notifiche</b>\n\n` +
`✅ Configurazione corretta!\n\n` +
`<b>Riceverai solo notifiche essenziali:</b>\n` +
` ✅ Task completato — solo se eri lontano dalla schermata (>90s)\n` +
` ❌ Errore definitivo — richiede la tua attenzione\n` +
` 🔔 Approvazione richiesta — con bottoni Approva/Rifiuta\n` +
` 🔁 Problema ricorrente — pattern da analizzare\n` +
` ⚠️ Conflitto su file — critico\n\n` +
`<i>Silenziati: avvii task, cancellazioni, step lunghi, gap interni.</i>`
);
}
export const tgNotify = {
taskStart, taskDone, taskError, taskCancelled, stepLong,
taskApproval, taskRegression, capGap,
agentJoined, agentLeft, agentClaim, agentRelease, agentConflict, agentChat,
sendTestMessage,
};
export default tgNotify;