Spaces:
Sleeping
Sleeping
| /** | |
| * 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; | |