diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 5f5c81fa..8ad86a22 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1775,6 +1775,7 @@ "service_fail_batch": "Mehrere Dienstausfälle", "cron_output": "Cron-Job-Ausgabe", "system_mail": "Smartd / Systemmail", + "apt_listchanges": "Pakethinweise von apt-listchanges", "webhook_test": "Webhook-Test", "update_available": "Verfügbare Updates (Legacy)", "unknown_persistent": "Scheck nicht verfügbar", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index cf345ec8..5edd785d 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1774,6 +1774,7 @@ "service_fail_batch": "Multiple service failures", "cron_output": "Cron job output", "system_mail": "Smartd / system mail", + "apt_listchanges": "apt-listchanges package notices", "webhook_test": "Webhook test", "update_available": "Updates available (legacy)", "unknown_persistent": "Check unavailable", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index b53cfb0f..4e051843 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1775,6 +1775,7 @@ "service_fail_batch": "Múltiples fallas de servicio", "cron_output": "Salida del trabajo cron", "system_mail": "Smartd / correo del sistema", + "apt_listchanges": "Avisos de paquetes de apt-listchanges", "webhook_test": "prueba de webhook", "update_available": "Actualizaciones disponibles (heredadas)", "unknown_persistent": "Verificar no disponible", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 5df18786..aa79448e 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1775,6 +1775,7 @@ "service_fail_batch": "Plusieurs échecs de service", "cron_output": "Sortie de la tâche Cron", "system_mail": "Smartd / messagerie système", + "apt_listchanges": "Avis de paquets apt-listchanges", "webhook_test": "Test de webhook", "update_available": "Mises à jour disponibles (héritées)", "unknown_persistent": "Chèque indisponible", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 9a8a0cfa..85e5a7b4 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1775,6 +1775,7 @@ "service_fail_batch": "Diversi errori di servizio", "cron_output": "Output del lavoro Cron", "system_mail": "Smartd/posta di sistema", + "apt_listchanges": "Avvisi sui pacchetti apt-listchanges", "webhook_test": "Prova del webhook", "update_available": "Aggiornamenti disponibili (legacy)", "unknown_persistent": "Controlla non disponibile", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 1fd17ead..41b95f92 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1775,6 +1775,7 @@ "service_fail_batch": "Várias falhas de serviço", "cron_output": "Saída do cron job", "system_mail": "Smartd / correio do sistema", + "apt_listchanges": "Avisos de pacotes do apt-listchanges", "webhook_test": "Teste de webhook", "update_available": "Atualizações disponíveis (legado)", "unknown_persistent": "Verifique indisponível", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 746d9251..c1a52de4 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1774,6 +1774,7 @@ "service_fail_batch": "Zlyhanie viacerých služieb", "cron_output": "Výstup úlohy cron", "system_mail": "Správy Smartd a systému", + "apt_listchanges": "Oznámenia o balíkoch apt-listchanges", "webhook_test": "Test webhooku", "update_available": "Dostupné aktualizácie (starší typ)", "unknown_persistent": "Kontrola nie je dostupná", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 0ea42e49..aff22761 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1775,6 +1775,7 @@ "service_fail_batch": "Flera tjänstefel", "cron_output": "Cron-jobbutdata", "system_mail": "Smartd / systemmail", + "apt_listchanges": "Paketmeddelanden från apt-listchanges", "webhook_test": "Webhook test", "update_available": "Uppdateringar tillgängliga (legacy)", "unknown_persistent": "Kontrollera inte tillgänglig", diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index f61041b1..8b6057a4 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -196,6 +196,20 @@ def _hostname() -> str: return resolved +def _new_post_install_update_versions( + updates: list[dict[str, Any]], + notified_versions: dict[str, set[str]], +) -> dict[str, str]: + """Return only optimization versions that have never been announced.""" + available: dict[str, str] = {} + for update in updates: + key = str(update.get('key', '') or '').strip() + version = str(update.get('available_version', '') or '').strip() + if key and version and version not in notified_versions.get(key, set()): + available[key] = version + return available + + def capture_journal_context(keywords: list, lines: int = 30, since: str = "5 minutes ago") -> str: """Capture relevant journal lines for AI context enrichment. @@ -2567,11 +2581,9 @@ class PollingCollector: self._last_ai_model_check = 0 # Sprint 12D: post-install function updates check, on the same # 24h cooldown as the Proxmox/ProxMenux update checks. Notify - # once per *changed set* of update keys — repeating the same - # notification every 24h forever would be noisy, so we de-dupe - # against the previously-notified set. + # once for each genuinely new available version. The persistent + # history is stored in updates_available.json beside the scan. self._last_post_install_check = 0 - self._notified_post_install_keys: set[str] = set() # Sprint 14.7: fingerprint (item_id → latest_version) of the # last managed-installs update notification, across all types # in the registry. A new notification fires when the @@ -3503,11 +3515,9 @@ class PollingCollector: Sprint 12A's detector runs at AppImage startup and writes ``updates_available.json``. This check refreshes the snapshot every 24h (matching the other update channels), and emits a - single ``post_install_update`` event the first time the *set* of - available updates changes. Repeating the same notification every - 24h forever would be noisy, so we de-dupe against the previously - notified set of tool keys: only when a new tool joins the list - (or an existing one disappears) does a fresh notification fire. + single ``post_install_update`` event when a tool exposes an available + version that has never been announced. Applying one item merely + shrinks the pending set and must not produce a second notification. """ now = time.time() if now - self._last_post_install_check < self.UPDATE_CHECK_INTERVAL: @@ -3523,16 +3533,12 @@ class PollingCollector: return if not updates: - # All caught up. Reset so a future bump triggers a fresh - # notification instead of being suppressed by stale state. - self._notified_post_install_keys = set() return - new_keys = {u.get('key', '') for u in updates if u.get('key')} - if new_keys == self._notified_post_install_keys: - return # already notified about this exact set - - self._notified_post_install_keys = new_keys + notified_versions = post_install_versions.load_notified_versions() + new_versions = _new_post_install_update_versions(updates, notified_versions) + if not new_versions: + return # Pre-format the bullet list here so the template can drop it # straight in with `{tool_list}` (the renderer is plain @@ -3567,6 +3573,9 @@ class PollingCollector: 'post_install_update', 'INFO', data, source='polling', entity='node', entity_id='', )) + for key, version in new_versions.items(): + notified_versions.setdefault(key, set()).add(version) + post_install_versions.save_notified_versions(notified_versions) # ── Managed-installs update check (Sprint 14.7) ───────────────── @@ -4178,26 +4187,33 @@ class ProxmoxHookWatcher: # smartd and other system mail contains verbose boilerplate. # Extract just the actionable warning/error lines. if pve_type == 'system-mail' and message: - clean_lines = [] - for line in message.split('\n'): - stripped = line.strip() - # Skip boilerplate lines - if not stripped: - continue - if stripped.startswith('This message was generated'): - continue - if stripped.startswith('For details see'): - continue - if stripped.startswith('You can also use'): - continue - if stripped.startswith('The original message'): - continue - if stripped.startswith('Another message will'): - continue - if stripped.startswith('host name:') or stripped.startswith('DNS domain:'): - continue - clean_lines.append(stripped) - data['reason'] = '\n'.join(clean_lines).strip() if clean_lines else message.strip()[:500] + # apt-listchanges is package-maintainer NEWS, not diagnostic + # boilerplate. Preserve it verbatim (including paragraphs and + # signatures) so ProxMenux only attributes the source and never + # silently edits or truncates the upstream notice. + if event_type == 'apt_listchanges': + data['reason'] = message.strip() + else: + clean_lines = [] + for line in message.split('\n'): + stripped = line.strip() + # Skip boilerplate lines + if not stripped: + continue + if stripped.startswith('This message was generated'): + continue + if stripped.startswith('For details see'): + continue + if stripped.startswith('You can also use'): + continue + if stripped.startswith('The original message'): + continue + if stripped.startswith('Another message will'): + continue + if stripped.startswith('host name:') or stripped.startswith('DNS domain:'): + continue + clean_lines.append(stripped) + data['reason'] = '\n'.join(clean_lines).strip() if clean_lines else message.strip()[:500] # Extract VMID and VM name from message for vzdump events if pve_type == 'vzdump' and message: @@ -4319,6 +4335,14 @@ class ProxmoxHookWatcher: msg_lower = (message or '').lower() title_lower_sm = (title or '').lower() + # apt-listchanges forwards legitimate upstream package NEWS through + # PVE's generic system-mail bucket. Keep it, but classify it as an + # update notice of its own so the template can identify the source + # clearly instead of making package-maintainer prose look like a + # recommendation written by ProxMenux. + if 'apt-listchanges' in title_lower_sm or 'apt-listchanges' in msg_lower[:500]: + return 'apt_listchanges', 'node', '' + # ── Record disk observation regardless of noise filter ── # Even "noise" events are recorded as observations so the user # can see them in the Storage UI. We just don't send notifications. diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index a851f4b0..e60e70a1 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -1350,6 +1350,20 @@ class NotificationManager: # If AI is enabled AND rich_format is on, AI will include emojis directly # Pass channel_type so AI knows whether to append original (email only) channel_ai_config = {**ai_config, 'channel_type': ch_name} + # Availability notices are factual inventories, not advice. + # Even when the user enables experimental AI suggestions for + # diagnostic alerts, update announcements must only translate + # and format the versions/actions already supplied by the + # deterministic template. + if event_type in { + 'update_available', 'update_summary', 'pve_update', + 'proxmenux_update', 'post_install_update', 'apt_listchanges', + 'lxc_updates_available', 'secure_gateway_update_available', + 'nvidia_driver_update_available', + 'coral_driver_update_available', 'app_update_available', + 'docker_stack_update_available', + }: + channel_ai_config['ai_allow_suggestions'] = False # Isolate the AI/enrich block in its own try so a failure # here (raised from enrich_context_for_ai or any other @@ -1979,7 +1993,6 @@ class NotificationManager: # cooldown resumes after that first post-restart send. 'update_summary', 'proxmenux_update', - 'post_install_update', 'pve_update', 'update_available', 'nvidia_driver_update_available', diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 3facb161..25a28c9f 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -1033,6 +1033,17 @@ TEMPLATES = { # /etc/aliases or removing MAILTO from the cron job. Audit Tier 6 # — `system_mail` toggle no visible en UI / reportado por usuario. }, + 'apt_listchanges': { + 'title': '{hostname}: {pve_title}', + 'body': ( + 'Upstream package information forwarded by Proxmox VE through ' + 'apt-listchanges. The following text comes from the package ' + 'maintainer and is not a ProxMenux recommendation.\n\n{reason}' + ), + 'label': 'apt-listchanges package notices', + 'group': 'updates', + 'default_enabled': True, + }, 'webhook_test': { 'title': '{hostname}: Webhook test received', 'body': 'PVE webhook connectivity test successful.\n{reason}', @@ -1272,9 +1283,9 @@ TEMPLATES = { 'post_install_update': { 'title': '{hostname}: {count} ProxMenux optimization update(s) available', 'body': ( - '{count} optimization update(s) detected on this host.\n\n' - '🛠️ Tools:\n{tool_list}\n\n' - '💡 How to apply:\n' + '{count} ProxMenux optimization update(s) available on this host.\n\n' + '🛠️ Available versions:\n{tool_list}\n\n' + '💡 Apply from:\n' ' • ProxMenux Monitor → Settings → ProxMenux Optimizations\n' ' • Or run the post-install menu (option 2) → "Apply available updates"' ), @@ -1733,6 +1744,7 @@ CATEGORY_EMOJI = { EVENT_EMOJI = { # VM / CT 'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates + 'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail 'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied 'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release 'docker_stack_update_available': '\U0001F433', diff --git a/AppImage/scripts/post_install_versions.py b/AppImage/scripts/post_install_versions.py index a19d5cf8..79dcd605 100644 --- a/AppImage/scripts/post_install_versions.py +++ b/AppImage/scripts/post_install_versions.py @@ -92,6 +92,73 @@ def _read_text(path: Path) -> str: return "" +def _load_notified_versions_from_disk() -> dict[str, set[str]]: + """Read the versions already announced for each optimization. + + Notification history lives beside the existing update snapshot so no + additional runtime file is introduced. Older snapshots simply have no + ``notified_versions`` member and therefore start with an empty history. + """ + try: + payload = json.loads(_read_text(_UPDATES_JSON) or "{}") + except json.JSONDecodeError: + return {} + raw = payload.get("notified_versions", {}) + if not isinstance(raw, dict): + return {} + normalized: dict[str, set[str]] = {} + for key, versions in raw.items(): + if isinstance(versions, str): + versions = [versions] + if not isinstance(versions, list): + continue + clean = {str(version).strip() for version in versions if str(version).strip()} + if clean: + normalized[str(key)] = clean + return normalized + + +def _write_persisted_snapshot( + scanned_at: float, + updates: list[dict[str, Any]], + notified_versions: dict[str, set[str]], +) -> None: + """Atomically persist the update snapshot and notification history.""" + payload = { + "scanned_at": scanned_at, + "updates": updates, + "notified_versions": { + key: sorted(versions) + for key, versions in sorted(notified_versions.items()) + if versions + }, + } + _UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True) + temporary = _UPDATES_JSON.with_suffix(_UPDATES_JSON.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8") + temporary.replace(_UPDATES_JSON) + + +def load_notified_versions() -> dict[str, set[str]]: + """Return a defensive copy of optimization versions already announced.""" + return { + key: set(versions) + for key, versions in _load_notified_versions_from_disk().items() + } + + +def save_notified_versions(history: dict[str, set[str]]) -> None: + """Persist notification history without changing the current scan.""" + with _cache_lock: + scanned_at = float(_cache.get("scanned_at", 0.0) or 0.0) + updates = list(_cache.get("updates", [])) + try: + _write_persisted_snapshot(scanned_at, updates, history) + except OSError: + # Notification de-duplication remains best-effort on read-only hosts. + pass + + # --------------------------------------------------------------------------- # Bash script parser # --------------------------------------------------------------------------- @@ -353,13 +420,10 @@ def scan(persist: bool = True) -> dict[str, Any]: if persist: try: - _UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True) - _UPDATES_JSON.write_text( - json.dumps( - {"scanned_at": snapshot["scanned_at"], "updates": updates}, - indent=2, - ), - encoding="utf-8", + _write_persisted_snapshot( + snapshot["scanned_at"], + updates, + _load_notified_versions_from_disk(), ) except OSError: # Writing the on-disk cache is best-effort. If /usr/local