Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions src/telegram_codex_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist prompts only after successful notification

When Telegram returns RetryAfter (which safe_send re-raises), this save has already written the latest version to codex_update_prompt_state.json. The exception aborts notify_codex_update_available before the prompt is delivered to all users, and subsequent checks/restarts will skip the update because the version is now persisted; save the key only after the send loop completes, or roll it back on failure.

Useful? React with 👍 / 👎.

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"
Expand Down
37 changes: 36 additions & 1 deletion tests/telegram_codex_bot/test_codex_update_prompt.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from unittest.mock import AsyncMock, MagicMock

import pytest
Expand All @@ -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()
Expand All @@ -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
Expand Down
Loading