homelab/telegram/idle_timer.py
Mikkel Georgsen 488d94ebcf feat(03-01): create SessionIdleTimer module
- Asyncio-based per-session idle timeout detection
- reset() method updates timestamp and creates new timer task
- cancel() method stops timer on shutdown/archive
- Properties for seconds_since_activity and last_activity
- Automatic callback firing after configurable timeout
2026-02-04 23:28:04 +00:00

147 lines
4.8 KiB
Python

"""
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