diff --git a/src/ccbot/bot/_session_create.py b/src/ccbot/bot/_session_create.py index 9ac2259c..a97c1542 100644 --- a/src/ccbot/bot/_session_create.py +++ b/src/ccbot/bot/_session_create.py @@ -11,16 +11,19 @@ from __future__ import annotations +import asyncio import logging from telegram.ext import ContextTypes +from ..handlers.directory_browser import take_pending_text from ..handlers.message_sender import safe_edit, safe_send from ..handlers.notifications import ( detach_paused_cards_at_message, paint_card_on_carrier, ) from ..local_terminal import open_terminal_for_window +from ..naming import maybe_auto_name from ..session import session_manager from ..tmux_manager import tmux_manager @@ -165,16 +168,17 @@ async def create_and_activate_session( if ws.session_id and not sess.claude_session_id: session_manager.set_session_claude_id(sess.id, ws.session_id) - # Forward any pending text held while the picker was up. - pending_text = context.user_data.get("_pending_text") if context.user_data else None + # Forward any pending text held while the picker was up. ``take_pending_text`` + # drops a stale stash (older than PENDING_TEXT_TTL_S) so a message typed + # hours ago against a since-gone session can never be injected here — the + # 2026-06-28 "medical insurance" misroute. + pending_text = take_pending_text(context.user_data) if pending_text: logger.debug( "Forwarding pending text to window %s (len=%d)", created_wname, len(pending_text), ) - if context.user_data is not None: - context.user_data.pop("_pending_text", None) send_ok, send_msg = await session_manager.send_to_window( created_wid, pending_text ) @@ -185,3 +189,11 @@ async def create_and_activate_session( user.id, f"❌ Failed to send pending message: {send_msg}", ) + elif len(pending_text) >= 20: + # Name the session from its actual first human message (this + # pending text), not from whatever inbound happens to land + # first. ``maybe_auto_name`` is one-shot (re-entrancy guard), + # so a later message can't override it. + asyncio.create_task( + maybe_auto_name(sess.id, pending_text, getattr(user, "id", None)) + ) diff --git a/src/ccbot/bot/callbacks/window_picker.py b/src/ccbot/bot/callbacks/window_picker.py index a15e2e53..41b996e6 100644 --- a/src/ccbot/bot/callbacks/window_picker.py +++ b/src/ccbot/bot/callbacks/window_picker.py @@ -24,6 +24,7 @@ UNBOUND_WINDOWS_KEY, build_directory_browser, clear_window_picker_state, + take_pending_text, ) from ...handlers.message_sender import safe_edit, safe_send from ...session import session_manager @@ -77,11 +78,7 @@ async def handle( await safe_edit(query, f"✅ Bound to window `{display}`") - pending_text = ( - context.user_data.get("_pending_text") if context.user_data else None - ) - if context.user_data is not None: - context.user_data.pop("_pending_text", None) + pending_text = take_pending_text(context.user_data) if pending_text: send_ok, send_msg = await session_manager.send_to_window( selected_wid, pending_text diff --git a/src/ccbot/bot/messages.py b/src/ccbot/bot/messages.py index 11a18752..4946115c 100644 --- a/src/ccbot/bot/messages.py +++ b/src/ccbot/bot/messages.py @@ -34,6 +34,7 @@ STATE_SELECTING_SESSION, STATE_SELECTING_WINDOW, build_directory_browser, + stash_pending_text, ) from ..handlers.interactive_ui import ( get_interactive_window, @@ -776,7 +777,7 @@ async def _resolve_active_window( context.user_data[BROWSE_PATH_KEY] = start_path context.user_data[BROWSE_PAGE_KEY] = 0 context.user_data[BROWSE_DIRS_KEY] = subdirs - context.user_data["_pending_text"] = text + stash_pending_text(context.user_data, text) await safe_reply(update.message, msg_text, reply_markup=keyboard) return None diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index 3805c9da..cdda4df5 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -12,6 +12,7 @@ - clear_browse_state: Clear browsing state from user_data """ +import logging import os import time from collections.abc import Callable @@ -55,6 +56,65 @@ SESSIONS_KEY = "cached_sessions" # Cache of ClaudeSession list[Any] SESSIONS_PAGE_KEY = "sessions_page" # current page index in session picker +logger = logging.getLogger(__name__) + +# Pending-text stash: a message the user typed while no active session +# existed is held in user_data until a session is created, then forwarded. +PENDING_TEXT_KEY = "_pending_text" + +# Max age before a stashed pending message is treated as abandoned. +# context.user_data lives in memory and is NOT cleared between messages — +# only a bot restart wipes it. Without an expiry a stash can survive for +# hours and then be injected into an unrelated session created much later +# (the 2026-06-28 "medical insurance" misroute: a 01:05 message resurfaced +# in a 10:34 session). 10 min is generous for a browser/picker flow yet +# kills any multi-hour leak. +PENDING_TEXT_TTL_S = 600.0 + + +def stash_pending_text(user_data: dict[str, Any] | None, text: str) -> None: + """Hold the user's text while a directory/session picker is up. + + Stamped with a timestamp so ``take_pending_text`` can reject a stale + stash. See ``PENDING_TEXT_TTL_S``. + """ + if user_data is not None: + user_data[PENDING_TEXT_KEY] = {"text": text, "ts": time.time()} + + +def take_pending_text( + user_data: dict[str, Any] | None, max_age_s: float | None = PENDING_TEXT_TTL_S +) -> str | None: + """Pop the stashed pending text, returning it only if still fresh. + + Always clears the slot (a leaked stash must never re-fire). Returns + None when absent, empty, or older than ``max_age_s``. Tolerates the + legacy bare-string format (treated as fresh) for state in flight + across a deploy. + """ + if not user_data: + return None + raw = user_data.pop(PENDING_TEXT_KEY, None) + if raw is None: + return None + if isinstance(raw, str): # legacy pre-TTL format + return raw or None + if not isinstance(raw, dict): + return None + text = raw.get("text") + if not text: + return None + age = time.time() - float(raw.get("ts", 0.0)) + if max_age_s is not None and age > max_age_s: + logger.info( + "Dropping stale pending text (age=%.0fs > %.0fs, len=%d)", + age, + max_age_s, + len(text), + ) + return None + return text + def clear_browse_state(user_data: dict[str, Any] | None) -> None: """Clear directory browsing state keys from user_data.""" diff --git a/tests/e2e/test_inbound_routing.py b/tests/e2e/test_inbound_routing.py index 8eee8ed0..3ca555ec 100644 --- a/tests/e2e/test_inbound_routing.py +++ b/tests/e2e/test_inbound_routing.py @@ -85,8 +85,11 @@ async def test_text_with_no_active_session_opens_dir_browser(fake_tmux, fake_bot await text_handler(update, ctx) assert fake_tmux.sent == [] - # The pending text is held for forwarding after session creation. - assert ctx.user_data.get("_pending_text") == "hello there" + # The pending text is held (timestamped) for forwarding after session + # creation; ``take_pending_text`` reads it back with a freshness guard. + from ccbot.handlers.directory_browser import take_pending_text + + assert take_pending_text(ctx.user_data) == "hello there" @pytest.mark.asyncio diff --git a/tests/test_pending_text_ttl.py b/tests/test_pending_text_ttl.py new file mode 100644 index 00000000..cd130771 --- /dev/null +++ b/tests/test_pending_text_ttl.py @@ -0,0 +1,81 @@ +"""Pending-text stash freshness guard (regression for the 2026-06-28 misroute). + +A message typed while no active session existed is held in ``user_data`` +and forwarded once a session is created. ``user_data`` lives in memory and +is only cleared by a bot restart, so without an expiry a stash could +survive for hours and then be injected into an unrelated session created +much later — exactly how a 01:05 "smartphone 100%" message resurfaced in a +10:34 session that then got auto-named "medical insurance". + +These tests pin the TTL contract of ``stash_pending_text`` / +``take_pending_text``. +""" + +import time + +from ccbot.handlers.directory_browser import ( + PENDING_TEXT_KEY, + PENDING_TEXT_TTL_S, + stash_pending_text, + take_pending_text, +) + + +def test_round_trip_fresh() -> None: + ud: dict = {} + stash_pending_text(ud, "hello world") + assert take_pending_text(ud) == "hello world" + # Slot is cleared after taking — a leaked stash must never re-fire. + assert PENDING_TEXT_KEY not in ud + assert take_pending_text(ud) is None + + +def test_stale_is_dropped() -> None: + ud: dict = {} + stash_pending_text(ud, "9.5h old night message") + # Backdate the stamp past the TTL. + ud[PENDING_TEXT_KEY]["ts"] = time.time() - (PENDING_TEXT_TTL_S + 60) + assert take_pending_text(ud) is None + # Even though dropped, the slot is still cleared. + assert PENDING_TEXT_KEY not in ud + + +def test_just_within_ttl_survives() -> None: + ud: dict = {} + stash_pending_text(ud, "recent") + ud[PENDING_TEXT_KEY]["ts"] = time.time() - (PENDING_TEXT_TTL_S - 30) + assert take_pending_text(ud) == "recent" + + +def test_custom_max_age() -> None: + ud: dict = {} + stash_pending_text(ud, "x") + ud[PENDING_TEXT_KEY]["ts"] = time.time() - 5 + assert take_pending_text(ud, max_age_s=1) is None + + +def test_no_expiry_when_max_age_none() -> None: + ud: dict = {} + stash_pending_text(ud, "x") + ud[PENDING_TEXT_KEY]["ts"] = time.time() - 99999 + assert take_pending_text(ud, max_age_s=None) == "x" + + +def test_legacy_bare_string_tolerated() -> None: + # State in flight across the deploy that introduces the dict format. + ud: dict = {PENDING_TEXT_KEY: "legacy text"} + assert take_pending_text(ud) == "legacy text" + assert PENDING_TEXT_KEY not in ud + + +def test_absent_and_empty() -> None: + assert take_pending_text(None) is None + assert take_pending_text({}) is None + assert ( + take_pending_text({PENDING_TEXT_KEY: {"text": "", "ts": time.time()}}) is None + ) + + +def test_stash_none_user_data_is_noop() -> None: + # Must not raise when user_data is unavailable. + stash_pending_text(None, "x")