From d7488a55796b7edde3102958a2cfad84ea6db30b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:19:20 -0700 Subject: [PATCH] fix(telegram): treat "never checked" identity as stale on a fresh-boot clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity-refresh TTL used 0.0 as the "never checked" sentinel and compared it against time.monotonic(). That epoch is arbitrary and starts near zero on a freshly-booted host, so on CI runners and containers `monotonic() - 0.0` was itself below the TTL — "never checked" read as "checked just now" and the first identity refresh was suppressed for the first 5 minutes of uptime. The stale-handle recovery therefore did nothing on exactly the machines most likely to be freshly booted. Invisible on a long-lived dev box (uptime >> TTL); caught by CI. - Sentinel is now None, meaning never checked and always stale. - Both TTL comparison sites route through _bot_identity_is_fresh(). - Regression test pins the invariant under a faked 12s-uptime clock. Verified by re-running the recheck tests against a simulated 3s-uptime host: green with the fix, and restoring the 0.0 sentinel reproduces the exact CI failure locally. --- plugins/platforms/telegram/adapter.py | 25 +++++++++++++++++---- tests/gateway/test_telegram_group_gating.py | 24 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index b7e270a9f296..f9f9e21d0b61 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -759,7 +759,12 @@ class TelegramAdapter(BasePlatformAdapter): # pointing at the old handle until something calls getMe again. Every # mention/routing comparison reads _current_bot_username() instead. self._bot_username_observed: Optional[str] = None - self._bot_identity_checked_at: float = 0.0 + # None = never checked. Must NOT be 0.0: these are compared against + # time.monotonic(), whose epoch is arbitrary and on a freshly-booted + # host starts near zero — so a 0.0 sentinel reads as "checked just + # now" and suppresses the first refresh for the first TTL seconds of + # uptime. + self._bot_identity_checked_at: Optional[float] = None self._bot_identity_refresh_task: Optional[asyncio.Task] = None # Consecutive heartbeat probes that saw queued updates the running # poller is not consuming. get_me() can't see this — the send path is @@ -7873,6 +7878,19 @@ class TelegramAdapter(BasePlatformAdapter): continue self._note_bot_username(getattr(candidate, "username", None)) + def _bot_identity_is_fresh(self) -> bool: + """True when identity was re-read within the TTL. + + ``None`` means never checked, which is always stale. Do not fold the + sentinel into ``0.0``: monotonic clocks have an arbitrary epoch that + can legitimately be smaller than the TTL on a freshly-booted host, + which would make "never" look like "just now". + """ + checked_at = getattr(self, "_bot_identity_checked_at", None) + if checked_at is None: + return False + return (time.monotonic() - checked_at) < self._BOT_IDENTITY_TTL_SECONDS + async def _refresh_bot_identity(self, *, force: bool = False) -> None: """Re-read the bot's identity from Telegram when the cache may be stale. @@ -7883,8 +7901,7 @@ class TelegramAdapter(BasePlatformAdapter): bot = self._bot if bot is None or not callable(getattr(bot, "get_me", None)): return - now = time.monotonic() - if not force and (now - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + if not force and self._bot_identity_is_fresh(): return try: me = await asyncio.wait_for(bot.get_me(), self._BOT_IDENTITY_PROBE_TIMEOUT) @@ -8047,7 +8064,7 @@ class TelegramAdapter(BasePlatformAdapter): existing = getattr(self, "_bot_identity_refresh_task", None) if existing is not None and not existing.done(): return - if (time.monotonic() - getattr(self, "_bot_identity_checked_at", 0.0)) < self._BOT_IDENTITY_TTL_SECONDS: + if self._bot_identity_is_fresh(): return try: loop = asyncio.get_running_loop() diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 018be18778a1..fa05f605af96 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1597,3 +1597,27 @@ def test_clean_bot_trigger_text_strips_the_current_handle(): adapter._note_bot_username("new_helper_bot") assert adapter._clean_bot_trigger_text("@new_helper_bot ship it") == "ship it" + + +def test_identity_freshness_does_not_depend_on_host_uptime(monkeypatch): + """A never-checked identity is stale even when monotonic() is near zero. + + time.monotonic() has an arbitrary epoch: on a freshly-booted host (CI + runners, containers) it starts near 0. A 0.0 "never checked" sentinel + therefore reads as "checked just now" for the first TTL seconds of + uptime, suppressing the very first identity refresh — so the stale-handle + recovery silently did nothing on exactly the machines most likely to be + freshly booted. Caught by CI, invisible on a long-lived dev box. + """ + adapter = _make_adapter(require_mention=True) + adapter._bot = _IdentityBot(cached="old_helper_bot", server="new_helper_bot") + + # Simulate a host that booted 12 seconds ago. + monkeypatch.setattr( + "plugins.platforms.telegram.adapter.time.monotonic", lambda: 12.0 + ) + + assert adapter._bot_identity_is_fresh() is False + + adapter._note_bot_username("new_helper_bot") + assert adapter._bot_identity_is_fresh() is True