diff --git a/telegram/idle_timer.py b/telegram/idle_timer.py new file mode 100644 index 0000000..8d8fff7 --- /dev/null +++ b/telegram/idle_timer.py @@ -0,0 +1,147 @@ +""" +Session idle timer management for Claude Code Telegram bot. + +This module provides the SessionIdleTimer class that manages per-session idle +timeouts using asyncio. Each session has its own timer that fires a callback +after a configurable timeout period. Timers reset on activity and can be +cancelled on session shutdown/archive. + +Example: + async def handle_timeout(session_name: str): + print(f"Session {session_name} timed out") + + timer = SessionIdleTimer("my-session", timeout_seconds=600, on_timeout=handle_timeout) + timer.reset() # Start the timer + # ... activity occurs ... + timer.reset() # Reset timer on activity + # ... no activity for 600 seconds ... + # handle_timeout("my-session") called automatically +""" + +import asyncio +import logging +from datetime import datetime, timezone +from typing import Awaitable, Callable, Optional + +logger = logging.getLogger(__name__) + + +class SessionIdleTimer: + """ + Manages idle timeout for a single session. + + Provides per-session timeout detection with automatic callback firing + after inactivity. Timer can be reset on activity and cancelled on shutdown. + + Attributes: + session_name: Name of the session this timer tracks + timeout_seconds: Idle timeout in seconds + on_timeout: Async callback to invoke when timeout fires + last_activity: UTC timestamp of last activity + seconds_since_activity: Float seconds since last activity + """ + + def __init__( + self, + session_name: str, + timeout_seconds: int, + on_timeout: Callable[[str], Awaitable[None]] + ): + """ + Initialize SessionIdleTimer. + + Args: + session_name: Name of the session to track + timeout_seconds: Idle timeout in seconds + on_timeout: Async callback(session_name) to invoke when timeout fires + """ + self.session_name = session_name + self.timeout_seconds = timeout_seconds + self.on_timeout = on_timeout + + self._timer_task: Optional[asyncio.Task] = None + self._last_activity = datetime.now(timezone.utc) + + logger.debug( + f"SessionIdleTimer initialized: session={session_name}, " + f"timeout={timeout_seconds}s" + ) + + def reset(self) -> None: + """ + Reset the idle timer. + + Updates last activity timestamp to now, cancels any existing timer task, + and creates a new background task that will fire the timeout callback + after timeout_seconds of inactivity. + + Call this whenever activity occurs on the session. + """ + # Update last activity timestamp + self._last_activity = datetime.now(timezone.utc) + + # Cancel existing timer if running + if self._timer_task and not self._timer_task.done(): + self._timer_task.cancel() + logger.debug(f"Cancelled existing timer for session '{self.session_name}'") + + # Create new timer task + self._timer_task = asyncio.create_task(self._wait_for_timeout()) + logger.debug( + f"Started idle timer for session '{self.session_name}': " + f"{self.timeout_seconds}s" + ) + + async def _wait_for_timeout(self) -> None: + """ + Background task that waits for timeout then fires callback. + + Sleeps for timeout_seconds, then invokes on_timeout callback with + session name. Catches asyncio.CancelledError silently (timer was reset). + """ + try: + await asyncio.sleep(self.timeout_seconds) + + # Timeout fired - call callback + logger.info( + f"Session '{self.session_name}' idle timeout fired " + f"({self.timeout_seconds}s)" + ) + await self.on_timeout(self.session_name) + + except asyncio.CancelledError: + # Timer was reset or cancelled - this is normal + logger.debug(f"Timer cancelled for session '{self.session_name}'") + + def cancel(self) -> None: + """ + Cancel the idle timer. + + Stops the background timer task if running. Used on session shutdown + or archive to prevent timeout callback from firing. + """ + if self._timer_task and not self._timer_task.done(): + self._timer_task.cancel() + logger.debug(f"Cancelled timer for session '{self.session_name}'") + + @property + def seconds_since_activity(self) -> float: + """ + Get seconds since last activity. + + Returns: + Float seconds elapsed since last reset() call + """ + now = datetime.now(timezone.utc) + delta = now - self._last_activity + return delta.total_seconds() + + @property + def last_activity(self) -> datetime: + """ + Get timestamp of last activity. + + Returns: + UTC datetime of last reset() call + """ + return self._last_activity