/** * 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 { 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 { 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 | 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( `⚡ Modalità Riepilogo attivata\n` + `Molti eventi in poco tempo — riceverai un riepilogo ogni minuto invece di singole notifiche.` ); } _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(); 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( `📋 Riepilogo notifiche (${entries.length} eventi)\n\n` + `${overview}\n\n` + `Ultimi eventi:\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 => `${f.split("/").pop() ?? f}`).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(); 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 ? `\nRisultato: ${_trunc(result, 220)}` : ""; const filesStr = (filesModified != null && filesModified > 0) ? `\nFile modificati: ${filesModified}` : ""; const stepsStr = (stepsCount != null && stepsCount > 0) ? `\nStep eseguiti: ${stepsCount}` : ""; _sendOrBuffer( '✅', _trunc(goal, 60), `✅ Risposta pronta!\n\n` + `Goal: ${_trunc(goal, 120)}\n` + `Durata: ${_fmt(durationMs)}` + filesStr + stepsStr + `\n${_id(taskId)}` + 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), `❌ Errore agente\n\n` + `Goal: ${_trunc(goal, 120)}\n` + `Errore: ${_trunc(error, 200)}\n` + `${_id(taskId)}` ); } /** ⏹ 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(); 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_") * @param ignoreData — callback_data per [Ignora] (default "ignore_") */ // Dedup taskApproval: stessa proposta non inviata 2 volte const _approvalSent = new Set(); 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( `🔔 Fix disponibile\n\n` + `Azione: ${_trunc(goal, 180)}\n` + `Confidenza: ${confidence}%\n` + `Rischio: ${riskIcon} ${risk}\n` + (confirmKey ? `Key: ${confirmKey.slice(0, 40)}\n` : "") + `${id}`, [[ { 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)}`, `🔁 Problema ricorrente\n\n` + `Problema: ${_trunc(goal, 160)}\n` + `Visto: ${occurrences} volte negli ultimi ${daysPeriod} giorni\n\n` + `Richiede analisi della causa radice.` ); } /** * 🧩 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(); 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( `🟢 Agente connesso\n` + `Sessione: ${sessionName}\n` + (sprint && sprint !== "—" ? `Sprint: ${sprint}\n` : "") + `Ora attivo nel repo.` ); } /** * 🔴 Agente disconnesso. * Silenziato di default. */ export function agentLeft(sessionName: string): void { if (!isTgEnabled() || !_agentCoordEnabled()) return; void _send( `🔴 Agente disconnesso\n` + `Sessione: ${sessionName}` ); } /** * 🔒 Agente ha claimato file. * Silenziato di default. */ export function agentClaim(sessionName: string, sprint: string | null, files: string[]): void { if (!isTgEnabled() || !_agentCoordEnabled()) return; void _send( `🔒 Claim${sessionName}\n` + (sprint && sprint !== "—" ? `Sprint: ${sprint}\n` : "") + (files.length > 0 ? `File: ${_fileList(files)}` : "") ); } /** * 🔓 Agente ha rilasciato i file. * Silenziato di default. */ export function agentRelease(sessionName: string): void { if (!isTgEnabled() || !_agentCoordEnabled()) return; void _send( `🔓 Release${sessionName}\n` + `File liberi per il prossimo agente.` ); } /** ⚠️ 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( `⚠️ CONFLITTO RILEVATO\n\n` + `${mySession} e ${otherSession} vogliono gli stessi file!\n\n` + `File in conflitto: ${_fileList(conflictFiles)}\n` + (sprint ? `Sprint: ${sprint}\n` : "") + `\nAzione richiesta: 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( `💬 ${fromSession}:\n${_trunc(message, 300)}` ); } /** 🔔 Test configurazione — usato da Settings */ export async function sendTestMessage(): Promise { return _send( `🔔 Agente AI — Test notifiche\n\n` + `✅ Configurazione corretta!\n\n` + `Riceverai solo notifiche essenziali:\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` + `Silenziati: avvii task, cancellazioni, step lunghi, gap interni.` ); } export const tgNotify = { taskStart, taskDone, taskError, taskCancelled, stepLong, taskApproval, taskRegression, capGap, agentJoined, agentLeft, agentClaim, agentRelease, agentConflict, agentChat, sendTestMessage, }; export default tgNotify;