From 8955193f2f1a8d7d3619bf5b772422baa340a4df Mon Sep 17 00:00:00 2001 From: QueryPlanner Date: Fri, 24 Jul 2026 21:40:58 +0530 Subject: [PATCH] feat: add saved route traffic updates - Persist user-scoped routes as place IDs and labels - Add fresh checks and recurring traffic update tools - Protect route data in Telegram and observability paths - Cover saved route behavior with unit and prompt evaluations --- .env.example | 2 + docs/google-maps-routes.md | 13 + src/blacki/container.py | 18 + src/blacki/prompt.py | 25 +- src/blacki/registry.py | 31 +- src/blacki/reminders/tools.py | 5 +- src/blacki/routes/__init__.py | 16 + src/blacki/routes/common_tools.py | 658 +++++++++++++++++++++++++ src/blacki/routes/scheduling.py | 52 ++ src/blacki/routes/storage.py | 252 ++++++++++ src/blacki/telegram/bot.py | 33 +- src/blacki/utils/privacy.py | 8 + tests/eval/blacki_eval/agent.py | 6 +- tests/eval/routes.evalset.json | 180 +++++++ tests/eval/test_eval_agent.py | 6 +- tests/reminders/test_reminder_tools.py | 18 + tests/routes/test_common_tools.py | 610 +++++++++++++++++++++++ tests/routes/test_scheduling.py | 42 ++ tests/routes/test_storage.py | 135 +++++ tests/test_container.py | 19 + tests/test_privacy.py | 16 + tests/test_registry.py | 18 + tests/test_telegram_bot.py | 69 ++- 23 files changed, 2220 insertions(+), 12 deletions(-) create mode 100644 src/blacki/routes/common_tools.py create mode 100644 src/blacki/routes/scheduling.py create mode 100644 src/blacki/routes/storage.py create mode 100644 tests/routes/test_common_tools.py create mode 100644 tests/routes/test_scheduling.py create mode 100644 tests/routes/test_storage.py diff --git a/.env.example b/.env.example index 21165a8..82371c1 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,8 @@ OPENROUTER_API_KEY=replace-me # Use a separate server-side key restricted to the Routes API and apply # project quotas or budget alerts before enabling traffic-aware requests. # GOOGLE_MAPS_ROUTES_API_KEY=your_google_maps_routes_api_key_here +# GOOGLE_MAPS_SAVED_ROUTE_LIMIT=20 +# GOOGLE_MAPS_ROUTE_UPDATE_LIMIT=10 # --------------------------------------------------------------------------- # Browser Use Cloud (optional browser automation for the agent) diff --git a/docs/google-maps-routes.md b/docs/google-maps-routes.md index df80691..195d667 100644 --- a/docs/google-maps-routes.md +++ b/docs/google-maps-routes.md @@ -49,6 +49,19 @@ For a current driving estimate, the agent uses: `OPTIMISTIC` and `PESSIMISTIC` are also supported for driving. Non-driving modes use `NONE` because Google traffic models are limited to driving routes. +## Saved routes and scheduled updates + +When SQLite storage is enabled, Blacki can save common routes and schedule +recurring traffic checks. It persists only Google place IDs, user-authored +labels, route preferences, and reminder metadata. Raw addresses, API responses, +and traffic snapshots are not stored. + +Saved-route operations are owner-scoped. In Telegram they are intentionally +limited to private chats because group and topic sessions use a shared chat +identity. `GOOGLE_MAPS_SAVED_ROUTE_LIMIT` defaults to 20 saved routes per user, +and `GOOGLE_MAPS_ROUTE_UPDATE_LIMIT` defaults to 10 active traffic updates. +Scheduled checks must be at least 15 minutes apart. + ## Location and time inputs Plain location strings are sent as addresses. A known Google place ID can be diff --git a/src/blacki/container.py b/src/blacki/container.py index 71e4b06..4571e65 100644 --- a/src/blacki/container.py +++ b/src/blacki/container.py @@ -29,6 +29,7 @@ from blacki.calories.storage import SqliteCalorieStorage from blacki.declarative_db.storage import SqliteDeclarativeDbStorage from blacki.reminders.storage import SqliteReminderStorage + from blacki.routes.storage import SqliteSavedRouteStorage from blacki.utils.preferences import SqlitePreferencesStorage from blacki.workouts.storage import SqliteWorkoutStorage @@ -138,6 +139,9 @@ class AppContainer: _declarative_db_storage: SqliteDeclarativeDbStorage | None = field( default=None, init=False, repr=False ) + _saved_route_storage: SqliteSavedRouteStorage | None = field( + default=None, init=False, repr=False + ) @classmethod async def create(cls, sqlite_path: str | Path) -> Self: @@ -182,6 +186,10 @@ async def _close_storages(self) -> None: await self._declarative_db_storage.close() self._declarative_db_storage = None + if self._saved_route_storage is not None: + await self._saved_route_storage.close() + self._saved_route_storage = None + async def initialize_all_storages(self) -> None: """Initialize all storage instances. @@ -193,6 +201,7 @@ async def initialize_all_storages(self) -> None: await self.workout_storage.initialize() await self.preferences_storage.initialize() await self.declarative_db_storage.initialize() + await self.saved_route_storage.initialize() @property def lock(self) -> asyncio.Lock: @@ -245,3 +254,12 @@ def declarative_db_storage(self) -> SqliteDeclarativeDbStorage: self.conn, self._lock ) return self._declarative_db_storage + + @property + def saved_route_storage(self) -> SqliteSavedRouteStorage: + """Get or create the saved-route storage instance.""" + if self._saved_route_storage is None: + from blacki.routes.storage import SqliteSavedRouteStorage + + self._saved_route_storage = SqliteSavedRouteStorage(self.conn, self._lock) + return self._saved_route_storage diff --git a/src/blacki/prompt.py b/src/blacki/prompt.py index 1518566..acafa2b 100644 --- a/src/blacki/prompt.py +++ b/src/blacki/prompt.py @@ -138,6 +138,18 @@ as the traffic model for non-driving modes. Treat avoid options as preferences, not guarantees, and preserve all provider warnings and Google Maps attribution. Ask one focused question when an endpoint or required departure time is missing. + +Use save_common_route, update_common_route, and delete_common_route only after +an explicit user request to mutate a saved route. Never infer that a route +should be saved from a route lookup. Use user-authored labels and ask for a +more precise endpoint when place resolution is ambiguous. Use +list_common_routes for discovery and check_common_route for every fresh saved +route estimate. Saved routes are point-in-time checks, not live tracking. + +Use schedule_common_route_update only when the user explicitly asks for +recurring traffic updates and supplies a schedule. Do not simulate continuous +tracking. A scheduled route update must use check_common_route and return its +summary with Google Maps attribution; never guess when a lookup fails. """ @@ -199,7 +211,18 @@ } ), "reminder": frozenset({"schedule_reminder", "list_reminders", "cancel_reminder"}), - "routes": frozenset({"get_route_estimate", "compare_route_scenarios"}), + "routes": frozenset( + { + "get_route_estimate", + "compare_route_scenarios", + "save_common_route", + "list_common_routes", + "check_common_route", + "update_common_route", + "delete_common_route", + "schedule_common_route_update", + } + ), "search": frozenset({"exa_search", "brave_search"}), } diff --git a/src/blacki/registry.py b/src/blacki/registry.py index 0976bd5..e7849e8 100644 --- a/src/blacki/registry.py +++ b/src/blacki/registry.py @@ -61,7 +61,9 @@ def build_tools(config: ToolConfig) -> list[Any]: logger.info("Brave Search tool enabled") if config.google_maps_routes_api_key: - tools.extend(_build_google_routes_tools()) + tools.extend( + _build_google_routes_tools(include_saved_routes=bool(config.sqlite_path)) + ) logger.info("Google Maps Routes tools enabled") if config.sqlite_path: @@ -109,12 +111,33 @@ def _build_brave_search_tools() -> list[Any]: return [] -def _build_google_routes_tools() -> list[Any]: +def _build_google_routes_tools(*, include_saved_routes: bool = False) -> list[Any]: """Build Google Maps Routes tools.""" try: - from blacki.routes import compare_route_scenarios, get_route_estimate + from blacki.routes import ( + check_common_route, + compare_route_scenarios, + delete_common_route, + get_route_estimate, + list_common_routes, + save_common_route, + schedule_common_route_update, + update_common_route, + ) - return [get_route_estimate, compare_route_scenarios] + tools = [get_route_estimate, compare_route_scenarios] + if include_saved_routes: + tools.extend( + [ + save_common_route, + list_common_routes, + check_common_route, + update_common_route, + delete_common_route, + schedule_common_route_update, + ] + ) + return tools except ImportError as e: logger.warning("Failed to load Google Maps Routes tools: %s", e) return [] diff --git a/src/blacki/reminders/tools.py b/src/blacki/reminders/tools.py index e045c20..7c520ff 100644 --- a/src/blacki/reminders/tools.py +++ b/src/blacki/reminders/tools.py @@ -270,9 +270,12 @@ def _parse_reminder_datetime(datetime_val: str | int | float) -> datetime: def _format_reminder(reminder: Reminder) -> dict[str, Any]: """Format a reminder for display.""" next_trigger_time = format_stored_instant_for_display(reminder.trigger_time) + message = reminder.message + if message.startswith('{"kind":"blacki.route_traffic_update"'): + message = "Scheduled traffic update for a saved route" return { "id": reminder.id, - "message": reminder.message, + "message": message, "trigger_time": next_trigger_time, "next_trigger_time": next_trigger_time, "is_sent": reminder.is_sent, diff --git a/src/blacki/routes/__init__.py b/src/blacki/routes/__init__.py index 0c8000a..0786ea7 100644 --- a/src/blacki/routes/__init__.py +++ b/src/blacki/routes/__init__.py @@ -1,11 +1,27 @@ """Google Maps Routes API tools.""" from .client import close_shared_routes_client +from .common_tools import ( + CommonRouteChanges, + check_common_route, + delete_common_route, + list_common_routes, + save_common_route, + schedule_common_route_update, + update_common_route, +) from .tools import RouteScenario, compare_route_scenarios, get_route_estimate __all__ = [ + "CommonRouteChanges", "RouteScenario", + "check_common_route", "close_shared_routes_client", "compare_route_scenarios", + "delete_common_route", "get_route_estimate", + "list_common_routes", + "save_common_route", + "schedule_common_route_update", + "update_common_route", ] diff --git a/src/blacki/routes/common_tools.py b/src/blacki/routes/common_tools.py new file mode 100644 index 0000000..11e724b --- /dev/null +++ b/src/blacki/routes/common_tools.py @@ -0,0 +1,658 @@ +"""ADK tools for user-owned common routes and scheduled traffic checks.""" + +from __future__ import annotations + +import logging +import os +from datetime import timedelta +from typing import Any + +from google.adk.tools import ToolContext +from pydantic import BaseModel, ConfigDict, Field + +from blacki.reminders import get_scheduler +from blacki.reminders.recurrence import get_next_trigger_time +from blacki.reminders.tools import _build_reminder_schedule +from blacki.utils.timezone import now_utc, utc_iso_seconds + +from .scheduling import encode_route_update_event, parse_route_update_event +from .storage import ( + DuplicateRouteNameError, + SavedRoute, + SavedRouteLimitError, + get_saved_route_storage, + normalize_route_name, +) +from .tools import ( + GOOGLE_MAPS_ATTRIBUTION, + _estimate_route, + _normalize_travel_mode, +) + +DEFAULT_SAVED_ROUTE_LIMIT = 20 +DEFAULT_ROUTE_UPDATE_LIMIT = 10 +MINIMUM_ROUTE_UPDATE_INTERVAL = timedelta(minutes=15) +MAX_ROUTE_NAME_LENGTH = 80 +MAX_ROUTE_LABEL_LENGTH = 100 + +logger = logging.getLogger(__name__) + + +class CommonRouteChanges(BaseModel): + """Fields that can be changed on a saved common route.""" + + model_config = ConfigDict(extra="forbid") + + new_name: str | None = Field( + default=None, + description="New user-visible route name, or null to keep it unchanged.", + ) + origin: str | None = Field( + default=None, + description="New origin address or place_id value, or null if unchanged.", + ) + destination: str | None = Field( + default=None, + description="New destination address or place_id value, or null if unchanged.", + ) + origin_label: str | None = Field( + default=None, + description="New user-authored origin label, or null if unchanged.", + ) + destination_label: str | None = Field( + default=None, + description="New user-authored destination label, or null if unchanged.", + ) + travel_mode: str | None = Field( + default=None, + description="New travel mode, or null if unchanged.", + ) + avoid_tolls: bool | None = Field( + default=None, + description="New toll preference, or null if unchanged.", + ) + avoid_highways: bool | None = Field( + default=None, + description="New highway preference, or null if unchanged.", + ) + avoid_ferries: bool | None = Field( + default=None, + description="New ferry preference, or null if unchanged.", + ) + + +def _configured_limit(name: str, default: int) -> int: + raw_value = os.environ.get(name, "").strip() + if not raw_value: + return default + try: + value = int(raw_value) + except ValueError: + return default + return value if value > 0 else default + + +def _owner_from_context( + tool_context: ToolContext, +) -> tuple[str | None, dict[str, Any] | None]: + user_id = getattr(tool_context, "user_id", None) or tool_context.state.get( + "user_id" + ) + telegram_chat_id = tool_context.state.get("telegram_chat_id") + telegram_chat_type = tool_context.state.get("telegram_chat_type") + if telegram_chat_id and telegram_chat_type != "private": + return None, { + "status": "error", + "error_code": "unsupported_context", + "message": ( + "Saved routes are available only in a private Telegram chat " + "because group chats share one conversation identity." + ), + } + if not user_id: + return None, { + "status": "error", + "error_code": "user_not_identified", + "message": "Cannot access saved routes without a user identity.", + } + return str(user_id), None + + +def _validated_text(value: str, label: str, maximum: int) -> str: + normalized = " ".join(value.split()) + if not normalized: + raise ValueError(f"{label} cannot be empty.") + if len(normalized) > maximum: + raise ValueError(f"{label} is too long (max {maximum} characters).") + return normalized + + +def _explicit_place_id(value: str) -> str | None: + prefix, separator, place_id = value.strip().partition(":") + if prefix.casefold() == "place_id" and separator and place_id.strip(): + return place_id.strip() + return None + + +def _resolved_place_id( + value: str, + result: dict[str, Any], + endpoint: str, +) -> str | None: + explicit = _explicit_place_id(value) + if explicit: + return explicit + resolved = result.get("resolved_waypoints", {}).get(endpoint) + if not isinstance(resolved, dict) or resolved.get("partial_match") is True: + return None + place_id = resolved.get("place_id") + return place_id if isinstance(place_id, str) and place_id else None + + +def _route_settings(route: SavedRoute) -> dict[str, Any]: + return { + "travel_mode": route.travel_mode, + "avoid_tolls": route.avoid_tolls, + "avoid_highways": route.avoid_highways, + "avoid_ferries": route.avoid_ferries, + } + + +def _route_listing(route: SavedRoute) -> dict[str, Any]: + return { + "name": route.name, + "origin_label": route.origin_label, + "destination_label": route.destination_label, + **_route_settings(route), + } + + +def _route_summary(route: SavedRoute, result: dict[str, Any]) -> str: + primary = result["routes"][0] + delay = primary.get("traffic_delay_minutes") + delay_text = ( + f", including about {delay:g} minutes of traffic delay" + if isinstance(delay, int | float) + else "" + ) + return ( + f"{route.name}: {primary['duration_minutes']:g} minutes for " + f"{primary['distance_kilometers']:g} km from {route.origin_label} to " + f"{route.destination_label}{delay_text}. " + f"{GOOGLE_MAPS_ATTRIBUTION} · updated {result['computed_at']}." + ) + + +async def _fresh_saved_route_estimate(route: SavedRoute) -> dict[str, Any]: + api_key = os.environ.get("GOOGLE_MAPS_ROUTES_API_KEY", "").strip() + if not api_key: + return { + "status": "error", + "error_code": "not_configured", + "error": "GOOGLE_MAPS_ROUTES_API_KEY is not configured.", + } + return await _estimate_route( + api_key=api_key, + origin=f"place_id:{route.origin_place_id}", + destination=f"place_id:{route.destination_place_id}", + travel_mode=route.travel_mode, + departure_time="now", + traffic_model="BEST_GUESS" if route.travel_mode == "DRIVE" else "NONE", + avoid_tolls=route.avoid_tolls, + avoid_highways=route.avoid_highways, + avoid_ferries=route.avoid_ferries, + include_alternatives=False, + ) + + +async def save_common_route( + route_name: str, + origin: str, + destination: str, + origin_label: str, + destination_label: str, + travel_mode: str, + avoid_tolls: bool, + avoid_highways: bool, + avoid_ferries: bool, + tool_context: ToolContext, +) -> dict[str, Any]: + """Save a reusable route after resolving both endpoints to Google place IDs. + + Use only when the user explicitly asks to save a route. Labels must be the + user's own non-sensitive descriptions, such as Home and Office. Raw + addresses and traffic results are never persisted. + """ + owner_id, error = _owner_from_context(tool_context) + if error: + return error + try: + name = _validated_text(route_name, "Route name", MAX_ROUTE_NAME_LENGTH) + start_label = _validated_text( + origin_label, "Origin label", MAX_ROUTE_LABEL_LENGTH + ) + end_label = _validated_text( + destination_label, "Destination label", MAX_ROUTE_LABEL_LENGTH + ) + api_key = os.environ.get("GOOGLE_MAPS_ROUTES_API_KEY", "").strip() + if not api_key: + return { + "status": "error", + "error_code": "not_configured", + "message": "Google Maps Routes is not configured.", + } + estimate = await _estimate_route( + api_key=api_key, + origin=origin, + destination=destination, + travel_mode=travel_mode, + departure_time="now", + traffic_model=( + "BEST_GUESS" + if _normalize_travel_mode(travel_mode) == "DRIVE" + else "NONE" + ), + avoid_tolls=avoid_tolls, + avoid_highways=avoid_highways, + avoid_ferries=avoid_ferries, + include_alternatives=False, + ) + if estimate["status"] != "success": + return { + "status": "error", + "error_code": estimate.get("error_code", "route_unavailable"), + "message": "The route could not be verified, so it was not saved.", + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + origin_place_id = _resolved_place_id(origin, estimate, "origin") + destination_place_id = _resolved_place_id(destination, estimate, "destination") + if not origin_place_id or not destination_place_id: + return { + "status": "error", + "error_code": "ambiguous_location", + "message": ( + "One or both locations were ambiguous. Provide a more precise " + "address or a Google place ID." + ), + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + timestamp = utc_iso_seconds(now_utc()) + saved = await get_saved_route_storage().create_route( + SavedRoute( + user_id=owner_id or "", + name=name, + normalized_name=normalize_route_name(name), + origin_place_id=origin_place_id, + destination_place_id=destination_place_id, + origin_label=start_label, + destination_label=end_label, + travel_mode=estimate["travel_mode"], + avoid_tolls=avoid_tolls, + avoid_highways=avoid_highways, + avoid_ferries=avoid_ferries, + created_at=timestamp, + updated_at=timestamp, + ), + _configured_limit( + "GOOGLE_MAPS_SAVED_ROUTE_LIMIT", DEFAULT_SAVED_ROUTE_LIMIT + ), + ) + return { + "status": "success", + "route": _route_listing(saved), + "message": f"Saved common route '{saved.name}'.", + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + except (DuplicateRouteNameError, SavedRouteLimitError, ValueError) as exc: + return { + "status": "error", + "error_code": "invalid_saved_route", + "message": str(exc), + } + + +async def list_common_routes(tool_context: ToolContext) -> dict[str, Any]: + """List the current user's saved routes without returning place IDs.""" + owner_id, error = _owner_from_context(tool_context) + if error: + return error + routes = await get_saved_route_storage().list_routes(owner_id or "") + return { + "status": "success", + "routes": [_route_listing(route) for route in routes], + "count": len(routes), + } + + +async def check_common_route( + route_reference: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Get a fresh Google Maps estimate for a saved route name or ``id:N``.""" + owner_id, error = _owner_from_context(tool_context) + if error: + return error + route = await get_saved_route_storage().get_route(owner_id or "", route_reference) + if route is None: + return { + "status": "error", + "error_code": "route_not_found", + "message": "That saved route was not found.", + } + estimate = await _fresh_saved_route_estimate(route) + if estimate["status"] != "success": + return { + "status": "error", + "error_code": estimate.get("error_code", "route_unavailable"), + "message": "A fresh route estimate is unavailable right now.", + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + return { + "status": "success", + "route": _route_listing(route), + "estimate": { + key: value + for key, value in estimate.items() + if key not in {"origin", "destination", "resolved_waypoints"} + }, + "summary": _route_summary(route, estimate), + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + + +async def update_common_route( + route_reference: str, + changes: CommonRouteChanges, + tool_context: ToolContext, +) -> dict[str, Any]: + """Update a saved route only when the user explicitly requests the change.""" + owner_id, error = _owner_from_context(tool_context) + if error: + return error + storage = get_saved_route_storage() + route = await storage.get_route(owner_id or "", route_reference) + if route is None or route.id is None: + return { + "status": "error", + "error_code": "route_not_found", + "message": "That saved route was not found.", + } + try: + specified_changes = changes.model_dump(exclude_none=True) + if not specified_changes: + raise ValueError("Provide at least one saved-route field to update.") + name = ( + _validated_text(changes.new_name, "Route name", MAX_ROUTE_NAME_LENGTH) + if changes.new_name is not None + else route.name + ) + start_label = ( + _validated_text( + changes.origin_label, "Origin label", MAX_ROUTE_LABEL_LENGTH + ) + if changes.origin_label is not None + else route.origin_label + ) + end_label = ( + _validated_text( + changes.destination_label, + "Destination label", + MAX_ROUTE_LABEL_LENGTH, + ) + if changes.destination_label is not None + else route.destination_label + ) + origin_place_id = route.origin_place_id + destination_place_id = route.destination_place_id + normalized_mode = route.travel_mode + avoid_tolls = ( + changes.avoid_tolls + if changes.avoid_tolls is not None + else route.avoid_tolls + ) + avoid_highways = ( + changes.avoid_highways + if changes.avoid_highways is not None + else route.avoid_highways + ) + avoid_ferries = ( + changes.avoid_ferries + if changes.avoid_ferries is not None + else route.avoid_ferries + ) + route_fields_changed = bool( + { + "origin", + "destination", + "travel_mode", + "avoid_tolls", + "avoid_highways", + "avoid_ferries", + } + & specified_changes.keys() + ) + if route_fields_changed: + origin = changes.origin or f"place_id:{route.origin_place_id}" + destination = ( + changes.destination or f"place_id:{route.destination_place_id}" + ) + normalized_mode = _normalize_travel_mode( + changes.travel_mode or route.travel_mode + ) + api_key = os.environ.get("GOOGLE_MAPS_ROUTES_API_KEY", "").strip() + if not api_key: + return { + "status": "error", + "error_code": "not_configured", + "message": "Google Maps Routes is not configured.", + } + estimate = await _estimate_route( + api_key=api_key, + origin=origin, + destination=destination, + travel_mode=normalized_mode, + departure_time="now", + traffic_model=("BEST_GUESS" if normalized_mode == "DRIVE" else "NONE"), + avoid_tolls=avoid_tolls, + avoid_highways=avoid_highways, + avoid_ferries=avoid_ferries, + include_alternatives=False, + ) + if estimate["status"] != "success": + return { + "status": "error", + "error_code": estimate.get("error_code", "route_unavailable"), + "message": ( + "The proposed route could not be verified; no changes saved." + ), + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + resolved_origin_place_id = _resolved_place_id(origin, estimate, "origin") + resolved_destination_place_id = _resolved_place_id( + destination, estimate, "destination" + ) + if not resolved_origin_place_id or not resolved_destination_place_id: + return { + "status": "error", + "error_code": "ambiguous_location", + "message": ( + "One or both locations were ambiguous. No changes were saved." + ), + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + origin_place_id = resolved_origin_place_id + destination_place_id = resolved_destination_place_id + updated = await storage.update_route( + owner_id or "", + route.id, + { + "name": name, + "normalized_name": normalize_route_name(name), + "origin_place_id": origin_place_id, + "destination_place_id": destination_place_id, + "origin_label": start_label, + "destination_label": end_label, + "travel_mode": normalized_mode, + "avoid_tolls": int(avoid_tolls), + "avoid_highways": int(avoid_highways), + "avoid_ferries": int(avoid_ferries), + "updated_at": utc_iso_seconds(now_utc()), + }, + ) + if updated is None: + return { + "status": "error", + "error_code": "route_not_found", + "message": "That saved route was not found.", + } + return { + "status": "success", + "route": _route_listing(updated), + "message": f"Updated common route '{updated.name}'.", + "attribution": GOOGLE_MAPS_ATTRIBUTION, + } + except (DuplicateRouteNameError, ValueError) as exc: + return { + "status": "error", + "error_code": "invalid_saved_route", + "message": str(exc), + } + + +async def delete_common_route( + route_reference: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Delete a saved route and its active traffic-update reminders.""" + owner_id, error = _owner_from_context(tool_context) + if error: + return error + storage = get_saved_route_storage() + route = await storage.get_route(owner_id or "", route_reference) + if route is None or route.id is None: + return { + "status": "error", + "error_code": "route_not_found", + "message": "That saved route was not found.", + } + scheduler = get_scheduler() + reminders = await scheduler.get_user_reminders(owner_id or "") + cancelled = 0 + for reminder in reminders: + event = parse_route_update_event(reminder.message) + if ( + event + and event.route_id == route.id + and reminder.id is not None + and await scheduler.delete_reminder(reminder.id, owner_id or "") + ): + cancelled += 1 + deleted = await storage.delete_route(owner_id or "", route.id) + if not deleted: + return { + "status": "error", + "error_code": "route_not_found", + "message": "That saved route was not found.", + } + return { + "status": "success", + "message": f"Deleted common route '{route.name}'.", + "cancelled_updates": cancelled, + } + + +async def schedule_common_route_update( + route_reference: str, + recurrence: str, + tool_context: ToolContext, +) -> dict[str, Any]: + """Schedule recurring fresh traffic checks for a saved route. + + ``recurrence`` must be a five-field cron expression in the application + timezone. Checks must be at least 15 minutes apart. + """ + owner_id, error = _owner_from_context(tool_context) + if error: + return error + route = await get_saved_route_storage().get_route(owner_id or "", route_reference) + if route is None or route.id is None: + return { + "status": "error", + "error_code": "route_not_found", + "message": "That saved route was not found.", + } + try: + schedule = _build_reminder_schedule( + reminder_datetime=None, + recurrence=recurrence, + ) + next_after = get_next_trigger_time( + schedule["recurrence_rule"], + schedule["timezone_name"], + reference_time=schedule["trigger_time"] + timedelta(seconds=1), + ) + except ValueError as exc: + return { + "status": "error", + "error_code": "invalid_schedule", + "message": str(exc), + } + if next_after - schedule["trigger_time"] < MINIMUM_ROUTE_UPDATE_INTERVAL: + return { + "status": "error", + "error_code": "schedule_too_frequent", + "message": "Route updates must be scheduled at least 15 minutes apart.", + } + scheduler = get_scheduler() + reminders = await scheduler.get_user_reminders(owner_id or "") + route_events = [ + (reminder, event) + for reminder in reminders + if (event := parse_route_update_event(reminder.message)) is not None + ] + if any( + event.route_id == route.id + and reminder.recurrence_rule == schedule["recurrence_rule"] + for reminder, event in route_events + ): + return { + "status": "error", + "error_code": "duplicate_schedule", + "message": "That traffic-update schedule already exists.", + } + limit = _configured_limit( + "GOOGLE_MAPS_ROUTE_UPDATE_LIMIT", DEFAULT_ROUTE_UPDATE_LIMIT + ) + if len(route_events) >= limit: + return { + "status": "error", + "error_code": "schedule_limit_reached", + "message": f"You can have at most {limit} active route updates.", + } + try: + reminder_id = await scheduler.schedule_reminder( + user_id=owner_id or "", + message=encode_route_update_event(route.id), + trigger_time=schedule["trigger_time"], + recurrence_rule=schedule["recurrence_rule"], + recurrence_text=schedule["recurrence_text"], + timezone_name=schedule["timezone_name"], + ) + except Exception: + logger.exception("Failed to create a scheduled route update") + return { + "status": "error", + "error_code": "schedule_failed", + "message": ( + "The route is still saved, but its traffic update could not be " + "scheduled. Please try again." + ), + } + return { + "status": "success", + "reminder_id": reminder_id, + "route_name": route.name, + "recurrence": schedule["recurrence_text"], + "next_update": utc_iso_seconds(schedule["trigger_time"]), + "message": f"Scheduled traffic updates for '{route.name}'.", + } diff --git a/src/blacki/routes/scheduling.py b/src/blacki/routes/scheduling.py new file mode 100644 index 0000000..6db0db8 --- /dev/null +++ b/src/blacki/routes/scheduling.py @@ -0,0 +1,52 @@ +"""Versioned reminder envelopes for scheduled saved-route checks.""" + +from __future__ import annotations + +import json +from typing import Literal + +from pydantic import BaseModel, ConfigDict, PositiveInt, ValidationError + +ROUTE_UPDATE_EVENT_KIND: Literal["blacki.route_traffic_update"] = ( + "blacki.route_traffic_update" +) + + +class ScheduledRouteUpdate(BaseModel): + """A minimal persisted event that contains no address or traffic result.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["blacki.route_traffic_update"] = ROUTE_UPDATE_EVENT_KIND + version: Literal[1] = 1 + route_id: PositiveInt + + +def encode_route_update_event(route_id: int) -> str: + """Serialize a stable scheduled-route event.""" + return ScheduledRouteUpdate(route_id=route_id).model_dump_json() + + +def parse_route_update_event(value: str) -> ScheduledRouteUpdate | None: + """Parse a route event, returning ``None`` for normal reminder messages.""" + try: + decoded = json.loads(value) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(decoded, dict) or decoded.get("kind") != ROUTE_UPDATE_EVENT_KIND: + return None + try: + return ScheduledRouteUpdate.model_validate(decoded) + except ValidationError: + return None + + +def build_scheduled_route_prompt(event: ScheduledRouteUpdate) -> str: + """Build a controlled agent instruction from a validated event.""" + return ( + "[Scheduled Route Update]\n" + f'Call check_common_route with route_reference "id:{event.route_id}". ' + "If it succeeds, send the returned summary verbatim. If the route no " + "longer exists or the lookup fails, explain that briefly without " + "guessing traffic, distance, or travel time." + ) diff --git a/src/blacki/routes/storage.py b/src/blacki/routes/storage.py new file mode 100644 index 0000000..6c5750c --- /dev/null +++ b/src/blacki/routes/storage.py @@ -0,0 +1,252 @@ +"""User-scoped persistence for common Google Maps routes.""" + +from __future__ import annotations + +import sqlite3 +import unicodedata +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from blacki.storage.base import SqlStorage + +if TYPE_CHECKING: + import asyncio + + import aiosqlite + +SAVED_ROUTE_UPDATE_COLUMNS = frozenset( + { + "name", + "normalized_name", + "origin_place_id", + "destination_place_id", + "origin_label", + "destination_label", + "travel_mode", + "avoid_tolls", + "avoid_highways", + "avoid_ferries", + "updated_at", + } +) + + +class DuplicateRouteNameError(ValueError): + """A user already has a route with the normalized name.""" + + +class SavedRouteLimitError(ValueError): + """A user has reached the configured saved-route limit.""" + + +class SavedRoute(BaseModel): + """One reusable route containing place IDs rather than raw addresses.""" + + id: int | None = None + user_id: str + name: str + normalized_name: str + origin_place_id: str + destination_place_id: str + origin_label: str + destination_label: str + travel_mode: str + avoid_tolls: bool + avoid_highways: bool + avoid_ferries: bool + created_at: str + updated_at: str + + +def normalize_route_name(value: str) -> str: + """Normalize a user-visible route name for owner-scoped uniqueness.""" + return " ".join(unicodedata.normalize("NFKC", value).split()).casefold() + + +class SqliteSavedRouteStorage(SqlStorage): + """SQLite storage for user-owned common routes.""" + + def __init__(self, conn: aiosqlite.Connection, lock: asyncio.Lock) -> None: + super().__init__(conn, lock) + + async def _create_tables(self) -> None: + await self._conn.execute(""" + CREATE TABLE IF NOT EXISTS saved_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + origin_place_id TEXT NOT NULL, + destination_place_id TEXT NOT NULL, + origin_label TEXT NOT NULL, + destination_label TEXT NOT NULL, + travel_mode TEXT NOT NULL, + avoid_tolls INTEGER NOT NULL DEFAULT 0, + avoid_highways INTEGER NOT NULL DEFAULT 0, + avoid_ferries INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (user_id, normalized_name) + ) + """) + await self._conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_saved_routes_user + ON saved_routes (user_id, normalized_name) + """) + + async def create_route(self, route: SavedRoute, limit: int) -> SavedRoute: + """Atomically enforce the per-user limit and insert a route.""" + async with self._lock: + try: + await self._conn.execute("BEGIN IMMEDIATE") + count = await self._count_for_user(route.user_id) + if count >= limit: + raise SavedRouteLimitError( + f"You can save at most {limit} common routes." + ) + route_id = await self._execute( + """ + INSERT INTO saved_routes ( + user_id, name, normalized_name, origin_place_id, + destination_place_id, origin_label, destination_label, + travel_mode, avoid_tolls, avoid_highways, avoid_ferries, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + route.user_id, + route.name, + route.normalized_name, + route.origin_place_id, + route.destination_place_id, + route.origin_label, + route.destination_label, + route.travel_mode, + int(route.avoid_tolls), + int(route.avoid_highways), + int(route.avoid_ferries), + route.created_at, + route.updated_at, + ), + use_lock=False, + ) + await self._conn.commit() + except sqlite3.IntegrityError as exc: + raise DuplicateRouteNameError( + f"A common route named '{route.name}' already exists." + ) from exc + finally: + if self._conn.in_transaction: + await self._conn.rollback() + return route.model_copy(update={"id": int(route_id)}) + + async def list_routes(self, user_id: str) -> list[SavedRoute]: + """List a user's routes without exposing another owner.""" + rows = await self._fetch_all( + """ + SELECT * FROM saved_routes + WHERE user_id = ? + ORDER BY normalized_name ASC + """, + (user_id,), + ) + return [self._row_to_route(row) for row in rows] + + async def get_route(self, user_id: str, reference: str) -> SavedRoute | None: + """Resolve an owner-qualified route by ``id:N`` or normalized name.""" + normalized_reference = reference.strip() + if normalized_reference.casefold().startswith("id:"): + route_id_text = normalized_reference.partition(":")[2].strip() + if route_id_text.isdecimal(): + row = await self._fetch_one( + "SELECT * FROM saved_routes WHERE user_id = ? AND id = ?", + (user_id, int(route_id_text)), + ) + return self._row_to_route(row) if row else None + row = await self._fetch_one( + """ + SELECT * FROM saved_routes + WHERE user_id = ? AND normalized_name = ? + """, + (user_id, normalize_route_name(normalized_reference)), + ) + return self._row_to_route(row) if row else None + + async def update_route( + self, + user_id: str, + route_id: int, + values: dict[str, Any], + ) -> SavedRoute | None: + """Update only an owner-qualified route.""" + if not values: + return await self.get_route(user_id, f"id:{route_id}") + if not values.keys() <= SAVED_ROUTE_UPDATE_COLUMNS: + raise ValueError("Unsupported saved-route update field.") + assignments = ", ".join(f"{column} = ?" for column in values) + params = (*values.values(), user_id, route_id) + async with self._lock: + try: + cursor = await self._conn.execute( + f""" + UPDATE saved_routes SET {assignments} + WHERE user_id = ? AND id = ? + """, # noqa: S608 - columns are validated against a fixed allowlist + params, + ) + except sqlite3.IntegrityError as exc: + raise DuplicateRouteNameError( + "A common route with that name already exists." + ) from exc + if cursor.rowcount == 0: + return None + return await self.get_route(user_id, f"id:{route_id}") + + async def delete_route(self, user_id: str, route_id: int) -> bool: + """Delete only an owner-qualified route.""" + async with self._lock: + cursor = await self._conn.execute( + "DELETE FROM saved_routes WHERE user_id = ? AND id = ?", + (user_id, route_id), + ) + return cursor.rowcount > 0 + + async def _count_for_user(self, user_id: str) -> int: + cursor = await self._conn.execute( + "SELECT COUNT(*) FROM saved_routes WHERE user_id = ?", + (user_id,), + ) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + @staticmethod + def _row_to_route(row: dict[str, Any]) -> SavedRoute: + return SavedRoute( + id=int(row["id"]), + user_id=row["user_id"], + name=row["name"], + normalized_name=row["normalized_name"], + origin_place_id=row["origin_place_id"], + destination_place_id=row["destination_place_id"], + origin_label=row["origin_label"], + destination_label=row["destination_label"], + travel_mode=row["travel_mode"], + avoid_tolls=bool(row["avoid_tolls"]), + avoid_highways=bool(row["avoid_highways"]), + avoid_ferries=bool(row["avoid_ferries"]), + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +def get_saved_route_storage() -> SqliteSavedRouteStorage: + """Return initialized saved-route storage from the app container.""" + from blacki.container import get_container + + storage = get_container().saved_route_storage + if not storage.is_initialized: + raise RuntimeError( + "Saved route storage not initialized. Call storage.initialize() first." + ) + return storage diff --git a/src/blacki/telegram/bot.py b/src/blacki/telegram/bot.py index f444cf6..9949f6e 100644 --- a/src/blacki/telegram/bot.py +++ b/src/blacki/telegram/bot.py @@ -18,6 +18,7 @@ from .types import ( BotCommand, CallbackQuery, + ChatType, InlineKeyboardButton, InlineKeyboardMarkup, Message, @@ -261,6 +262,7 @@ async def _handle_update(self, update: Update) -> None: chat_id=chat_id, message_thread_id=message_thread_id, user_message=user_message, + chat_type=message.chat.type, ) async def _route_non_text_message(self, message: Message) -> None: @@ -293,6 +295,7 @@ async def _route_non_text_message(self, message: Message) -> None: file_id=file_id, file_name=file_name, caption=message.caption, + chat_type=message.chat.type, ) async def _handle_command(self, message: Message, command: str) -> None: @@ -481,6 +484,7 @@ async def _handle_file_upload( file_id: str, file_name: str, caption: str | None, + chat_type: ChatType = ChatType.PRIVATE, ) -> None: """Handle incoming file uploads, save to sandbox, and message agent.""" from blacki.sandbox.manager import get_sandbox_manager @@ -493,6 +497,7 @@ async def _handle_file_upload( chat_id=str(chat_id), message_thread_id=message_thread_id, conversation_key=session_identity.conversation_key, + chat_type=chat_type, ) manager = get_sandbox_manager() @@ -575,6 +580,7 @@ async def _handle_message( chat_id: int, message_thread_id: int | None, user_message: str, + chat_type: ChatType = ChatType.PRIVATE, ) -> None: """Handle a regular text message with typing + final response.""" session_identity = self._build_session_identity( @@ -595,6 +601,7 @@ async def _handle_message( chat_id=str(chat_id), message_thread_id=message_thread_id, conversation_key=session_identity.conversation_key, + chat_type=chat_type, ) final_response = await self.runtime.run_user_turn( locator=SessionLocator( @@ -642,6 +649,12 @@ async def handle_scheduled_reminder(self, reminder: Reminder) -> None: logger.info("Handling scheduled reminder %s for chat %s", reminder.id, chat_id) try: + from blacki.routes.scheduling import ( + build_scheduled_route_prompt, + parse_route_update_event, + ) + + route_event = parse_route_update_event(reminder.message) await self.api.send_chat_action( chat_id=chat_id, action="typing", @@ -652,13 +665,19 @@ async def handle_scheduled_reminder(self, reminder: Reminder) -> None: chat_id=chat_id_str, message_thread_id=message_thread_id, conversation_key=session_identity.conversation_key, + chat_type=(ChatType.PRIVATE if chat_id > 0 else ChatType.SUPERGROUP), + ) + message_text = ( + build_scheduled_route_prompt(route_event) + if route_event + else f"[Scheduled Event] {reminder.message}" ) final_response = await self.runtime.run_user_turn( locator=SessionLocator( user_id=session_identity.user_id, session_id_prefix=session_identity.session_id_prefix, ), - message_text=f"[Scheduled Event] {reminder.message}", + message_text=message_text, state=state, ) await self._send_final_response( @@ -672,7 +691,15 @@ async def handle_scheduled_reminder(self, reminder: Reminder) -> None: reminder.id, chat_id, ) - text = format_for_telegram(f"⏰ *Reminder*\n\n{reminder.message}") + from blacki.routes.scheduling import parse_route_update_event + + route_event = parse_route_update_event(reminder.message) + fallback_message = ( + "⏰ I couldn't refresh your scheduled route right now." + if route_event + else f"⏰ *Reminder*\n\n{reminder.message}" + ) + text = format_for_telegram(fallback_message) await self.api.send_message( chat_id=chat_id, text=text, @@ -757,12 +784,14 @@ def _build_session_state( chat_id: str, message_thread_id: int | None, conversation_key: str, + chat_type: ChatType = ChatType.PRIVATE, ) -> dict[str, str]: """Build explicit session state for ADK callbacks and observability.""" session_state: dict[str, str] = { "user_id": f"telegram-{conversation_key}", "telegram_chat_id": chat_id, "telegram_conversation_key": conversation_key, + "telegram_chat_type": chat_type.value, } if message_thread_id is not None: session_state["telegram_thread_id"] = str(message_thread_id) diff --git a/src/blacki/utils/privacy.py b/src/blacki/utils/privacy.py index e9ef8dc..799cc88 100644 --- a/src/blacki/utils/privacy.py +++ b/src/blacki/utils/privacy.py @@ -9,6 +9,12 @@ { "get_route_estimate", "compare_route_scenarios", + "save_common_route", + "list_common_routes", + "check_common_route", + "update_common_route", + "delete_common_route", + "schedule_common_route_update", } ) REDACTED_ROUTE_DETAILS = "" @@ -33,6 +39,8 @@ def redact_route_tool_payload( "error_code", "scenario_count", "successful_scenarios", + "count", + "cancelled_updates", "attribution", ): if key in payload: diff --git a/tests/eval/blacki_eval/agent.py b/tests/eval/blacki_eval/agent.py index 7b3b2f9..59b1da9 100644 --- a/tests/eval/blacki_eval/agent.py +++ b/tests/eval/blacki_eval/agent.py @@ -38,7 +38,11 @@ async def _route_eval_compute_routes( "duration": "1800s", "staticDuration": "1200s", } - ] + ], + "geocodingResults": { + "origin": {"placeId": "eval-origin-place"}, + "destination": {"placeId": "eval-destination-place"}, + }, } diff --git a/tests/eval/routes.evalset.json b/tests/eval/routes.evalset.json index 7da4e72..acc4880 100644 --- a/tests/eval/routes.evalset.json +++ b/tests/eval/routes.evalset.json @@ -89,6 +89,186 @@ "user_id": "routes-eval-user", "state": {} } + }, + { + "eval_id": "explicit_saved_route_mutations_use_dedicated_tools", + "conversation": [ + { + "invocation_id": "routes-save-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Save my commute now. Call save_common_route exactly once with route_name 'Commute', origin '1 Home Street, Pune', destination '2 Office Road, Pune', origin_label 'Home', destination_label 'Office', travel_mode DRIVE, and every avoid option false." + } + ] + }, + "intermediate_data": { + "tool_uses": [ + { + "name": "save_common_route", + "args": { + "route_name": "Commute", + "origin": "1 Home Street, Pune", + "destination": "2 Office Road, Pune", + "origin_label": "Home", + "destination_label": "Office", + "travel_mode": "DRIVE", + "avoid_tolls": false, + "avoid_highways": false, + "avoid_ferries": false + } + } + ] + } + }, + { + "invocation_id": "routes-update-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Rename that route. Call update_common_route exactly once with route_reference 'Commute' and changes containing only new_name 'Weekday Commute'." + } + ] + }, + "intermediate_data": { + "tool_uses": [ + { + "name": "update_common_route", + "args": { + "route_reference": "Commute", + "changes": { + "new_name": "Weekday Commute" + } + } + } + ] + } + }, + { + "invocation_id": "routes-delete-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Delete it now. Call delete_common_route exactly once with route_reference 'Weekday Commute'." + } + ] + }, + "intermediate_data": { + "tool_uses": [ + { + "name": "delete_common_route", + "args": { + "route_reference": "Weekday Commute" + } + } + ] + } + } + ], + "session_input": { + "app_name": "blacki", + "user_id": "saved-routes-mutation-eval-user", + "state": {} + } + }, + { + "eval_id": "saved_route_reads_and_schedule_use_dedicated_tools", + "conversation": [ + { + "invocation_id": "routes-list-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Call list_common_routes exactly once and do not call any other tool." + } + ] + }, + "intermediate_data": { + "tool_uses": [ + { + "name": "list_common_routes", + "args": {} + } + ] + } + }, + { + "invocation_id": "routes-check-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Call check_common_route exactly once with route_reference 'Commute'." + } + ] + }, + "intermediate_data": { + "tool_uses": [ + { + "name": "check_common_route", + "args": { + "route_reference": "Commute" + } + } + ] + } + }, + { + "invocation_id": "routes-schedule-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Schedule weekday updates. Call schedule_common_route_update exactly once with route_reference 'Commute' and recurrence '0 8 * * 1-5'." + } + ] + }, + "intermediate_data": { + "tool_uses": [ + { + "name": "schedule_common_route_update", + "args": { + "route_reference": "Commute", + "recurrence": "0 8 * * 1-5" + } + } + ] + } + } + ], + "session_input": { + "app_name": "blacki", + "user_id": "saved-routes-read-eval-user", + "state": {} + } + }, + { + "eval_id": "saved_route_suggestion_does_not_mutate", + "conversation": [ + { + "invocation_id": "routes-no-mutation-1", + "user_content": { + "role": "user", + "parts": [ + { + "text": "Suggest a short name for a route I might save later. Do not call any tool and do not save, update, delete, or schedule anything." + } + ] + }, + "intermediate_data": { + "tool_uses": [] + } + } + ], + "session_input": { + "app_name": "blacki", + "user_id": "saved-routes-readonly-eval-user", + "state": {} + } } ] } diff --git a/tests/eval/test_eval_agent.py b/tests/eval/test_eval_agent.py index 870e11d..76e25b7 100644 --- a/tests/eval/test_eval_agent.py +++ b/tests/eval/test_eval_agent.py @@ -69,7 +69,11 @@ async def test_route_eval_boundary_is_deterministic() -> None: "duration": "1800s", "staticDuration": "1200s", } - ] + ], + "geocodingResults": { + "origin": {"placeId": "eval-origin-place"}, + "destination": {"placeId": "eval-destination-place"}, + }, } diff --git a/tests/reminders/test_reminder_tools.py b/tests/reminders/test_reminder_tools.py index dc1e0e1..9f7611f 100644 --- a/tests/reminders/test_reminder_tools.py +++ b/tests/reminders/test_reminder_tools.py @@ -555,3 +555,21 @@ def test_format_recurring_reminder(self) -> None: assert result["is_recurring"] is True assert result["schedule_type"] == "recurring" assert result["recurrence"] == "every 15 minutes" + + def test_format_route_update_hides_internal_event(self) -> None: + """Should present a route update without exposing its stored route ID.""" + reminder = Reminder( + id=2, + user_id="user1", + message=( + '{"kind":"blacki.route_traffic_update","version":1,"route_id":17}' + ), + trigger_time="2026-04-18T12:00:00+00:00", + recurrence_rule="0 8 * * *", + created_at="2026-04-18T10:00:00+00:00", + ) + + result = _format_reminder(reminder) + + assert result["message"] == "Scheduled traffic update for a saved route" + assert "17" not in result["message"] diff --git a/tests/routes/test_common_tools.py b/tests/routes/test_common_tools.py new file mode 100644 index 0000000..53a76ba --- /dev/null +++ b/tests/routes/test_common_tools.py @@ -0,0 +1,610 @@ +# mypy: disable-error-code="no-untyped-def" +"""Integration tests for saved-route ADK tools.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +from typing import cast +from unittest.mock import AsyncMock, create_autospec, patch + +import aiosqlite +import pytest +from conftest import MockState, MockToolContext +from google.adk.tools import FunctionTool, ToolContext + +from blacki.container import ( + get_container, + reset_container_for_tests, + set_container_from_connection, +) +from blacki.reminders import scheduler as scheduler_module +from blacki.reminders.scheduler import ReminderScheduler +from blacki.reminders.storage import Reminder +from blacki.routes.client import RoutesAPIError +from blacki.routes.common_tools import ( + CommonRouteChanges, + _configured_limit, + check_common_route, + delete_common_route, + list_common_routes, + save_common_route, + schedule_common_route_update, + update_common_route, +) + + +def _context( + user_id: str | None = "user-1", + state: dict[str, str] | None = None, +) -> ToolContext: + return cast( + ToolContext, + MockToolContext(user_id=user_id, state=MockState(state or {})), + ) + + +def _routes_response( + *, + origin_place_id: str = "origin-place", + destination_place_id: str = "destination-place", + partial_origin: bool = False, + static_duration: str | None = "1500s", +) -> dict[str, object]: + route: dict[str, object] = { + "distanceMeters": 12000, + "duration": "1800s", + } + if static_duration is not None: + route["staticDuration"] = static_duration + return { + "routes": [route], + "geocodingResults": { + "origin": { + "placeId": origin_place_id, + "partialMatch": partial_origin, + }, + "destination": {"placeId": destination_place_id}, + }, + } + + +@pytest.fixture(autouse=True) +async def route_environment( + monkeypatch: pytest.MonkeyPatch, +) -> AsyncGenerator[None, None]: + conn = await aiosqlite.connect(":memory:", isolation_level=None) + conn.row_factory = aiosqlite.Row + container = set_container_from_connection(conn, asyncio.Lock()) + await container.saved_route_storage.initialize() + await container.reminder_storage.initialize() + scheduler_module._scheduler = None + monkeypatch.setenv("GOOGLE_MAPS_ROUTES_API_KEY", "test-routes-key") + yield + scheduler_module._scheduler = None + reset_container_for_tests() + await conn.close() + + +async def _save(route_name: str = "Commute", user_id: str = "user-1"): + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(return_value=_routes_response()), + ): + return await save_common_route( + route_name, + "1 Home Street, Pune", + "2 Office Road, Pune", + "Home", + "Office", + "DRIVE", + False, + False, + False, + _context(user_id), + ) + + +class TestSavedRouteAccess: + def test_adk_declarations_support_saved_route_schemas(self) -> None: + for tool in ( + save_common_route, + list_common_routes, + check_common_route, + update_common_route, + delete_common_route, + schedule_common_route_update, + ): + declaration = FunctionTool(tool)._get_declaration() + assert declaration is not None + if tool is list_common_routes: + assert declaration.parameters_json_schema is None + else: + assert declaration.parameters_json_schema is not None + + update_schema = FunctionTool(update_common_route)._get_declaration() + assert update_schema is not None + parameters = update_schema.parameters_json_schema + assert parameters is not None + assert "CommonRouteChanges" in parameters["$defs"] + + @pytest.mark.parametrize( + ("value", "expected"), + [("", 20), ("invalid", 20), ("0", 20), ("7", 7)], + ) + def test_configured_limit_uses_safe_positive_values( + self, + monkeypatch: pytest.MonkeyPatch, + value: str, + expected: int, + ) -> None: + monkeypatch.setenv("TEST_ROUTE_LIMIT", value) + assert _configured_limit("TEST_ROUTE_LIMIT", 20) == expected + + @pytest.mark.asyncio + async def test_save_persists_place_ids_but_not_addresses(self) -> None: + result = await _save() + + assert result["status"] == "success" + assert "place" not in str(result) + routes = await list_common_routes(_context()) + assert routes["routes"] == [ + { + "name": "Commute", + "origin_label": "Home", + "destination_label": "Office", + "travel_mode": "DRIVE", + "avoid_tolls": False, + "avoid_highways": False, + "avoid_ferries": False, + } + ] + rows = list( + await get_container().conn.execute_fetchall( + "SELECT origin_place_id, destination_place_id FROM saved_routes" + ) + ) + assert tuple(rows[0]) == ("origin-place", "destination-place") + + @pytest.mark.asyncio + async def test_owner_isolation_and_private_telegram_requirement(self) -> None: + await _save() + + assert (await list_common_routes(_context("other-user")))["routes"] == [] + group = _context( + "telegram-chat--99", + {"telegram_chat_id": "-99", "telegram_chat_type": "group"}, + ) + result = await list_common_routes(group) + assert result["error_code"] == "unsupported_context" + + missing = await list_common_routes(_context(None)) + assert missing["error_code"] == "user_not_identified" + + @pytest.mark.asyncio + async def test_mutating_and_check_tools_reject_group_context(self) -> None: + group = _context( + "telegram-chat--99", + {"telegram_chat_id": "-99", "telegram_chat_type": "supergroup"}, + ) + changes = CommonRouteChanges() + + results = [ + await save_common_route( + "Route", "A", "B", "A", "B", "DRIVE", False, False, False, group + ), + await check_common_route("Route", group), + await update_common_route("Route", changes, group), + await delete_common_route("Route", group), + await schedule_common_route_update("Route", "0 8 * * *", group), + ] + + assert {result["error_code"] for result in results} == {"unsupported_context"} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("kwargs", "error_code"), + [ + ({"route_name": " "}, "invalid_saved_route"), + ({"origin_label": "x" * 101}, "invalid_saved_route"), + ({"destination_label": ""}, "invalid_saved_route"), + ], + ) + async def test_save_validates_user_fields( + self, kwargs: dict[str, str], error_code: str + ) -> None: + request = { + "route_name": "Commute", + "origin": "Home, Pune", + "destination": "Office, Pune", + "origin_label": "Home", + "destination_label": "Office", + } + request.update(kwargs) + result = await save_common_route( + request["route_name"], + request["origin"], + request["destination"], + request["origin_label"], + request["destination_label"], + "DRIVE", + False, + False, + False, + _context(), + ) + assert result["error_code"] == error_code + + @pytest.mark.asyncio + async def test_save_rejects_missing_key_provider_error_and_ambiguity( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GOOGLE_MAPS_ROUTES_API_KEY") + missing = await save_common_route( + "Commute", + "A", + "B", + "Home", + "Office", + "DRIVE", + False, + False, + False, + _context(), + ) + assert missing["error_code"] == "not_configured" + + monkeypatch.setenv("GOOGLE_MAPS_ROUTES_API_KEY", "key") + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(side_effect=RoutesAPIError("quota_exceeded", "quota")), + ): + provider = await save_common_route( + "Commute", + "A", + "B", + "Home", + "Office", + "DRIVE", + False, + False, + False, + _context(), + ) + assert provider["error_code"] == "quota_exceeded" + + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(return_value=_routes_response(partial_origin=True)), + ): + ambiguous = await save_common_route( + "Commute", + "A", + "B", + "Home", + "Office", + "DRIVE", + False, + False, + False, + _context(), + ) + assert ambiguous["error_code"] == "ambiguous_location" + + @pytest.mark.asyncio + async def test_save_duplicate_and_configured_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + assert (await _save())["status"] == "success" + duplicate = await _save(" COMMUTE ") + assert duplicate["error_code"] == "invalid_saved_route" + + monkeypatch.setenv("GOOGLE_MAPS_SAVED_ROUTE_LIMIT", "1") + limited = await _save("Gym") + assert "at most 1" in limited["message"] + + +class TestCheckAndUpdate: + @pytest.mark.asyncio + async def test_check_returns_fresh_attributed_summary(self) -> None: + await _save() + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(return_value=_routes_response()), + ): + result = await check_common_route("commute", _context()) + + assert result["status"] == "success" + assert result["attribution"] == "Google Maps" + assert "30 minutes" in result["summary"] + assert "5 minutes of traffic delay" in result["summary"] + assert "place" not in str(result) + + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(return_value=_routes_response(static_duration=None)), + ): + no_delay = await check_common_route("id:1", _context()) + assert "traffic delay" not in no_delay["summary"] + + @pytest.mark.asyncio + async def test_check_handles_missing_key_failure_and_missing_route( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + assert (await check_common_route("missing", _context()))["error_code"] == ( + "route_not_found" + ) + await _save() + monkeypatch.delenv("GOOGLE_MAPS_ROUTES_API_KEY") + unavailable = await check_common_route("commute", _context()) + assert unavailable["error_code"] == "not_configured" + + @pytest.mark.asyncio + async def test_update_changes_route_and_resolves_new_endpoint(self) -> None: + await _save() + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock( + return_value=_routes_response(destination_place_id="new-destination") + ), + ): + result = await update_common_route( + "Commute", + CommonRouteChanges( + new_name="Weekday commute", + destination="3 New Office Road, Pune", + destination_label="New Office", + travel_mode="WALK", + avoid_tolls=False, + avoid_highways=False, + avoid_ferries=False, + ), + _context(), + ) + + assert result["status"] == "success" + assert result["route"]["name"] == "Weekday commute" + assert result["route"]["destination_label"] == "New Office" + assert result["route"]["travel_mode"] == "WALK" + + @pytest.mark.asyncio + async def test_update_name_and_label_does_not_call_provider(self) -> None: + await _save() + provider = AsyncMock(return_value=_routes_response()) + with patch("blacki.routes.tools.compute_routes", new=provider): + result = await update_common_route( + "Commute", + CommonRouteChanges( + new_name="Weekday commute", + origin_label="My home", + ), + _context(), + ) + + assert result["status"] == "success" + assert result["route"]["name"] == "Weekday commute" + assert result["route"]["origin_label"] == "My home" + provider.assert_not_awaited() + + @pytest.mark.asyncio + async def test_update_missing_duplicate_invalid_and_unverified( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + missing = await update_common_route("missing", CommonRouteChanges(), _context()) + assert missing["error_code"] == "route_not_found" + + await _save() + await _save("Gym") + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(return_value=_routes_response()), + ): + duplicate = await update_common_route( + "Gym", + CommonRouteChanges(new_name="COMMUTE"), + _context(), + ) + invalid = await update_common_route( + "Gym", + CommonRouteChanges(origin_label=""), + _context(), + ) + assert duplicate["error_code"] == "invalid_saved_route" + assert invalid["error_code"] == "invalid_saved_route" + + monkeypatch.delenv("GOOGLE_MAPS_ROUTES_API_KEY") + no_key = await update_common_route( + "Gym", CommonRouteChanges(travel_mode="WALK"), _context() + ) + assert no_key["error_code"] == "not_configured" + + monkeypatch.setenv("GOOGLE_MAPS_ROUTES_API_KEY", "key") + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(side_effect=RoutesAPIError("no_route", "none")), + ): + unavailable = await update_common_route( + "Gym", CommonRouteChanges(avoid_tolls=True), _context() + ) + assert unavailable["error_code"] == "no_route" + + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(return_value=_routes_response(partial_origin=True)), + ): + ambiguous = await update_common_route( + "Gym", + CommonRouteChanges(origin="Ambiguous origin"), + _context(), + ) + assert ambiguous["error_code"] == "ambiguous_location" + + @pytest.mark.asyncio + async def test_update_handles_route_deleted_during_provider_lookup(self) -> None: + await _save() + + async def delete_during_lookup(*_args, **_kwargs): + from blacki.routes.storage import get_saved_route_storage + + route = await get_saved_route_storage().get_route("user-1", "Commute") + assert route is not None and route.id is not None + await get_saved_route_storage().delete_route("user-1", route.id) + return _routes_response() + + with patch( + "blacki.routes.tools.compute_routes", + new=AsyncMock(side_effect=delete_during_lookup), + ): + result = await update_common_route( + "Commute", + CommonRouteChanges(destination="New office, Pune"), + _context(), + ) + assert result["error_code"] == "route_not_found" + + @pytest.mark.asyncio + async def test_update_rejects_empty_change_set_without_provider_call(self) -> None: + await _save() + provider = AsyncMock(return_value=_routes_response()) + with patch("blacki.routes.tools.compute_routes", new=provider): + result = await update_common_route( + "Commute", CommonRouteChanges(), _context() + ) + + assert result["error_code"] == "invalid_saved_route" + provider.assert_not_awaited() + + +class TestScheduling: + @pytest.mark.asyncio + async def test_schedule_and_delete_route_cancels_update(self) -> None: + await _save() + scheduled = await schedule_common_route_update( + "Commute", "0 8 * * 1-5", _context() + ) + assert scheduled["status"] == "success" + assert scheduled["route_name"] == "Commute" + + deleted = await delete_common_route("Commute", _context()) + assert deleted["status"] == "success" + assert deleted["cancelled_updates"] == 1 + assert (await list_common_routes(_context()))["count"] == 0 + + @pytest.mark.asyncio + async def test_schedule_validates_route_frequency_duplicate_and_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + assert (await schedule_common_route_update("missing", "0 8 * * *", _context()))[ + "error_code" + ] == "route_not_found" + await _save() + + invalid = await schedule_common_route_update("Commute", "not cron", _context()) + frequent = await schedule_common_route_update( + "Commute", "* * * * *", _context() + ) + assert invalid["error_code"] == "invalid_schedule" + assert frequent["error_code"] == "schedule_too_frequent" + + first = await schedule_common_route_update("Commute", "0 8 * * *", _context()) + duplicate = await schedule_common_route_update( + "Commute", "0 8 * * *", _context() + ) + assert first["status"] == "success" + assert duplicate["error_code"] == "duplicate_schedule" + + monkeypatch.setenv("GOOGLE_MAPS_ROUTE_UPDATE_LIMIT", "1") + limited = await schedule_common_route_update("Commute", "0 9 * * *", _context()) + assert limited["error_code"] == "schedule_limit_reached" + + @pytest.mark.asyncio + async def test_schedule_failure_is_safe_and_keeps_saved_route(self) -> None: + await _save() + scheduler = create_autospec(ReminderScheduler, instance=True, spec_set=True) + scheduler.get_user_reminders = AsyncMock(return_value=[]) + scheduler.schedule_reminder = AsyncMock(side_effect=RuntimeError("db failed")) + + with patch("blacki.routes.common_tools.get_scheduler", return_value=scheduler): + result = await schedule_common_route_update( + "Commute", "0 8 * * *", _context() + ) + + assert result["error_code"] == "schedule_failed" + assert (await list_common_routes(_context()))["count"] == 1 + + @pytest.mark.asyncio + async def test_delete_missing_route_is_safe(self) -> None: + result = await delete_common_route("missing", _context()) + assert result["error_code"] == "route_not_found" + + @pytest.mark.asyncio + async def test_delete_ignores_unrelated_or_uncancellable_reminders(self) -> None: + await _save() + scheduler = create_autospec(ReminderScheduler, instance=True, spec_set=True) + scheduler.get_user_reminders = AsyncMock( + return_value=[ + Reminder( + id=1, + user_id="user-1", + message="ordinary", + trigger_time="2026-08-01T00:00:00+00:00", + created_at="2026-07-24T00:00:00+00:00", + ), + Reminder( + id=2, + user_id="user-1", + message=( + '{"kind":"blacki.route_traffic_update",' + '"version":1,"route_id":999}' + ), + trigger_time="2026-08-01T00:00:00+00:00", + created_at="2026-07-24T00:00:00+00:00", + ), + Reminder( + user_id="user-1", + message=( + '{"kind":"blacki.route_traffic_update",' + '"version":1,"route_id":1}' + ), + trigger_time="2026-08-01T00:00:00+00:00", + created_at="2026-07-24T00:00:00+00:00", + ), + Reminder( + id=3, + user_id="user-1", + message=( + '{"kind":"blacki.route_traffic_update",' + '"version":1,"route_id":1}' + ), + trigger_time="2026-08-01T00:00:00+00:00", + created_at="2026-07-24T00:00:00+00:00", + ), + ] + ) + scheduler.delete_reminder = AsyncMock(return_value=False) + + with patch("blacki.routes.common_tools.get_scheduler", return_value=scheduler): + result = await delete_common_route("Commute", _context()) + + assert result["status"] == "success" + assert result["cancelled_updates"] == 0 + scheduler.delete_reminder.assert_awaited_once_with(3, "user-1") + + @pytest.mark.asyncio + async def test_delete_handles_route_removed_concurrently(self) -> None: + await _save() + scheduler = create_autospec(ReminderScheduler, instance=True, spec_set=True) + + async def remove_route(_user_id: str): + from blacki.routes.storage import get_saved_route_storage + + route = await get_saved_route_storage().get_route("user-1", "Commute") + assert route is not None and route.id is not None + await get_saved_route_storage().delete_route("user-1", route.id) + return [] + + scheduler.get_user_reminders = AsyncMock(side_effect=remove_route) + with patch("blacki.routes.common_tools.get_scheduler", return_value=scheduler): + result = await delete_common_route("Commute", _context()) + + assert result["error_code"] == "route_not_found" diff --git a/tests/routes/test_scheduling.py b/tests/routes/test_scheduling.py new file mode 100644 index 0000000..93a4ac5 --- /dev/null +++ b/tests/routes/test_scheduling.py @@ -0,0 +1,42 @@ +"""Tests for versioned route-update reminder envelopes.""" + +import pytest +from pydantic import ValidationError + +from blacki.routes.scheduling import ( + ScheduledRouteUpdate, + build_scheduled_route_prompt, + encode_route_update_event, + parse_route_update_event, +) + + +def test_event_round_trip_and_prompt() -> None: + encoded = encode_route_update_event(17) + event = parse_route_update_event(encoded) + + assert event == ScheduledRouteUpdate(route_id=17) + assert '"route_id":17' in encoded + prompt = build_scheduled_route_prompt(event) + assert 'route_reference "id:17"' in prompt + assert "summary verbatim" in prompt + + +@pytest.mark.parametrize( + "value", + [ + "ordinary reminder", + "[]", + '{"kind":"something_else","route_id":1}', + '{"kind":"blacki.route_traffic_update","version":2,"route_id":1}', + '{"kind":"blacki.route_traffic_update","version":1,"route_id":0}', + '{"kind":"blacki.route_traffic_update","version":1,"route_id":1,"x":2}', + ], +) +def test_non_events_and_invalid_events_are_rejected(value: str) -> None: + assert parse_route_update_event(value) is None + + +def test_event_requires_positive_route_id() -> None: + with pytest.raises(ValidationError): + encode_route_update_event(0) diff --git a/tests/routes/test_storage.py b/tests/routes/test_storage.py new file mode 100644 index 0000000..5940b81 --- /dev/null +++ b/tests/routes/test_storage.py @@ -0,0 +1,135 @@ +# mypy: disable-error-code="no-untyped-def" +"""Tests for owner-scoped saved-route persistence.""" + +import asyncio + +import aiosqlite +import pytest + +from blacki.container import reset_container_for_tests, set_container_from_connection +from blacki.routes.storage import ( + DuplicateRouteNameError, + SavedRoute, + SavedRouteLimitError, + SqliteSavedRouteStorage, + normalize_route_name, +) + + +@pytest.fixture +async def storage(): + conn = await aiosqlite.connect(":memory:", isolation_level=None) + conn.row_factory = aiosqlite.Row + route_storage = SqliteSavedRouteStorage(conn, asyncio.Lock()) + await route_storage.initialize() + yield route_storage + await route_storage.close() + await conn.close() + + +def _route(name: str = "Home Office", user_id: str = "user-1") -> SavedRoute: + return SavedRoute( + user_id=user_id, + name=name, + normalized_name=normalize_route_name(name), + origin_place_id="origin-place", + destination_place_id="destination-place", + origin_label="Home", + destination_label="Office", + travel_mode="DRIVE", + avoid_tolls=False, + avoid_highways=True, + avoid_ferries=False, + created_at="2026-07-24T00:00:00+00:00", + updated_at="2026-07-24T00:00:00+00:00", + ) + + +def test_normalize_route_name_handles_unicode_and_whitespace() -> None: + assert normalize_route_name(" Home OFFICE ") == "home office" + + +@pytest.mark.asyncio +async def test_create_list_and_resolve_by_name_or_id(storage) -> None: + saved = await storage.create_route(_route(), limit=2) + + assert saved.id == 1 + assert await storage.list_routes("other-user") == [] + assert (await storage.get_route("user-1", " HOME office ")).id == 1 + assert (await storage.get_route("user-1", "id:1")).name == "Home Office" + assert await storage.get_route("other-user", "id:1") is None + assert await storage.get_route("user-1", "id:not-a-number") is None + + +@pytest.mark.asyncio +async def test_create_rejects_duplicate_and_limit_atomically(storage) -> None: + await storage.create_route(_route(), limit=1) + + with pytest.raises(SavedRouteLimitError, match="at most 1"): + await storage.create_route(_route("Gym"), limit=1) + + with pytest.raises(DuplicateRouteNameError, match="already exists"): + await storage.create_route(_route(" HOME OFFICE "), limit=2) + + assert [route.name for route in await storage.list_routes("user-1")] == [ + "Home Office" + ] + + +@pytest.mark.asyncio +async def test_update_is_owner_scoped_and_enforces_unique_names(storage) -> None: + first = await storage.create_route(_route(), limit=3) + second = await storage.create_route(_route("Gym"), limit=3) + + unchanged = await storage.update_route("user-1", first.id, {}) + assert unchanged.name == "Home Office" + + updated = await storage.update_route( + "user-1", + first.id, + { + "name": "Commute", + "normalized_name": "commute", + "avoid_tolls": 1, + }, + ) + assert updated.name == "Commute" + assert updated.avoid_tolls is True + assert await storage.update_route("other-user", first.id, {"name": "Nope"}) is None + + with pytest.raises(DuplicateRouteNameError): + await storage.update_route( + "user-1", + second.id, + {"name": "COMMUTE", "normalized_name": "commute"}, + ) + + with pytest.raises(ValueError, match="Unsupported"): + await storage.update_route( + "user-1", + first.id, + {"name = 'unsafe'": "value"}, + ) + + +@pytest.mark.asyncio +async def test_delete_is_owner_scoped(storage) -> None: + saved = await storage.create_route(_route(), limit=2) + + assert await storage.delete_route("other-user", saved.id) is False + assert await storage.delete_route("user-1", saved.id) is True + assert await storage.get_route("user-1", "Home Office") is None + + +@pytest.mark.asyncio +async def test_global_storage_accessor_requires_initialization() -> None: + from blacki.routes.storage import get_saved_route_storage + + conn = await aiosqlite.connect(":memory:", isolation_level=None) + try: + set_container_from_connection(conn) + with pytest.raises(RuntimeError, match="not initialized"): + get_saved_route_storage() + finally: + reset_container_for_tests() + await conn.close() diff --git a/tests/test_container.py b/tests/test_container.py index c4a678c..416bbd1 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -204,6 +204,15 @@ async def test_declarative_db_storage_property(self, conn, lock) -> None: assert storage is not None assert container._declarative_db_storage is storage + @pytest.mark.asyncio + async def test_saved_route_storage_property(self, conn, lock) -> None: + """Should lazily instantiate saved-route storage.""" + container = AppContainer(conn=conn, _lock=lock) + + storage = container.saved_route_storage + assert storage is not None + assert container._saved_route_storage is storage + @pytest.mark.asyncio async def test_close_closes_connection_and_storages(self, conn, lock) -> None: """Should close connection and all storage instances.""" @@ -214,11 +223,14 @@ async def test_close_closes_connection_and_storages(self, conn, lock) -> None: declarative_db = container.declarative_db_storage declarative_db.close = AsyncMock() + saved_routes = container.saved_route_storage + saved_routes.close = AsyncMock() await container.close() reminder.close.assert_called_once() declarative_db.close.assert_called_once() + saved_routes.close.assert_called_once() @pytest.mark.asyncio async def test_close_storages_resets_references(self, conn, lock) -> None: @@ -230,6 +242,7 @@ async def test_close_storages_resets_references(self, conn, lock) -> None: _ = container.workout_storage _ = container.preferences_storage _ = container.declarative_db_storage + _ = container.saved_route_storage await container._close_storages() @@ -238,6 +251,7 @@ async def test_close_storages_resets_references(self, conn, lock) -> None: assert container._workout_storage is None assert container._preferences_storage is None assert container._declarative_db_storage is None + assert container._saved_route_storage is None @pytest.mark.asyncio async def test_close_storages_partial(self, conn, lock) -> None: @@ -248,6 +262,7 @@ async def test_close_storages_partial(self, conn, lock) -> None: _ = container.workout_storage _ = container.preferences_storage _ = container.declarative_db_storage + _ = container.saved_route_storage await container._close_storages() @@ -256,6 +271,7 @@ async def test_close_storages_partial(self, conn, lock) -> None: assert container._workout_storage is None assert container._preferences_storage is None assert container._declarative_db_storage is None + assert container._saved_route_storage is None assert container._workout_storage is None assert container._preferences_storage is None @@ -268,11 +284,13 @@ async def test_initialize_all_storages(self, conn, lock) -> None: calorie = container.calorie_storage workout = container.workout_storage preferences = container.preferences_storage + saved_routes = container.saved_route_storage reminder.initialize = AsyncMock() calorie.initialize = AsyncMock() workout.initialize = AsyncMock() preferences.initialize = AsyncMock() + saved_routes.initialize = AsyncMock() await container.initialize_all_storages() @@ -280,6 +298,7 @@ async def test_initialize_all_storages(self, conn, lock) -> None: calorie.initialize.assert_called_once() workout.initialize.assert_called_once() preferences.initialize.assert_called_once() + saved_routes.initialize.assert_called_once() @pytest.mark.asyncio async def test_create_creates_container_with_connection(self) -> None: diff --git a/tests/test_privacy.py b/tests/test_privacy.py index b068780..94d7ae2 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -54,3 +54,19 @@ def test_route_payload_preserves_only_safe_operational_metadata() -> None: "attribution": "Google Maps", } assert "canary" not in str(redacted) + + +def test_saved_route_payload_is_redacted() -> None: + payload = { + "status": "success", + "route": {"origin_label": "home-canary"}, + "count": 1, + "cancelled_updates": 2, + } + + assert redact_route_tool_payload("list_common_routes", payload) == { + "details": REDACTED_ROUTE_DETAILS, + "status": "success", + "count": 1, + "cancelled_updates": 2, + } diff --git a/tests/test_registry.py b/tests/test_registry.py index dadd00a..40e7c28 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -83,6 +83,24 @@ def test_google_routes_tools_added_when_key_provided(self) -> None: assert {"get_route_estimate", "compare_route_scenarios"} <= tool_names assert len(tools) == 10 + def test_saved_route_tools_require_maps_and_sqlite(self) -> None: + """Should add saved-route tools only when both dependencies exist.""" + config = ToolConfig( + google_maps_routes_api_key="routes-key", + sqlite_path="/tmp/blacki.db", + ) + + tool_names = {tool.__name__ for tool in build_tools(config)} + + assert { + "save_common_route", + "list_common_routes", + "check_common_route", + "update_common_route", + "delete_common_route", + "schedule_common_route_update", + } <= tool_names + def test_database_tools_added(self) -> None: """Should add database-backed tools when sqlite path provided.""" config = ToolConfig(sqlite_path="/tmp/blacki.db") diff --git a/tests/test_telegram_bot.py b/tests/test_telegram_bot.py index 173927b..a8e7a3b 100644 --- a/tests/test_telegram_bot.py +++ b/tests/test_telegram_bot.py @@ -12,6 +12,7 @@ from blacki.adk_runtime import AdkRuntime, SessionLocator, StreamChunk, TurnResponse from blacki.reminders.storage import Reminder +from blacki.routes.scheduling import encode_route_update_event from blacki.telegram import TelegramConfig from blacki.telegram.api import TelegramApiClient, TelegramApiError from blacki.telegram.bot import ( @@ -31,7 +32,7 @@ _merge_stream_text, split_long_message, ) -from blacki.telegram.types import BotCommand, Message, ParseMode, Update +from blacki.telegram.types import BotCommand, ChatType, Message, ParseMode, Update class RecordingRuntime: @@ -247,6 +248,7 @@ def test_build_session_state_includes_thread_when_present( assert session_state["user_id"] == "telegram-chat-123-thread-99" assert session_state["telegram_chat_id"] == "123" assert session_state["telegram_thread_id"] == "99" + assert session_state["telegram_chat_type"] == "private" def test_create_bot_configured( @@ -2529,7 +2531,10 @@ async def test_handle_update_full_flow( await bot._handle_update(update) bot._handle_message.assert_called_once_with( - chat_id=123, message_thread_id=None, user_message="Regular message" + chat_id=123, + message_thread_id=None, + user_message="Regular message", + chat_type=ChatType.PRIVATE, ) @pytest.mark.asyncio @@ -2840,6 +2845,7 @@ async def test_handles_document( file_id="doc123", file_name="report.pdf", caption=None, + chat_type=ChatType.PRIVATE, ) @pytest.mark.asyncio @@ -2882,6 +2888,7 @@ async def test_handles_photo( file_id="large", file_name="photo.jpg", caption=None, + chat_type=ChatType.PRIVATE, ) @pytest.mark.asyncio @@ -2916,6 +2923,7 @@ async def test_handles_audio( file_id="aud123", file_name="song.mp3", caption=None, + chat_type=ChatType.PRIVATE, ) @pytest.mark.asyncio @@ -2952,6 +2960,7 @@ async def test_handles_video( file_id="vid123", file_name="clip.mp4", caption=None, + chat_type=ChatType.PRIVATE, ) @pytest.mark.asyncio @@ -2985,6 +2994,7 @@ async def test_handles_voice( file_id="voi123", file_name="voice.ogg", caption=None, + chat_type=ChatType.PRIVATE, ) @pytest.mark.asyncio @@ -3340,6 +3350,61 @@ async def test_handle_scheduled_reminder_with_thread( call_kwargs = mock_api.send_message.call_args.kwargs assert call_kwargs["message_thread_id"] == 678 + @pytest.mark.asyncio + async def test_handle_scheduled_route_uses_controlled_prompt( + self, + telegram_config: TelegramConfig, + runtime_recorder: RecordingRuntime, + ) -> None: + """Test route events become controlled ADK instructions without addresses.""" + bot = TelegramBot(telegram_config, cast(AdkRuntime, runtime_recorder)) + mock_api = create_autospec(TelegramApiClient, instance=True) + mock_api.send_chat_action = AsyncMock(return_value=True) + mock_api.send_message = AsyncMock(return_value=True) + bot._api = mock_api + reminder = Reminder( + id=7, + user_id="telegram-chat-12345", + message=encode_route_update_event(17), + trigger_time="2026-04-18T12:00:00+00:00", + created_at="2026-04-18T10:00:00+00:00", + ) + + await bot.handle_scheduled_reminder(reminder) + + call = runtime_recorder.run_user_turn_calls[0] + assert 'route_reference "id:17"' in call["message_text"] + assert "blacki.route_traffic_update" not in call["message_text"] + assert call["state"]["telegram_chat_type"] == "private" + + @pytest.mark.asyncio + async def test_scheduled_route_failure_does_not_expose_event( + self, + telegram_config: TelegramConfig, + runtime_recorder: RecordingRuntime, + ) -> None: + """Test route-event fallback is safe when the ADK runtime fails.""" + bot = TelegramBot(telegram_config, cast(AdkRuntime, runtime_recorder)) + runtime_recorder.run_user_turn_error = RuntimeError("failed") + mock_api = create_autospec(TelegramApiClient, instance=True) + mock_api.send_chat_action = AsyncMock(return_value=True) + mock_api.send_message = AsyncMock(return_value=True) + bot._api = mock_api + reminder = Reminder( + id=8, + user_id="telegram-chat-12345", + message=encode_route_update_event(99), + trigger_time="2026-04-18T12:00:00+00:00", + created_at="2026-04-18T10:00:00+00:00", + ) + + await bot.handle_scheduled_reminder(reminder) + + fallback = mock_api.send_message.await_args.kwargs["text"] + assert "couldn't refresh" in fallback + assert "99" not in fallback + assert "route_traffic_update" not in fallback + @pytest.mark.asyncio async def test_handle_scheduled_reminder_fallback( self,