e030466cac
- New internal/weather/ package: Provider interface, Weather struct, StubProvider - OpenMeteoProvider with geocoding + current weather (keyless, free API) - Config: WeatherConfig in VoiceConfig (provider, default_location) - Wire in voice.go as weatherProvider on reactiveHandler - Handle weather queries in IntentQuery (before notes RAG) - Helper: isWeatherQuery / extractWeatherLocation - Tests: mocked HTTP round-trip for OpenMeteo, stub ErrNotConfigured, config tests - No real network calls in any test Co-Authored-By: opencode <opencode@anthropic.com>
130 lines
3.0 KiB
Go
130 lines
3.0 KiB
Go
package weather
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
type OpenMeteoProvider struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewOpenMeteoProvider() *OpenMeteoProvider {
|
|
return &OpenMeteoProvider{
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *OpenMeteoProvider) SetClient(c *http.Client) {
|
|
p.httpClient = c
|
|
}
|
|
|
|
type geoResult struct {
|
|
Name string `json:"name"`
|
|
Latitude float64 `json:"latitude"`
|
|
Longitude float64 `json:"longitude"`
|
|
Country string `json:"country"`
|
|
}
|
|
|
|
type geoResponse struct {
|
|
Results []geoResult `json:"results"`
|
|
}
|
|
|
|
type currentWeather struct {
|
|
Temperature float64 `json:"temperature"`
|
|
WMO int `json:"weathercode"`
|
|
WindSpeed float64 `json:"windspeed"`
|
|
}
|
|
|
|
type forecastResponse struct {
|
|
CurrentWeather currentWeather `json:"current_weather"`
|
|
}
|
|
|
|
var wmoCodes = map[int]string{
|
|
0: "ясно",
|
|
1: "преимущественно ясно",
|
|
2: "облачно",
|
|
3: "пасмурно",
|
|
45: "туман",
|
|
51: "морось",
|
|
61: "дождь",
|
|
71: "снег",
|
|
95: "гроза",
|
|
}
|
|
|
|
func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) (Weather, error) {
|
|
lat, lon, name, err := p.geocode(ctx, location)
|
|
if err != nil {
|
|
return Weather{}, fmt.Errorf("open-meteo: geocode: %w", err)
|
|
}
|
|
|
|
u := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f¤t_weather=true", lat, lon)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return Weather{}, fmt.Errorf("open-meteo: request: %w", err)
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return Weather{}, fmt.Errorf("open-meteo: http: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return Weather{}, fmt.Errorf("open-meteo: read: %w", err)
|
|
}
|
|
|
|
var forecast forecastResponse
|
|
if err := json.Unmarshal(body, &forecast); err != nil {
|
|
return Weather{}, fmt.Errorf("open-meteo: decode: %w", err)
|
|
}
|
|
|
|
condition := wmoCodes[forecast.CurrentWeather.WMO]
|
|
if condition == "" {
|
|
condition = "неизвестно"
|
|
}
|
|
|
|
return Weather{
|
|
Location: name,
|
|
Temperature: forecast.CurrentWeather.Temperature,
|
|
Condition: condition,
|
|
}, nil
|
|
}
|
|
|
|
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
|
|
u := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=ru&format=json", url.QueryEscape(location))
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
|
|
var geo geoResponse
|
|
if err := json.Unmarshal(body, &geo); err != nil {
|
|
return 0, 0, "", err
|
|
}
|
|
|
|
if len(geo.Results) == 0 {
|
|
return 0, 0, "", fmt.Errorf("location %q not found", location)
|
|
}
|
|
|
|
r := geo.Results[0]
|
|
return r.Latitude, r.Longitude, r.Name, nil
|
|
}
|