""" Google Maps integration — config + server-side directions proxy. GET /api/maps/config returns: { google_maps_api_key, ok } — canvas pages fetch this to load the Google Maps JS API dynamically (the key never gets baked into page HTML). POST /api/maps/directions body: { origin, destination, waypoints?: [...], mode?: driving|walking|bicycling|transit } returns: the Google Maps Directions API (REST) JSON response verbatim. Lets the openclaw agent do server-side directions lookups (narrate a route over voice without building a canvas page) using the server-side key. Auth: there is NO per-route decorator here on purpose — the global `require_auth` before_request gate in app.py covers every /api/* path that is not in its public allowlists. /api/maps/* is intentionally NOT listed there, so both endpoints require a Clerk session (browser / canvas authFetch) or the internal X-Agent-Key (openclaw agent inside the Docker network). Same pattern as routes/fal.py. Key: GOOGLE_MAPS_API_KEY lives in /mnt/system/base/.platform-keys.env, which is mounted into every openvoiceui container via env_file. Read at request time so a key rotation only needs a container restart, never a code change. """ import logging import os import requests as http from flask import Blueprint, jsonify, request logger = logging.getLogger(__name__) maps_bp = Blueprint('maps', __name__) DIRECTIONS_URL = 'https://maps.googleapis.com/maps/api/directions/json' # Google's accepted travel modes. The UI often says "cycling"; Google says # "bicycling" — accept both. _VALID_MODES = {'driving', 'walking', 'bicycling', 'transit'} _MODE_ALIASES = {'cycling': 'bicycling', 'bike': 'bicycling', 'car': 'driving'} def _api_key() -> str: return os.environ.get('GOOGLE_MAPS_API_KEY', '').strip() @maps_bp.route('/api/maps/config', methods=['GET']) def maps_config(): """Return the Google Maps API key for canvas pages to load the JS API.""" key = _api_key() if not key: return jsonify({'ok': False, 'error': 'Maps API key not configured'}), 503 return jsonify({'ok': True, 'google_maps_api_key': key}) @maps_bp.route('/api/maps/directions', methods=['POST']) def maps_directions(): """Server-side Directions API lookup (REST, not JS). body: { origin, destination, waypoints?: [str, ...], mode?: str } Returns Google's JSON response as-is so callers get routes/legs/steps, distance and duration exactly as documented by Google. """ key = _api_key() if not key: return jsonify({'ok': False, 'error': 'Maps API key not configured'}), 503 body = request.get_json(silent=True) or {} origin = (body.get('origin') or '').strip() destination = (body.get('destination') or '').strip() if not origin or not destination: return jsonify({'ok': False, 'error': 'origin and destination are required'}), 400 mode = (body.get('mode') or 'driving').strip().lower() mode = _MODE_ALIASES.get(mode, mode) if mode not in _VALID_MODES: return jsonify({'ok': False, 'error': f"invalid mode '{mode}' — use one of {sorted(_VALID_MODES)}"}), 400 params = { 'origin': origin, 'destination': destination, 'mode': mode, 'key': key, } waypoints = body.get('waypoints') or [] if waypoints: if not isinstance(waypoints, list): return jsonify({'ok': False, 'error': 'waypoints must be a list of strings'}), 400 stops = [str(w).strip() for w in waypoints if str(w).strip()] if stops: # optimize:true lets Google reorder intermediate stops for the # shortest overall route unless the caller pins the order. prefix = 'optimize:true|' if body.get('optimize') else '' params['waypoints'] = prefix + '|'.join(stops) try: resp = http.get(DIRECTIONS_URL, params=params, timeout=20) data = resp.json() except Exception as exc: logger.error('maps: directions lookup failed: %s', exc) return jsonify({'ok': False, 'error': f'directions lookup failed: {exc}'}), 502 # Google returns 200 with a status field even on logical failures # (NOT_FOUND, ZERO_RESULTS, REQUEST_DENIED...). Pass it through so the # caller can narrate "no route found" instead of a generic error. data['ok'] = data.get('status') == 'OK' return jsonify(data), 200