From 2fd362dd011c1a41230bc45df0ab2027241bdc9f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 10:34:03 +0700 Subject: [PATCH] feat(thoitiet): make /thoitiet an hourly forecast for the next 6 hours --- README.md | 2 +- internal/modules/thoitiet/api_client.go | 47 +++++++++++++++++++- internal/modules/thoitiet/format.go | 25 +++++++++++ internal/modules/thoitiet/thoitiet.go | 31 ++++++++----- internal/modules/thoitiet/thoitiet_test.go | 51 ++++++++++++++++++++-- 5 files changed, 139 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a7d0bd0..45db0e0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Atlas via long polling and an in-process cron scheduler. | `alias` | `/alias ` save a replied message under a name, then send it back with `/insert `, bare `/`, or inline `@botname `; `/aliases` lists, `/unalias` deletes. See [docs/aliases.md](docs/aliases.md) | | `blacklist` | Per-topic text deny-list with whitelist exceptions: `/blacklist_add`, `/blacklist_del`, `/whitelist_add`, `/whitelist_del`, `/blacklist_rules` lists both, `/blacklist_check` judges a text, `/blacklist` does either, `/whitelist_rnd` picks a random exception. Passive — the bot never scans chat. See [docs/blacklist.md](docs/blacklist.md) | | `monkeyd` | `/monkeyd_crawl [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags ` list its tags as hashtags | -| `thoitiet` | Weather from Open-Meteo: `/thoitiethomnay` (alias `/thoitiet`) for current conditions and today, `/thoitietngaymai` for tomorrow, `/thoitiettuannay` for the next 7 days. Each takes `[location...]` (Vietnamese with or without diacritics, shorthand like `hcm`/`hn`, or a foreign city); the default is Ho Chi Minh City | +| `thoitiet` | Weather from Open-Meteo: `/thoitiet` for the next 6 hours hour by hour, `/thoitiethomnay` for current conditions and today, `/thoitietngaymai` for tomorrow, `/thoitiettuannay` for the next 7 days. Each takes `[location...]` (Vietnamese with or without diacritics, shorthand like `hcm`/`hn`, or a foreign city); the default is Ho Chi Minh City | Commands marked (admin) require a user ID in `ADMIN_IDS` or the owner; commands marked (owner) require `OWNER_ID`. Both kinds are hidden from `/help` and the diff --git a/internal/modules/thoitiet/api_client.go b/internal/modules/thoitiet/api_client.go index 99b8eb5..245b172 100644 --- a/internal/modules/thoitiet/api_client.go +++ b/internal/modules/thoitiet/api_client.go @@ -28,10 +28,15 @@ const ( 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. @@ -50,6 +55,7 @@ type geocodeResponse struct { // 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"` @@ -71,11 +77,47 @@ type dailyWeather struct { 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 { @@ -98,7 +140,8 @@ func geocode(ctx context.Context, client *http.Client, name string) ([]place, er return body.Results, nil } -// fetchForecast returns current conditions and a 7-day daily forecast. The +// 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{} @@ -106,7 +149,9 @@ func fetchForecast(ctx context.Context, client *http.Client, p place) (forecast, 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 { diff --git a/internal/modules/thoitiet/format.go b/internal/modules/thoitiet/format.go index 4515846..22c0634 100644 --- a/internal/modules/thoitiet/format.go +++ b/internal/modules/thoitiet/format.go @@ -59,6 +59,31 @@ func conditionFor(code int) condition { // vnWeekdays are the short Vietnamese weekday names, indexed by time.Weekday. var vnWeekdays = [...]string{"CN", "T2", "T3", "T4", "T5", "T6", "T7"} +// hourlyViewHours is how many upcoming hours /thoitiet lists. +const hourlyViewHours = 6 + +// formatHourly renders current conditions followed by one block per upcoming +// hour. +func formatHourly(p place, f forecast) string { + c := f.Current + cur := conditionFor(c.WeatherCode) + h := f.Hourly + idx := f.upcomingHours(hourlyViewHours) + var sb strings.Builder + fmt.Fprintf(&sb, "🕐 Thời tiết %d giờ tới — %s\n", len(idx), displayName(p)) + fmt.Fprintf(&sb, "Hiện tại %s: %s (cảm giác %s), %s %s\n", clock(c.Time), temp(c.Temperature), + temp(c.ApparentTemperature), cur.label, cur.emoji) + for _, i := range idx { + hc := conditionFor(h.WeatherCode[i]) + fmt.Fprintf(&sb, "\n%s %s %s, %s (cảm giác %s)\n", clock(h.Time[i]), hc.emoji, hc.label, + temp(h.Temperature[i]), temp(h.ApparentTemperature[i])) + fmt.Fprintf(&sb, "Mưa %d%% (%s mm), độ ẩm %d%%, gió %s km/h\n", round(h.PrecipitationProbability[i]), + decimal(h.Precipitation[i]), round(h.Humidity[i]), decimal(h.WindSpeed[i])) + } + sb.WriteString("\n" + sourceLine) + return sb.String() +} + // formatToday renders current conditions plus today's daily summary. func formatToday(p place, f forecast) string { c := f.Current diff --git a/internal/modules/thoitiet/thoitiet.go b/internal/modules/thoitiet/thoitiet.go index 01eb986..22928f8 100644 --- a/internal/modules/thoitiet/thoitiet.go +++ b/internal/modules/thoitiet/thoitiet.go @@ -1,7 +1,7 @@ -// Package thoitiet is the weather module: /thoitiethomnay (alias /thoitiet), -// /thoitietngaymai, and /thoitiettuannay show today's, tomorrow's, and the -// next 7 days' forecast for a location, Ho Chi Minh City by default. Data -// comes from Open-Meteo, which needs no API key. +// Package thoitiet is the weather module: /thoitiet shows the next 6 hours +// hour by hour, and /thoitiethomnay, /thoitietngaymai, and /thoitiettuannay +// show today's, tomorrow's, and the next 7 days' forecast for a location, Ho +// Chi Minh City by default. Data comes from Open-Meteo, which needs no API key. package thoitiet import ( @@ -26,17 +26,24 @@ const ( // errPlaceNotFound means the geocoder had no match for the user's location. var errPlaceNotFound = errors.New("place not found") -// view is one forecast command's renderer plus the daily rows it reads. +// view is one forecast command's renderer plus a check that the response +// carries the rows it reads. type view struct { command string - minDays int + ready func(forecast) bool render func(place, forecast) string } +func hasDays(n int) func(forecast) bool { + return func(f forecast) bool { return f.Daily.days() >= n } +} + var ( - todayView = view{command: "thoitiethomnay", minDays: 1, render: formatToday} - tomorrowView = view{command: "thoitietngaymai", minDays: 2, render: formatTomorrow} - weekView = view{command: "thoitiettuannay", minDays: 1, render: formatWeek} + hourlyView = view{command: "thoitiet", render: formatHourly, + ready: func(f forecast) bool { return len(f.upcomingHours(hourlyViewHours)) > 0 }} + todayView = view{command: "thoitiethomnay", ready: hasDays(1), render: formatToday} + tomorrowView = view{command: "thoitietngaymai", ready: hasDays(2), render: formatTomorrow} + weekView = view{command: "thoitiettuannay", ready: hasDays(1), render: formatWeek} ) // New is the thoitiet module Factory. The module keeps no state. @@ -54,7 +61,7 @@ func New(_ modules.Deps) modules.Module { return modules.Module{ Commands: []modules.Command{ command("thoitiethomnay", "Thời tiết hôm nay (mặc định TP.HCM)", todayView), - command("thoitiet", "Thời tiết hôm nay (mặc định TP.HCM)", todayView), + command("thoitiet", "Thời tiết từng giờ trong 6 giờ tới (mặc định TP.HCM)", hourlyView), command("thoitietngaymai", "Thời tiết ngày mai (mặc định TP.HCM)", tomorrowView), command("thoitiettuannay", "Thời tiết 7 ngày tới (mặc định TP.HCM)", weekView), }, @@ -74,8 +81,8 @@ func handler(client *http.Client, v view) modules.CommandHandler { if errors.Is(err, errPlaceNotFound) { return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Không tìm thấy địa điểm %q.", query)) } - if err == nil && f.Daily.days() < v.minDays { - err = fmt.Errorf("forecast: %d daily rows, need %d", f.Daily.days(), v.minDays) + if err == nil && !v.ready(f) { + err = fmt.Errorf("forecast: missing rows for /%s", v.command) } if err != nil { log.Error("weather fetch failed", "module", "thoitiet", "command", v.command, "err", err) diff --git a/internal/modules/thoitiet/thoitiet_test.go b/internal/modules/thoitiet/thoitiet_test.go index cef18d7..87a1be0 100644 --- a/internal/modules/thoitiet/thoitiet_test.go +++ b/internal/modules/thoitiet/thoitiet_test.go @@ -15,7 +15,17 @@ import ( // forecastFixture mirrors a live Open-Meteo response for Ho Chi Minh City. const forecastFixture = `{ - "current":{"temperature_2m":29.0,"apparent_temperature":35.7,"relative_humidity_2m":78,"weather_code":3,"wind_speed_10m":3.2}, + "current":{"time":"2026-10-01T10:30","temperature_2m":29.0,"apparent_temperature":35.7,"relative_humidity_2m":78,"weather_code":3,"wind_speed_10m":3.2}, + "hourly":{ + "time":["2026-10-01T10:00","2026-10-01T11:00","2026-10-01T12:00","2026-10-01T13:00","2026-10-01T14:00","2026-10-01T15:00","2026-10-01T16:00"], + "temperature_2m":[30.4,31.4,32.1,32.1,32.2,32.7,31.9], + "apparent_temperature":[38.1,40.2,41.3,40.9,40.2,38.2,36.6], + "weather_code":[3,3,3,3,51,1,2], + "precipitation_probability":[2,2,4,10,23,41,55], + "precipitation":[0.00,0.00,0.00,0.00,0.10,0.00,0.00], + "relative_humidity_2m":[73,71,68,68,64,57,60], + "wind_speed_10m":[3.0,2.4,1.6,1.1,1.4,2.8,5.7] + }, "daily":{ "time":["2026-10-01","2026-10-02","2026-10-03","2026-10-04","2026-10-05","2026-10-06","2026-10-07"], "weather_code":[80,80,81,95,95,95,53], @@ -123,7 +133,7 @@ func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) { "Khả năng mưa 70% (5,3 mm), UV 8,9\n" + "Mặt trời mọc 05:42, lặn 17:44\n" + "Nguồn: Open-Meteo" - for _, cmd := range []string{"/thoitiethomnay", "/thoitiet", "/thoitiet hcm", "/thoitiet Sài Gòn"} { + for _, cmd := range []string{"/thoitiethomnay", "/thoitiethomnay hcm", "/thoitiethomnay Sài Gòn"} { if got := send(rb, cmd); got != want { t.Errorf("%s reply =\n%s\nwant\n%s", cmd, got, want) } @@ -132,11 +142,46 @@ func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) { t.Errorf("geocode called with %v, want no calls", f.geocodeQueries) } if f.lastForecastQ["latitude"] != "10.82302" || f.lastForecastQ["timezone"] != "auto" || - f.lastForecastQ["forecast_days"] != "7" { + f.lastForecastQ["forecast_days"] != "7" || f.lastForecastQ["forecast_hours"] != "7" { t.Errorf("forecast query = %v", f.lastForecastQ) } } +func TestHourly_ListsNextSixHours(t *testing.T) { + f := &fakeOpenMeteo{forecastBody: forecastFixture} + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + want := "🕐 Thời tiết 6 giờ tới — Thành phố Hồ Chí Minh\n" + + "Hiện tại 10:30: 29°C (cảm giác 36°C), Nhiều mây ☁️\n" + + "\n11:00 ☁️ Nhiều mây, 31°C (cảm giác 40°C)\n" + + "Mưa 2% (0,0 mm), độ ẩm 71%, gió 2,4 km/h\n" + + "\n12:00 ☁️ Nhiều mây, 32°C (cảm giác 41°C)\n" + + "Mưa 4% (0,0 mm), độ ẩm 68%, gió 1,6 km/h\n" + + "\n13:00 ☁️ Nhiều mây, 32°C (cảm giác 41°C)\n" + + "Mưa 10% (0,0 mm), độ ẩm 68%, gió 1,1 km/h\n" + + "\n14:00 🌦️ Mưa phùn nhẹ, 32°C (cảm giác 40°C)\n" + + "Mưa 23% (0,1 mm), độ ẩm 64%, gió 1,4 km/h\n" + + "\n15:00 🌤️ Ít mây, 33°C (cảm giác 38°C)\n" + + "Mưa 41% (0,0 mm), độ ẩm 57%, gió 2,8 km/h\n" + + "\n16:00 ⛅ Mây rải rác, 32°C (cảm giác 37°C)\n" + + "Mưa 55% (0,0 mm), độ ẩm 60%, gió 5,7 km/h\n" + + "\nNguồn: Open-Meteo" + if got := send(rb, "/thoitiet"); got != want { + t.Errorf("reply =\n%s\nwant\n%s", got, want) + } +} + +func TestHourly_NoUpcomingHoursRepliesError(t *testing.T) { + stale := strings.Replace(forecastFixture, `"time":"2026-10-01T10:30"`, `"time":"2026-10-01T16:30"`, 1) + stubOpenMeteo(t, &fakeOpenMeteo{forecastBody: stale}) + rb := installThoitiet(t) + + if got := send(rb, "/thoitiet"); got != fetchErrorText { + t.Errorf("reply = %q, want %q", got, fetchErrorText) + } +} + func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) { f := &fakeOpenMeteo{geocodeBody: daLatGeocodeFixture, forecastBody: forecastFixture} stubOpenMeteo(t, f)