package agent import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) var ( geocodeCache = make(map[string]*GeoResult) geocodeCacheMu sync.RWMutex ) // TravelDataClient calls the travel-svc data-layer endpoints. type TravelDataClient struct { baseURL string httpClient *http.Client maxRetries int } func NewTravelDataClient(baseURL string) *TravelDataClient { return &TravelDataClient{ baseURL: strings.TrimSuffix(baseURL, "/"), httpClient: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ MaxIdleConns: 20, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, }, }, maxRetries: 2, } } func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) { var lastErr error for attempt := 0; attempt <= c.maxRetries; attempt++ { if attempt > 0 { backoff := time.Duration(attempt*500) * time.Millisecond select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(backoff): } } resp, err := c.httpClient.Do(req.Clone(ctx)) if err != nil { lastErr = err continue } if resp.StatusCode >= 500 { resp.Body.Close() lastErr = fmt.Errorf("server error: %d", resp.StatusCode) continue } return resp, nil } return nil, fmt.Errorf("all %d retries failed: %w", c.maxRetries+1, lastErr) } func (c *TravelDataClient) Geocode(ctx context.Context, query string) (*GeoResult, error) { // Check in-memory cache first geocodeCacheMu.RLock() if cached, ok := geocodeCache[query]; ok { geocodeCacheMu.RUnlock() return cached, nil } geocodeCacheMu.RUnlock() u := fmt.Sprintf("%s/api/v1/travel/geocode?query=%s", c.baseURL, url.QueryEscape(query)) req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return nil, err } resp, err := c.doWithRetry(ctx, req) if err != nil { return nil, fmt.Errorf("geocode request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("geocode returned %d: %s", resp.StatusCode, string(body)) } var result GeoResult if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("geocode decode error: %w", err) } geocodeCacheMu.Lock() geocodeCache[query] = &result geocodeCacheMu.Unlock() return &result, nil } func (c *TravelDataClient) SearchPOI(ctx context.Context, lat, lng float64, radius int, categories []string) ([]POICard, error) { body := map[string]interface{}{ "lat": lat, "lng": lng, "radius": radius, "limit": 20, } if len(categories) > 0 { body["categories"] = categories } jsonBody, _ := json.Marshal(body) u := fmt.Sprintf("%s/api/v1/travel/poi", c.baseURL) req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("poi search failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("poi search returned %d", resp.StatusCode) } var rawPOIs []struct { ID string `json:"id"` Name string `json:"name"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Category string `json:"category"` Address string `json:"address"` Rating float64 `json:"rating"` Distance float64 `json:"distance"` } if err := json.NewDecoder(resp.Body).Decode(&rawPOIs); err != nil { return nil, err } cards := make([]POICard, 0, len(rawPOIs)) for _, p := range rawPOIs { cards = append(cards, POICard{ ID: p.ID, Name: p.Name, Category: p.Category, Address: p.Address, Lat: p.Lat, Lng: p.Lng, Rating: p.Rating, }) } return cards, nil } func (c *TravelDataClient) SearchFlights(ctx context.Context, origin, destination, date string, adults int) ([]TransportOption, error) { body := map[string]interface{}{ "origin": origin, "destination": destination, "departureDate": date, "adults": adults, } jsonBody, _ := json.Marshal(body) u := fmt.Sprintf("%s/api/v1/travel/flights", c.baseURL) req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("flights search failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("flights search returned %d", resp.StatusCode) } var rawFlights []struct { ID string `json:"id"` Airline string `json:"airline"` FlightNumber string `json:"flightNumber"` DepartureAirport string `json:"departureAirport"` DepartureCity string `json:"departureCity"` DepartureTime string `json:"departureTime"` ArrivalAirport string `json:"arrivalAirport"` ArrivalCity string `json:"arrivalCity"` ArrivalTime string `json:"arrivalTime"` Duration int `json:"duration"` Stops int `json:"stops"` Price float64 `json:"price"` Currency string `json:"currency"` BookingURL string `json:"bookingUrl"` } if err := json.NewDecoder(resp.Body).Decode(&rawFlights); err != nil { return nil, err } options := make([]TransportOption, 0, len(rawFlights)) for _, f := range rawFlights { options = append(options, TransportOption{ ID: f.ID, Mode: "flight", From: f.DepartureCity, To: f.ArrivalCity, Departure: f.DepartureTime, Arrival: f.ArrivalTime, DurationMin: f.Duration, Price: f.Price, Currency: f.Currency, Provider: f.Airline, BookingURL: f.BookingURL, Airline: f.Airline, FlightNum: f.FlightNumber, Stops: f.Stops, }) } return options, nil } func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, checkIn, checkOut string, adults int) ([]HotelCard, error) { body := map[string]interface{}{ "lat": lat, "lng": lng, "radius": 10, "checkIn": checkIn, "checkOut": checkOut, "adults": adults, } jsonBody, _ := json.Marshal(body) u := fmt.Sprintf("%s/api/v1/travel/hotels", c.baseURL) req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("hotels search failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hotels search returned %d", resp.StatusCode) } var rawHotels []struct { ID string `json:"id"` Name string `json:"name"` Address string `json:"address"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Rating float64 `json:"rating"` ReviewCount int `json:"reviewCount"` Stars int `json:"stars"` Price float64 `json:"price"` PricePerNight float64 `json:"pricePerNight"` Currency string `json:"currency"` CheckIn string `json:"checkIn"` CheckOut string `json:"checkOut"` Amenities []string `json:"amenities"` Photos []string `json:"photos"` BookingURL string `json:"bookingUrl"` } if err := json.NewDecoder(resp.Body).Decode(&rawHotels); err != nil { return nil, err } cards := make([]HotelCard, 0, len(rawHotels)) for _, h := range rawHotels { cards = append(cards, HotelCard{ ID: h.ID, Name: h.Name, Stars: h.Stars, Rating: h.Rating, ReviewCount: h.ReviewCount, PricePerNight: h.PricePerNight, TotalPrice: h.Price, Currency: h.Currency, Address: h.Address, Lat: h.Lat, Lng: h.Lng, BookingURL: h.BookingURL, Photos: h.Photos, Amenities: h.Amenities, CheckIn: h.CheckIn, CheckOut: h.CheckOut, }) } return cards, nil } // PlaceResult represents a place from 2GIS Places API. type PlaceResult struct { ID string `json:"id"` Name string `json:"name"` Address string `json:"address"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Type string `json:"type"` Purpose string `json:"purpose"` } func (c *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) { body := map[string]interface{}{ "query": query, "lat": lat, "lng": lng, "radius": radius, } jsonBody, _ := json.Marshal(body) u := fmt.Sprintf("%s/api/v1/travel/places", c.baseURL) req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.doWithRetry(ctx, req) if err != nil { return nil, fmt.Errorf("places search failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("places search returned %d: %s", resp.StatusCode, string(respBody)) } var places []PlaceResult if err := json.NewDecoder(resp.Body).Decode(&places); err != nil { return nil, fmt.Errorf("places decode error: %w", err) } return places, nil } func (c *TravelDataClient) GetRoute(ctx context.Context, points []MapPoint, transport string) (*RouteDirectionResult, error) { if len(points) < 2 { return nil, fmt.Errorf("need at least 2 points for route") } if transport == "" { transport = "driving" } coords := make([]map[string]float64, len(points)) for i, p := range points { coords[i] = map[string]float64{"lat": p.Lat, "lng": p.Lng} } body := map[string]interface{}{ "points": coords, "profile": transport, } jsonBody, _ := json.Marshal(body) u := fmt.Sprintf("%s/api/v1/travel/route", c.baseURL) req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("route request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("route returned %d: %s", resp.StatusCode, string(respBody)) } var result RouteDirectionResult if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } return &result, nil } // GetRouteSegments builds routes between each consecutive pair of points. func (c *TravelDataClient) GetRouteSegments(ctx context.Context, points []MapPoint, transport string) ([]RouteSegmentResult, error) { if len(points) < 2 { return nil, nil } segments := make([]RouteSegmentResult, 0, len(points)-1) for i := 0; i < len(points)-1; i++ { pair := []MapPoint{points[i], points[i+1]} dir, err := c.GetRoute(ctx, pair, transport) if err != nil { segments = append(segments, RouteSegmentResult{ FromName: points[i].Label, ToName: points[i+1].Label, }) continue } segments = append(segments, RouteSegmentResult{ FromName: points[i].Label, ToName: points[i+1].Label, Distance: dir.Distance, Duration: dir.Duration, Geometry: dir.Geometry, }) } return segments, nil } type GeoResult struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` Name string `json:"name"` Country string `json:"country"` } type RouteDirectionResult struct { Geometry RouteGeometryResult `json:"geometry"` Distance float64 `json:"distance"` Duration float64 `json:"duration"` Steps []RouteStepResult `json:"steps,omitempty"` } type RouteGeometryResult struct { Coordinates [][2]float64 `json:"coordinates"` Type string `json:"type"` } type RouteStepResult struct { Instruction string `json:"instruction"` Distance float64 `json:"distance"` Duration float64 `json:"duration"` Type string `json:"type"` } type RouteSegmentResult struct { FromName string `json:"from"` ToName string `json:"to"` Distance float64 `json:"distance"` Duration float64 `json:"duration"` Geometry RouteGeometryResult `json:"geometry,omitempty"` }