From f748d340955687382fd1f81792ad5016f0e3c368 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 1 Oct 2026 10:28:10 +0700 Subject: [PATCH] feat(thoitiet): add today, tomorrow, and 7-day weather commands --- README.md | 1 + cmd/server/command_menu_test.go | 4 + cmd/server/main.go | 2 + internal/modules/thoitiet/api_client.go | 139 ++++++++++ internal/modules/thoitiet/format.go | 149 +++++++++++ internal/modules/thoitiet/location.go | 88 ++++++ internal/modules/thoitiet/location_test.go | 56 ++++ internal/modules/thoitiet/thoitiet.go | 116 ++++++++ internal/modules/thoitiet/thoitiet_test.go | 250 ++++++++++++++++++ .../plan.md | 28 ++ ...rch-261001-0900-thoitiet-weather-module.md | 123 +++++++++ 11 files changed, 956 insertions(+) create mode 100644 internal/modules/thoitiet/api_client.go create mode 100644 internal/modules/thoitiet/format.go create mode 100644 internal/modules/thoitiet/location.go create mode 100644 internal/modules/thoitiet/location_test.go create mode 100644 internal/modules/thoitiet/thoitiet.go create mode 100644 internal/modules/thoitiet/thoitiet_test.go create mode 100644 plans/261001-0925-thoitiet-weather-module/plan.md create mode 100644 plans/reports/research-261001-0900-thoitiet-weather-module.md diff --git a/README.md b/README.md index ddacafc..a7d0bd0 100644 --- a/README.md +++ b/README.md @@ -21,6 +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 | 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/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index fd49304..ea861f2 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -94,6 +94,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "stock_sell": " ", "stock_cash_dividend": " ", "stock_share_dividend": " ", + "thoitiet": "[location...]", + "thoitiethomnay": "[location...]", + "thoitietngaymai": "[location...]", + "thoitiettuannay": "[location...]", "trongtruonghop": "[target...]", "tth": "[target...]", "wheelofnames": "", diff --git a/cmd/server/main.go b/cmd/server/main.go index 8b49616..69cc890 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -34,6 +34,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/stats" "github.com/tiennm99/miti99bot/internal/modules/sticker" "github.com/tiennm99/miti99bot/internal/modules/stock" + "github.com/tiennm99/miti99bot/internal/modules/thoitiet" "github.com/tiennm99/miti99bot/internal/modules/util" "github.com/tiennm99/miti99bot/internal/modules/wordle" "github.com/tiennm99/miti99bot/internal/server" @@ -103,6 +104,7 @@ func factories() map[string]modules.Factory { sticker.CollectionName: sticker.New, "alias": alias.New, "blacklist": blacklist.New, + "thoitiet": thoitiet.New, } } diff --git a/internal/modules/thoitiet/api_client.go b/internal/modules/thoitiet/api_client.go new file mode 100644 index 0000000..99b8eb5 --- /dev/null +++ b/internal/modules/thoitiet/api_client.go @@ -0,0 +1,139 @@ +package thoitiet + +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 + + 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" +) + +// 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 { + 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"` +} + +type forecast struct { + Current currentWeather `json:"current"` + Daily dailyWeather `json:"daily"` +} + +// 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 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("current", currentFields) + 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 { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxBodySize)).Decode(out); err != nil { + return fmt.Errorf("decode: %w", err) + } + return nil +} diff --git a/internal/modules/thoitiet/format.go b/internal/modules/thoitiet/format.go new file mode 100644 index 0000000..4515846 --- /dev/null +++ b/internal/modules/thoitiet/format.go @@ -0,0 +1,149 @@ +package thoitiet + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// sourceLine credits the data source, as Open-Meteo's CC BY 4.0 licence asks. +const sourceLine = "Nguồn: Open-Meteo" + +type condition struct { + label string + emoji string +} + +// wmoConditions maps WMO weather interpretation codes, the "weather_code" +// field Open-Meteo returns, to a Vietnamese label. +var wmoConditions = map[int]condition{ + 0: {"Trời quang", "☀️"}, + 1: {"Ít mây", "🌤️"}, + 2: {"Mây rải rác", "⛅"}, + 3: {"Nhiều mây", "☁️"}, + 45: {"Sương mù", "🌫️"}, + 48: {"Sương mù đóng băng", "🌫️"}, + 51: {"Mưa phùn nhẹ", "🌦️"}, + 53: {"Mưa phùn", "🌦️"}, + 55: {"Mưa phùn dày", "🌧️"}, + 56: {"Mưa phùn băng nhẹ", "🌧️"}, + 57: {"Mưa phùn băng", "🌧️"}, + 61: {"Mưa nhẹ", "🌧️"}, + 63: {"Mưa vừa", "🌧️"}, + 65: {"Mưa to", "🌧️"}, + 66: {"Mưa băng nhẹ", "🌧️"}, + 67: {"Mưa băng", "🌧️"}, + 71: {"Tuyết nhẹ", "🌨️"}, + 73: {"Tuyết vừa", "🌨️"}, + 75: {"Tuyết dày", "❄️"}, + 77: {"Tuyết hạt", "🌨️"}, + 80: {"Mưa rào nhẹ", "🌦️"}, + 81: {"Mưa rào", "🌦️"}, + 82: {"Mưa rào rất to", "⛈️"}, + 85: {"Mưa tuyết nhẹ", "🌨️"}, + 86: {"Mưa tuyết", "🌨️"}, + 95: {"Dông", "⛈️"}, + 96: {"Dông kèm mưa đá nhẹ", "⛈️"}, + 99: {"Dông kèm mưa đá", "⛈️"}, +} + +func conditionFor(code int) condition { + if c, ok := wmoConditions[code]; ok { + return c + } + return condition{"Không rõ", "🌡️"} +} + +// vnWeekdays are the short Vietnamese weekday names, indexed by time.Weekday. +var vnWeekdays = [...]string{"CN", "T2", "T3", "T4", "T5", "T6", "T7"} + +// formatToday renders current conditions plus today's daily summary. +func formatToday(p place, f forecast) string { + c := f.Current + cur := conditionFor(c.WeatherCode) + var sb strings.Builder + fmt.Fprintf(&sb, "%s Thời tiết hôm nay %s — %s\n", conditionFor(f.Daily.WeatherCode[0]).emoji, + shortDate(f.Daily.Time[0]), displayName(p)) + fmt.Fprintf(&sb, "Hiện tại: %s (cảm giác %s), %s %s\n", temp(c.Temperature), temp(c.ApparentTemperature), + cur.label, cur.emoji) + fmt.Fprintf(&sb, "Độ ẩm %d%%, gió %s km/h\n", round(c.Humidity), decimal(c.WindSpeed)) + writeDay(&sb, "Cả ngày", f.Daily, 0) + sb.WriteString(sourceLine) + return sb.String() +} + +// formatTomorrow renders tomorrow's daily summary. +func formatTomorrow(p place, f forecast) string { + d := f.Daily + var sb strings.Builder + fmt.Fprintf(&sb, "%s Thời tiết ngày mai %s %s — %s\n", conditionFor(d.WeatherCode[1]).emoji, + weekday(d.Time[1]), shortDate(d.Time[1]), displayName(p)) + writeDay(&sb, "Dự báo", d, 1) + sb.WriteString(sourceLine) + return sb.String() +} + +// formatWeek renders one line per day for every day in the forecast. +func formatWeek(p place, f forecast) string { + d := f.Daily + n := d.days() + var sb strings.Builder + fmt.Fprintf(&sb, "📅 Thời tiết %d ngày tới — %s\n", n, displayName(p)) + for i := range n { + c := conditionFor(d.WeatherCode[i]) + fmt.Fprintf(&sb, "%s %s: %s–%s %s %s, mưa %d%%\n", weekday(d.Time[i]), shortDate(d.Time[i]), + tempValue(d.TemperatureMin[i]), temp(d.TemperatureMax[i]), c.emoji, c.label, + round(d.PrecipitationProbability[i])) + } + sb.WriteString(sourceLine) + return sb.String() +} + +// writeDay renders the daily lines shared by the today and tomorrow views. +func writeDay(sb *strings.Builder, label string, d dailyWeather, i int) { + c := conditionFor(d.WeatherCode[i]) + fmt.Fprintf(sb, "%s: %s–%s, %s %s\n", label, tempValue(d.TemperatureMin[i]), temp(d.TemperatureMax[i]), + c.label, c.emoji) + fmt.Fprintf(sb, "Khả năng mưa %d%% (%s mm), UV %s\n", round(d.PrecipitationProbability[i]), + decimal(d.PrecipitationSum[i]), decimal(d.UVIndexMax[i])) + fmt.Fprintf(sb, "Mặt trời mọc %s, lặn %s\n", clock(d.Sunrise[i]), clock(d.Sunset[i])) +} + +func round(v float64) int { return int(math.Round(v)) } + +func tempValue(v float64) string { return strconv.Itoa(round(v)) } + +func temp(v float64) string { return tempValue(v) + "°C" } + +// decimal renders one decimal place with the Vietnamese comma separator. +func decimal(v float64) string { + return strings.Replace(strconv.FormatFloat(v, 'f', 1, 64), ".", ",", 1) +} + +// shortDate turns Open-Meteo's "2026-10-01" into "01/10". +func shortDate(iso string) string { + t, err := time.Parse(time.DateOnly, iso) + if err != nil { + return iso + } + return t.Format("02/01") +} + +func weekday(iso string) string { + t, err := time.Parse(time.DateOnly, iso) + if err != nil { + return "" + } + return vnWeekdays[t.Weekday()] +} + +// clock turns Open-Meteo's local "2026-10-01T05:42" into "05:42". +func clock(iso string) string { + t, err := time.Parse("2006-01-02T15:04", iso) + if err != nil { + return iso + } + return t.Format("15:04") +} diff --git a/internal/modules/thoitiet/location.go b/internal/modules/thoitiet/location.go new file mode 100644 index 0000000..e44e824 --- /dev/null +++ b/internal/modules/thoitiet/location.go @@ -0,0 +1,88 @@ +package thoitiet + +import ( + "strings" + "unicode" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" +) + +// hcmPlace is the default location. Its coordinates are Open-Meteo's own +// geocoding result for the city, so the default needs no geocoding request. +var hcmPlace = place{ + Name: "Thành phố Hồ Chí Minh", + Latitude: 10.82302, + Longitude: 106.62965, + CountryCode: "VN", + Country: "Việt Nam", +} + +// hcmAliases are normalized spellings that mean Ho Chi Minh City. Geocoding +// finds nothing for the short forms and a Bình Thạnh ward for "saigon". +var hcmAliases = map[string]bool{ + "hcm": true, + "tphcm": true, + "tp hcm": true, + "tp.hcm": true, + "sg": true, + "saigon": true, + "sai gon": true, + "ho chi minh": true, + "tp ho chi minh": true, + "thanh pho ho chi minh": true, +} + +// queryAliases expands common short forms the geocoder does not know. +var queryAliases = map[string]string{ + "hn": "Ha Noi", + "dn": "Da Nang", +} + +// stripMarks removes combining marks after NFD decomposition, which drops +// Vietnamese tone and vowel marks ("Lạt" -> "Lat"). +var stripMarks = transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + +// normalizeQuery lowercases a location, removes Vietnamese diacritics, and +// collapses whitespace. Đ is not a decomposable letter, so it is mapped by +// hand, together with the look-alike Ð (U+00D0) that GeoNames uses in names +// such as "Ðà Lạt"; leaving either in place makes the geocoder miss the city. +func normalizeQuery(s string) string { + s = strings.NewReplacer("Đ", "D", "đ", "d", "Ð", "D", "ð", "d").Replace(s) + if stripped, _, err := transform.String(stripMarks, s); err == nil { + s = stripped + } + return strings.Join(strings.Fields(strings.ToLower(s)), " ") +} + +// pickPlace prefers the first Vietnamese match, since most lookups are for +// Vietnamese places and the geocoder may rank a same-named place abroad first, +// and otherwise falls back to the top result. +func pickPlace(results []place) (place, bool) { + for _, p := range results { + if p.CountryCode == "VN" { + return p, true + } + } + if len(results) == 0 { + return place{}, false + } + return results[0], true +} + +// displayName labels a place with its province when that adds information, +// and with its country when it is outside Vietnam. The province is skipped +// when it only respells the name, as in "Hà Nội" with admin1 "Hanoi". +func displayName(p place) string { + parts := []string{p.Name} + if p.Admin1 != "" && compactName(p.Admin1) != compactName(p.Name) { + parts = append(parts, p.Admin1) + } + if p.CountryCode != "VN" && p.Country != "" { + parts = append(parts, p.Country) + } + return strings.Join(parts, ", ") +} + +func compactName(s string) string { return strings.ReplaceAll(normalizeQuery(s), " ", "") } diff --git a/internal/modules/thoitiet/location_test.go b/internal/modules/thoitiet/location_test.go new file mode 100644 index 0000000..9d5871b --- /dev/null +++ b/internal/modules/thoitiet/location_test.go @@ -0,0 +1,56 @@ +package thoitiet + +import "testing" + +func TestNormalizeQuery(t *testing.T) { + cases := map[string]string{ + "": "", + " Đà Lạt ": "da lat", + "Ðà Lạt": "da lat", // GeoNames' look-alike Ð (U+00D0) + "Hồ Chí Minh": "ho chi minh", + "Thừa Thiên Huế": "thua thien hue", + "Sài Gòn": "sai gon", + "TP.HCM": "tp.hcm", + "New York": "new york", + "Buôn Ma Thuột": "buon ma thuot", + "Phan Rang–Tháp Chàm": "phan rang–thap cham", + } + for in, want := range cases { + if got := normalizeQuery(in); got != want { + t.Errorf("normalizeQuery(%q) = %q, want %q", in, got, want) + } + } +} + +func TestPickPlace(t *testing.T) { + jp := place{Name: "Tokyo", CountryCode: "JP"} + vn := place{Name: "Huế", CountryCode: "VN"} + et := place{Name: "Humera", CountryCode: "ET"} + + if got, ok := pickPlace([]place{et, vn}); !ok || got != vn { + t.Errorf("pickPlace prefers VN: got %+v, %v", got, ok) + } + if got, ok := pickPlace([]place{jp, et}); !ok || got != jp { + t.Errorf("pickPlace falls back to first: got %+v, %v", got, ok) + } + if _, ok := pickPlace(nil); ok { + t.Error("pickPlace(nil) ok = true, want false") + } +} + +func TestDisplayName(t *testing.T) { + cases := []struct { + p place + want string + }{ + {hcmPlace, "Thành phố Hồ Chí Minh"}, + {place{Name: "Hà Nội", Admin1: "Hanoi", CountryCode: "VN"}, "Hà Nội"}, + {place{Name: "Vũng Tàu", Admin1: "Thành phố Hồ Chí Minh", CountryCode: "VN"}, "Vũng Tàu, Thành phố Hồ Chí Minh"}, + {place{Name: "Tokyo", Admin1: "Tokyo", CountryCode: "JP", Country: "Nhật Bản"}, "Tokyo, Nhật Bản"}, + } + for _, c := range cases { + if got := displayName(c.p); got != c.want { + t.Errorf("displayName(%+v) = %q, want %q", c.p, got, c.want) + } + } +} diff --git a/internal/modules/thoitiet/thoitiet.go b/internal/modules/thoitiet/thoitiet.go new file mode 100644 index 0000000..01eb986 --- /dev/null +++ b/internal/modules/thoitiet/thoitiet.go @@ -0,0 +1,116 @@ +// 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 + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + locationParameter = "[location...]" + fetchErrorText = "Không lấy được dữ liệu thời tiết. Thử lại sau nhé." +) + +// 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. +type view struct { + command string + minDays int + render func(place, forecast) string +} + +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} +) + +// New is the thoitiet module Factory. The module keeps no state. +func New(_ modules.Deps) modules.Module { + client := &http.Client{Timeout: httpTimeout} + command := func(name, description string, v view) modules.Command { + return modules.Command{ + Name: name, + Visibility: modules.VisibilityPublic, + Description: description, + Parameters: locationParameter, + Handler: handler(client, v), + } + } + 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("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), + }, + } +} + +func handler(client *http.Client, v view) modules.CommandHandler { + return func(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + query := chathelper.ArgAfterCommand(msg.Text) + fetchCtx, cancel := chathelper.FetchContext(ctx) + p, f, err := lookup(fetchCtx, client, query) + cancel() + 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 { + log.Error("weather fetch failed", "module", "thoitiet", "command", v.command, "err", err) + return chathelper.Reply(ctx, b, msg, fetchErrorText) + } + return chathelper.Reply(ctx, b, msg, v.render(p, f)) + } +} + +// lookup resolves the user's location and fetches its forecast. An empty +// query or a Ho Chi Minh City alias skips geocoding. +func lookup(ctx context.Context, client *http.Client, query string) (place, forecast, error) { + p, err := resolvePlace(ctx, client, query) + if err != nil { + return place{}, forecast{}, err + } + f, err := fetchForecast(ctx, client, p) + return p, f, err +} + +func resolvePlace(ctx context.Context, client *http.Client, query string) (place, error) { + q := normalizeQuery(query) + if q == "" || hcmAliases[q] { + return hcmPlace, nil + } + if expanded, ok := queryAliases[q]; ok { + q = expanded + } + results, err := geocode(ctx, client, q) + if err != nil { + return place{}, err + } + p, ok := pickPlace(results) + if !ok { + return place{}, errPlaceNotFound + } + return p, nil +} diff --git a/internal/modules/thoitiet/thoitiet_test.go b/internal/modules/thoitiet/thoitiet_test.go new file mode 100644 index 0000000..cef18d7 --- /dev/null +++ b/internal/modules/thoitiet/thoitiet_test.go @@ -0,0 +1,250 @@ +package thoitiet + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// 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}, + "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], + "temperature_2m_max":[32.7,31.8,32.0,32.2,32.5,31.1,31.1], + "temperature_2m_min":[24.3,23.9,24.2,23.7,24.5,24.0,23.9], + "precipitation_sum":[5.30,7.90,7.00,5.70,13.90,4.00,7.60], + "precipitation_probability_max":[70,88,85,92,98,100,89], + "uv_index_max":[8.90,8.20,8.60,8.40,8.85,7.80,8.70], + "sunrise":["2026-10-01T05:42","2026-10-02T05:42","2026-10-03T05:42","2026-10-04T05:42","2026-10-05T05:42","2026-10-06T05:42","2026-10-07T05:42"], + "sunset":["2026-10-01T17:44","2026-10-02T17:43","2026-10-03T17:42","2026-10-04T17:42","2026-10-05T17:41","2026-10-06T17:40","2026-10-07T17:40"] + } +}` + +const daLatGeocodeFixture = `{"results":[ + {"name":"Ðà Lạt","latitude":11.94646,"longitude":108.44193,"country_code":"VN","country":"Việt Nam","admin1":"Lam Dong"} +]}` + +// fakeOpenMeteo serves both Open-Meteo endpoints for the duration of t and +// records the geocoding queries it received. +type fakeOpenMeteo struct { + geocodeBody string + forecastBody string + forecastStatus int + geocodeQueries []string + forecastCalls atomic.Int32 + lastForecastQ map[string]string +} + +func stubOpenMeteo(t *testing.T, f *fakeOpenMeteo) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/search": + f.geocodeQueries = append(f.geocodeQueries, r.URL.Query().Get("name")) + _, _ = w.Write([]byte(f.geocodeBody)) + case "/forecast": + f.forecastCalls.Add(1) + f.lastForecastQ = map[string]string{} + for k := range r.URL.Query() { + f.lastForecastQ[k] = r.URL.Query().Get(k) + } + if f.forecastStatus != 0 { + http.Error(w, "unavailable", f.forecastStatus) + return + } + _, _ = w.Write([]byte(f.forecastBody)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + origGeocode, origForecast := geocodeURL, forecastURL + geocodeURL, forecastURL = server.URL+"/search", server.URL+"/forecast" + t.Cleanup(func() { geocodeURL, forecastURL = origGeocode, origForecast }) +} + +func installThoitiet(t *testing.T) *testutil.RecordingBot { + t.Helper() + rb := testutil.NewRecordingBot(t) + mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("thoitiet")}) + reg := &modules.Registry{ + Modules: []modules.Module{{Name: "thoitiet", Commands: mod.Commands}}, + AllCommands: map[string]modules.Command{}, + } + for _, c := range mod.Commands { + reg.AllCommands[c.Name] = c + } + modules.Install(rb.Bot, reg, modules.Auth{}) + return rb +} + +func send(rb *testutil.RecordingBot, text string) string { + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + return rb.LastSent().Text() +} + +func TestCommands_RegistrationAndParameters(t *testing.T) { + mod := New(modules.Deps{}) + want := []string{"thoitiethomnay", "thoitiet", "thoitietngaymai", "thoitiettuannay"} + if len(mod.Commands) != len(want) { + t.Fatalf("commands = %d, want %d", len(mod.Commands), len(want)) + } + for i, c := range mod.Commands { + if c.Name != want[i] { + t.Errorf("commands[%d] = %q, want %q", i, c.Name, want[i]) + } + if c.Parameters != "[location...]" { + t.Errorf("/%s parameters = %q", c.Name, c.Parameters) + } + if c.Visibility != modules.VisibilityPublic { + t.Errorf("/%s is not public", c.Name) + } + } +} + +func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) { + f := &fakeOpenMeteo{forecastBody: forecastFixture} + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + want := "🌦️ Thời tiết hôm nay 01/10 — Thành phố Hồ Chí Minh\n" + + "Hiện tại: 29°C (cảm giác 36°C), Nhiều mây ☁️\n" + + "Độ ẩm 78%, gió 3,2 km/h\n" + + "Cả ngày: 24–33°C, Mưa rào nhẹ 🌦️\n" + + "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"} { + if got := send(rb, cmd); got != want { + t.Errorf("%s reply =\n%s\nwant\n%s", cmd, got, want) + } + } + if len(f.geocodeQueries) != 0 { + 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" { + t.Errorf("forecast query = %v", f.lastForecastQ) + } +} + +func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) { + f := &fakeOpenMeteo{geocodeBody: daLatGeocodeFixture, forecastBody: forecastFixture} + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + got := send(rb, "/thoitietngaymai Đà Lạt ") + want := "🌦️ Thời tiết ngày mai T6 02/10 — Ðà Lạt, Lam Dong\n" + + "Dự báo: 24–32°C, Mưa rào nhẹ 🌦️\n" + + "Khả năng mưa 88% (7,9 mm), UV 8,2\n" + + "Mặt trời mọc 05:42, lặn 17:43\n" + + "Nguồn: Open-Meteo" + if got != want { + t.Errorf("reply =\n%s\nwant\n%s", got, want) + } + if len(f.geocodeQueries) != 1 || f.geocodeQueries[0] != "da lat" { + t.Errorf("geocode queries = %q, want [\"da lat\"]", f.geocodeQueries) + } + if f.lastForecastQ["latitude"] != "11.94646" { + t.Errorf("forecast latitude = %q", f.lastForecastQ["latitude"]) + } +} + +func TestWeek_ListsSevenDays(t *testing.T) { + f := &fakeOpenMeteo{forecastBody: forecastFixture} + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + want := "📅 Thời tiết 7 ngày tới — Thành phố Hồ Chí Minh\n" + + "T5 01/10: 24–33°C 🌦️ Mưa rào nhẹ, mưa 70%\n" + + "T6 02/10: 24–32°C 🌦️ Mưa rào nhẹ, mưa 88%\n" + + "T7 03/10: 24–32°C 🌦️ Mưa rào, mưa 85%\n" + + "CN 04/10: 24–32°C ⛈️ Dông, mưa 92%\n" + + "T2 05/10: 25–33°C ⛈️ Dông, mưa 98%\n" + + "T3 06/10: 24–31°C ⛈️ Dông, mưa 100%\n" + + "T4 07/10: 24–31°C 🌦️ Mưa phùn, mưa 89%\n" + + "Nguồn: Open-Meteo" + if got := send(rb, "/thoitiettuannay"); got != want { + t.Errorf("reply =\n%s\nwant\n%s", got, want) + } +} + +func TestAliasExpansionAndForeignPlace(t *testing.T) { + f := &fakeOpenMeteo{ + geocodeBody: `{"results":[ + {"name":"Tokyo","latitude":35.6895,"longitude":139.69171,"country_code":"JP","country":"Nhật Bản","admin1":"Tokyo"} + ]}`, + forecastBody: forecastFixture, + } + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + got := send(rb, "/thoitiettuannay Tokyo") + if !strings.HasPrefix(got, "📅 Thời tiết 7 ngày tới — Tokyo, Nhật Bản\n") { + t.Errorf("reply header = %q", strings.SplitN(got, "\n", 2)[0]) + } + send(rb, "/thoitiet HN") + if want := []string{"tokyo", "Ha Noi"}; strings.Join(f.geocodeQueries, "|") != strings.Join(want, "|") { + t.Errorf("geocode queries = %q, want %q", f.geocodeQueries, want) + } +} + +func TestUnknownLocation(t *testing.T) { + f := &fakeOpenMeteo{geocodeBody: `{"generationtime_ms":0.3}`} + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + if got, want := send(rb, "/thoitiet xyzzy"), `Không tìm thấy địa điểm "xyzzy".`; got != want { + t.Errorf("reply = %q, want %q", got, want) + } + if n := f.forecastCalls.Load(); n != 0 { + t.Errorf("forecast calls = %d, want 0", n) + } +} + +func TestUpstreamFailureRepliesError(t *testing.T) { + cases := map[string]*fakeOpenMeteo{ + "forecast non-200": {forecastStatus: http.StatusServiceUnavailable}, + "forecast bad json": {forecastBody: ""}, + "no daily rows": {forecastBody: `{"daily":{"time":[]}}`}, + "geocode bad json": {geocodeBody: ""}, + } + for name, f := range cases { + t.Run(name, func(t *testing.T) { + stubOpenMeteo(t, f) + rb := installThoitiet(t) + cmd := "/thoitiet" + if f.geocodeBody != "" { + cmd = "/thoitiet Hue" + } + if got := send(rb, cmd); got != fetchErrorText { + t.Errorf("reply = %q, want %q", got, fetchErrorText) + } + }) + } +} + +func TestTomorrow_NeedsTwoDailyRows(t *testing.T) { + oneDay := strings.NewReplacer( + `,"2026-10-02","2026-10-03","2026-10-04","2026-10-05","2026-10-06","2026-10-07"`, "", + ).Replace(forecastFixture) + f := &fakeOpenMeteo{forecastBody: oneDay} + stubOpenMeteo(t, f) + rb := installThoitiet(t) + + if got := send(rb, "/thoitietngaymai"); got != fetchErrorText { + t.Errorf("reply = %q, want %q", got, fetchErrorText) + } + if got := send(rb, "/thoitiettuannay"); !strings.Contains(got, "Thời tiết 1 ngày tới") { + t.Errorf("week reply = %q, want a one-day list", got) + } +} diff --git a/plans/261001-0925-thoitiet-weather-module/plan.md b/plans/261001-0925-thoitiet-weather-module/plan.md new file mode 100644 index 0000000..ba1bad2 --- /dev/null +++ b/plans/261001-0925-thoitiet-weather-module/plan.md @@ -0,0 +1,28 @@ +# Plan: `thoitiet` weather module + +Status: done (2026-10-01). Design and decisions: +[research report](../reports/research-261001-0900-thoitiet-weather-module.md). + +## Outcome + +Public commands `/thoitiethomnay` (alias `/thoitiet`), `/thoitietngaymai`, and +`/thoitiettuannay`, each taking `[location...]` and defaulting to Ho Chi Minh +City, backed by Open-Meteo geocoding and forecast APIs. + +## Steps + +1. `internal/modules/thoitiet`: `thoitiet.go` (registration + handlers), + `api_client.go` (geocode + forecast), `location.go` (diacritic stripping, + aliases, result choice), `format.go` (WMO labels, three renderers). +2. Tests for location parsing, formatting, and handlers against `httptest`. +3. Wire `"thoitiet": thoitiet.New` in `cmd/server/main.go`; pin the new + parameter strings in `cmd/server/command_menu_test.go`. +4. README module table row. + +## Acceptance criteria + +- No argument shows Ho Chi Minh City without a geocoding request. +- `Đà Lạt`, `Da Lat`, `hcm`, `saigon`, and foreign names like `Tokyo` resolve. +- `/thoitiettuannay` lists 7 days starting today in the location's timezone. +- Unknown location and upstream failure reply with Vietnamese error text. +- `go test ./...`, `go vet ./...`, and `golangci-lint run` pass. diff --git a/plans/reports/research-261001-0900-thoitiet-weather-module.md b/plans/reports/research-261001-0900-thoitiet-weather-module.md new file mode 100644 index 0000000..b213b11 --- /dev/null +++ b/plans/reports/research-261001-0900-thoitiet-weather-module.md @@ -0,0 +1,123 @@ +# Research + Brainstorm: `thoitiet` weather module + +Conducted 2026-10-01. Scope: weather data source and command design for a new +`thoitiet` module with today, tomorrow, and this-week forecasts, an optional +location argument, and Ho Chi Minh City as the default. + +## Recommendation + +Build a new `internal/modules/thoitiet` module on **Open-Meteo** (forecast + +geocoding). It needs no API key, no new env var, and no storage. All payload +fields below were verified with live requests on 2026-10-01. + +## Commands + +| Command | Parameters | Behaviour | +|---|---|---| +| `/thoitiethomnay` | `[location...]` | Current conditions + today's forecast | +| `/thoitiet` | `[location...]` | Alias of `/thoitiethomnay` (same handler) | +| `/thoitietngaymai` | `[location...]` | Tomorrow's daily forecast | +| `/thoitiettuannay` | `[location...]` | One line per day for the next 7 days, starting today | + +`[location...]` follows `docs/command-parameter-conventions.md` (optional +remaining text, so `/thoitiet Đà Lạt` works). An empty argument means Ho Chi +Minh City. The alias is registered as a second `modules.Command` sharing the +handler, the same pattern as `/trongtruonghop` + `/tth` in `misc`. Stats will +count the two names separately, which matches that existing alias. + +## Data source comparison + +| Source | Key | Free limit | VN geocoding | Verdict | +|---|---|---|---|---| +| Open-Meteo | None | 10,000/day, 5,000/h, 600/min, non-commercial | Yes, `language=vi` returns Vietnamese names | **Pick** | +| OpenWeatherMap | Required | 1,000/day (One Call 3.0) | Yes | Needs secret + card on file | +| WeatherAPI.com | Required | 1M/month | Yes, `lang=vi` | Needs secret; no benefit here | + +A personal Telegram bot is non-commercial and nowhere near the limits, so +Open-Meteo is fine without caching. Its data is CC BY 4.0, so replies end with a +short `Nguồn: Open-Meteo` credit. + +## Verified API behaviour + +Geocoding: `GET https://geocoding-api.open-meteo.com/v1/search?name=&count=10&language=vi` + +| Query | Result | +|---|---| +| `Ho Chi Minh`, `Hồ Chí Minh` | Thành phố Hồ Chí Minh (10.823, 106.630) | +| `Ha Noi`, `Hanoi` | Hà Nội | +| `Da Lat` | Ðà Lạt | +| `Đà Lạt` (typed with diacritics) | **Wrong: "Đã Tịch", Quảng Trị** | +| `Da Nang`, `Can Tho`, `Nha Trang`, `Hue`, `Vung Tau`, `Tokyo` | Correct | +| `hcm`, `Thu Duc` | No results | +| `Saigon` | A ward in Bình Thạnh, not the city | + +Two consequences for the design. First, strip Vietnamese diacritics (including +`Đ/đ` → `D/d`) before querying, because GeoNames stores Đà Lạt with the +look-alike `Ð` (U+00D0) and diacritic input misses it. Second, keep a small +alias map for common shorthand: `hcm`, `tphcm`, `sg`, `saigon`, `sai gon` → HCM; +`hn` → Hà Nội; `dn` → Đà Nẵng. Pick the first `country_code=VN` result, else the +first result, so foreign cities still work in a single request. + +Forecast: `GET https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&timezone=auto&forecast_days=7` +with + +- `current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m` +- `daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunrise,sunset` + +`timezone=auto` makes "today" the location's own date. HCM returns +`utc_offset_seconds=25200`. Weather is a WMO code that maps to a Vietnamese +label plus emoji (about 28 codes, for example 0 Trời quang ☀️, 3 Nhiều mây ☁️, +61–65 Mưa 🌧️, 80–82 Mưa rào 🌦️, 95–99 Dông ⛈️). + +## Design + +```text +handler(range) ── parse args ──► resolveLocation(q) + ├─ empty → HCM constant (no geocoding call) + ├─ alias map → constant + └─ geocode(stripDiacritics(q)) + ──► fetchForecast(lat, lon) ──► format(range) ──► send HTML +``` + +Files, mirroring `lol` and `giaxang`: + +- `thoitiet.go`: `New(deps)` and the four command registrations. +- `api_client.go`: geocode + forecast with an `*http.Client` timeout, + `io.LimitReader`, and package-level URL vars so tests can use `httptest`. +- `location.go`: diacritic stripping, alias map, result selection. +- `format.go`: WMO table and the today/tomorrow/week renderers. +- `handlers.go` and tests for each file. +- Wiring: `cmd/server/main.go` catalog entry `"thoitiet": thoitiet.New`, the + expected `cmd/server/command_menu_test.go` entries, and the README module table. + +Sample `/thoitiet` reply: + +```text +🌤 Thời tiết hôm nay — Thành phố Hồ Chí Minh +Hiện tại: 29°C (cảm giác 35.7°C), Nhiều mây ☁️, độ ẩm 78%, gió 3 km/h +Cả ngày: 24–33°C, Mưa rào 🌦️, khả năng mưa 70% (5.3 mm), UV 8.9 +Mặt trời: 05:42 – 17:44 +Nguồn: Open-Meteo +``` + +Errors: unknown location → `Không tìm thấy địa điểm "".`; upstream failure → +`Không lấy được dữ liệu thời tiết. Thử lại sau nhé.` (same tone as `/giaxang`). + +## Rejected options + +- Caching forecasts in Mongo: the limits make it unnecessary, and it adds a + storage schema for no user-visible gain. +- A per-user saved default location: not requested. +- A keyed provider (OWM, WeatherAPI): adds a secret and setup for no gain. + +## Next steps + +1. Write the plan under `plans/261001-…-thoitiet-weather-module/` and implement. +2. Run `go test ./internal/modules/thoitiet/... ./cmd/server/...`, `go vet ./...`, + and `golangci-lint run`. + +## Decisions + +- `/thoitiet` is an alias of `/thoitiethomnay` (user request, 2026-10-01). +- `/thoitiettuannay` covers the next 7 days starting today, not the Mon–Sun + calendar week (user choice, 2026-10-01).