Files
tiennm99bot/internal/modules/weather/api_client.go
T
tiennm99 053fd07e5a feat(weather)!: add /thuyvan flood alerts for Tân Thuận
/thuyvan shows the 5-day tide-peak forecast at Phú An and Nhà Bè from
the Đài KTTV Nam Bộ bulletin PDF, the Open-Meteo rain forecast, and live
VNDMS river gauges within 30 km. /thuyvan_subscribe opts a chat into a
10:30 ICT push, retried at 12:30, sent only when a forecast peak reaches
báo động I (1.40 m) or a day's rain reaches 50 mm. A missing or stale
bulletin fails the push instead of reading as no risk.

The thoitiet module is renamed to weather; the /thoitiet* commands keep
their names.

BREAKING CHANGE: the module key is now "weather". A MODULES list that
names thoitiet fails at startup and must name weather instead.
2026-10-02 13:06:07 +07:00

205 lines
7.1 KiB
Go

package weather
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// Open-Meteo endpoints. Both are free for non-commercial use with no API key
// (under 10,000 calls a day). Variables so tests can point them at an
// httptest server.
var (
geocodeURL = "https://geocoding-api.open-meteo.com/v1/search"
forecastURL = "https://api.open-meteo.com/v1/forecast"
)
const (
httpTimeout = 5 * time.Second
maxBodySize = 1 << 20
// geocodeCount is how many candidates to fetch so a Vietnamese match can
// win over a same-named place abroad.
geocodeCount = 10
// forecastDays covers today plus the six days after it.
forecastDays = 7
// forecastHours is the hour in progress plus the six after it; hourly
// data starts at the current hour, which the hourly view skips.
forecastHours = 7
currentFields = "temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m"
dailyFields = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum," +
"precipitation_probability_max,uv_index_max,sunrise,sunset"
hourlyFields = "temperature_2m,apparent_temperature,weather_code,precipitation_probability,precipitation," +
"relative_humidity_2m,wind_speed_10m"
)
// place is one geocoding result.
type place struct {
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
CountryCode string `json:"country_code"`
Country string `json:"country"`
Admin1 string `json:"admin1"`
}
type geocodeResponse struct {
Results []place `json:"results"`
}
// currentWeather is the "current" block of a forecast response.
type currentWeather struct {
Time string `json:"time"`
Temperature float64 `json:"temperature_2m"`
ApparentTemperature float64 `json:"apparent_temperature"`
Humidity float64 `json:"relative_humidity_2m"`
WeatherCode int `json:"weather_code"`
WindSpeed float64 `json:"wind_speed_10m"`
}
// dailyWeather holds parallel per-day arrays; index 0 is today in the
// location's own timezone. A null upstream value decodes as zero.
type dailyWeather struct {
Time []string `json:"time"`
WeatherCode []int `json:"weather_code"`
TemperatureMax []float64 `json:"temperature_2m_max"`
TemperatureMin []float64 `json:"temperature_2m_min"`
PrecipitationSum []float64 `json:"precipitation_sum"`
PrecipitationProbability []float64 `json:"precipitation_probability_max"`
UVIndexMax []float64 `json:"uv_index_max"`
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
}
// hourlyWeather holds parallel per-hour arrays in the location's local time,
// starting at the hour in progress.
type hourlyWeather struct {
Time []string `json:"time"`
Temperature []float64 `json:"temperature_2m"`
ApparentTemperature []float64 `json:"apparent_temperature"`
WeatherCode []int `json:"weather_code"`
PrecipitationProbability []float64 `json:"precipitation_probability"`
Precipitation []float64 `json:"precipitation"`
Humidity []float64 `json:"relative_humidity_2m"`
WindSpeed []float64 `json:"wind_speed_10m"`
}
type forecast struct {
Current currentWeather `json:"current"`
Hourly hourlyWeather `json:"hourly"`
Daily dailyWeather `json:"daily"`
}
// hours reports how many complete hourly rows the response carries.
func (h hourlyWeather) hours() int {
return min(len(h.Time), len(h.Temperature), len(h.ApparentTemperature), len(h.WeatherCode),
len(h.PrecipitationProbability), len(h.Precipitation), len(h.Humidity), len(h.WindSpeed))
}
// upcomingHours returns the indexes of the hourly rows that start after the
// current observation time, up to limit. Both use the same local ISO format,
// so they compare as strings.
func (f forecast) upcomingHours(limit int) []int {
var idx []int
for i := range f.Hourly.hours() {
if len(idx) == limit {
break
}
if f.Hourly.Time[i] > f.Current.Time {
idx = append(idx, i)
}
}
return idx
}
// days reports how many complete daily rows the response carries, the length
// of the shortest per-day array, so renderers can index every array safely.
func (d dailyWeather) days() int {
return min(len(d.Time), len(d.WeatherCode), len(d.TemperatureMax), len(d.TemperatureMin),
len(d.PrecipitationSum), len(d.PrecipitationProbability), len(d.UVIndexMax),
len(d.Sunrise), len(d.Sunset))
}
// geocode returns Open-Meteo's matches for name, with Vietnamese labels.
func geocode(ctx context.Context, client *http.Client, name string) ([]place, error) {
q := url.Values{}
q.Set("name", name)
q.Set("count", strconv.Itoa(geocodeCount))
q.Set("language", "vi")
q.Set("format", "json")
var body geocodeResponse
if err := getJSON(ctx, client, geocodeURL+"?"+q.Encode(), &body); err != nil {
return nil, fmt.Errorf("geocode: %w", err)
}
return body.Results, nil
}
// fetchForecast returns current conditions, the next hours, and a 7-day daily
// forecast. The
// timezone is the location's own, so "today" is its local date.
func fetchForecast(ctx context.Context, client *http.Client, p place) (forecast, error) {
q := url.Values{}
q.Set("latitude", strconv.FormatFloat(p.Latitude, 'f', -1, 64))
q.Set("longitude", strconv.FormatFloat(p.Longitude, 'f', -1, 64))
q.Set("timezone", "auto")
q.Set("forecast_days", strconv.Itoa(forecastDays))
q.Set("forecast_hours", strconv.Itoa(forecastHours))
q.Set("current", currentFields)
q.Set("hourly", hourlyFields)
q.Set("daily", dailyFields)
var body forecast
if err := getJSON(ctx, client, forecastURL+"?"+q.Encode(), &body); err != nil {
return forecast{}, fmt.Errorf("forecast: %w", err)
}
if body.Daily.days() == 0 {
return forecast{}, fmt.Errorf("forecast: no daily data")
}
return body, nil
}
func getJSON(ctx context.Context, client *http.Client, rawURL string, out any) error {
body, err := getBody(ctx, client, rawURL, maxBodySize, http.Header{"Accept": {"application/json"}})
if err != nil {
return err
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
}
// getBody GETs rawURL with the given headers and returns at most limit bytes
// of a 200 response. A longer body is an error rather than a silent cut, so a
// truncated JSON or PDF never reaches a parser.
func getBody(ctx context.Context, client *http.Client, rawURL string, limit int64, header http.Header) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
for k, v := range header {
req.Header[k] = v
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("body exceeds %d bytes", limit)
}
return body, nil
}