diff --git a/src/telegram_codex_bot/bot.py b/src/telegram_codex_bot/bot.py index 3683a58..6a29872 100644 --- a/src/telegram_codex_bot/bot.py +++ b/src/telegram_codex_bot/bot.py @@ -228,6 +228,7 @@ _runtime_stopped = False _codex_update_prompted_versions: set[str] = set() _codex_update_apply_lock: asyncio.Lock | None = None +_CODEX_UPDATE_PROMPT_STATE_FILENAME = "codex_update_prompt_state.json" @dataclass @@ -3788,15 +3789,67 @@ def _codex_update_prompt_key(result: CodexUpdateResult) -> str: return result.latest_version or result.message or "unknown" +def _codex_update_prompt_state_file() -> Path: + """Return the state file tracking already prompted Codex CLI versions.""" + return app_dir() / _CODEX_UPDATE_PROMPT_STATE_FILENAME + + +def _load_codex_update_prompted_versions() -> set[str]: + """Load Codex CLI versions that have already shown an update prompt.""" + path = _codex_update_prompt_state_file() + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return set() + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read Codex update prompt state %s: %s", path, exc) + return set() + + if not isinstance(payload, dict): + return set() + + prompted_versions = payload.get("prompted_versions") + if not isinstance(prompted_versions, list): + return set() + return { + version for version in prompted_versions if isinstance(version, str) and version + } + + +def _save_codex_update_prompted_versions(prompted_versions: set[str]) -> None: + """Persist Codex CLI versions that have already shown an update prompt.""" + path = _codex_update_prompt_state_file() + try: + atomic_write_json( + path, + {"prompted_versions": sorted(prompted_versions)}, + ) + except OSError as exc: + logger.warning("Failed to write Codex update prompt state %s: %s", path, exc) + + +def _mark_codex_update_prompted(key: str) -> bool: + """Return True when a Codex update prompt key is newly marked.""" + persisted_versions = _load_codex_update_prompted_versions() + prompted_versions = persisted_versions | _codex_update_prompted_versions + if key in prompted_versions: + _codex_update_prompted_versions.update(prompted_versions) + return False + + prompted_versions.add(key) + _codex_update_prompted_versions.update(prompted_versions) + _save_codex_update_prompted_versions(prompted_versions) + return True + + async def notify_codex_update_available( bot: Bot, result: CodexUpdateResult, ) -> None: """Notify allowed Telegram users that a Codex CLI update needs approval.""" key = _codex_update_prompt_key(result) - if key in _codex_update_prompted_versions: + if not _mark_codex_update_prompted(key): return - _codex_update_prompted_versions.add(key) current = result.current_version or "unknown" latest = result.latest_version or "unknown" diff --git a/tests/telegram_codex_bot/test_codex_update_prompt.py b/tests/telegram_codex_bot/test_codex_update_prompt.py index 5a003a9..1e30a23 100644 --- a/tests/telegram_codex_bot/test_codex_update_prompt.py +++ b/tests/telegram_codex_bot/test_codex_update_prompt.py @@ -1,3 +1,4 @@ +import json from unittest.mock import AsyncMock, MagicMock import pytest @@ -23,7 +24,11 @@ def _make_callback_update(data: str) -> MagicMock: @pytest.mark.asyncio -async def test_notify_codex_update_available_sends_private_prompts(monkeypatch): +async def test_notify_codex_update_available_sends_private_prompts( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("TELEGRAM_CODEX_BOT_DIR", str(tmp_path)) monkeypatch.setattr(bot_module.config, "allowed_users", {222, 111}) monkeypatch.setattr(bot_module, "_codex_update_prompted_versions", set()) safe_send = AsyncMock() @@ -48,6 +53,36 @@ async def test_notify_codex_update_available_sends_private_prompts(monkeypatch): keyboard = safe_send.await_args_list[0].kwargs["reply_markup"] assert keyboard.inline_keyboard[0][0].callback_data == CB_CODEX_UPDATE_APPLY assert keyboard.inline_keyboard[0][1].callback_data == CB_CODEX_UPDATE_DISMISS + state = json.loads((tmp_path / "codex_update_prompt_state.json").read_text()) + assert state == {"prompted_versions": ["0.126.0"]} + + +@pytest.mark.asyncio +async def test_notify_codex_update_available_skips_persisted_prompted_version( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("TELEGRAM_CODEX_BOT_DIR", str(tmp_path)) + monkeypatch.setattr(bot_module.config, "allowed_users", {222, 111}) + (tmp_path / "codex_update_prompt_state.json").write_text( + json.dumps({"prompted_versions": ["0.126.0"]}), + encoding="utf-8", + ) + monkeypatch.setattr(bot_module, "_codex_update_prompted_versions", set()) + safe_send = AsyncMock() + monkeypatch.setattr(bot_module, "safe_send", safe_send) + + result = CodexUpdateResult( + checked=True, + supported=True, + update_available=True, + current_version="0.125.0", + latest_version="0.126.0", + ) + + await bot_module.notify_codex_update_available(MagicMock(), result) + + safe_send.assert_not_awaited() @pytest.mark.asyncio