feat(thoitiet): make /thoitiet an hourly forecast for the next 6 hours

This commit is contained in:
tiennm99 committed 2026-10-01 10:34:03 +07:00
1 parent f748d34095
commit 2fd362dd01
5 files changed
+139 -17

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ Atlas via long polling and an in-process cron scheduler.
| `alias` | `/alias <name>` save a replied message under a name, then send it back with `/insert <name>`, bare `/<name>`, or inline `@botname <prefix>`; `/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 <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` 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
+46 -1
View File
@@ -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 {
+25
View File
@@ -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
+19 -12
View File
@@ -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)
+48 -3
View File
@@ -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)