From 9801a64e18e5770b5b4c39f6b7ed5c3d459e4a90 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 8 Oct 2026 17:02:05 +0700 Subject: [PATCH] =?UTF-8?q?feat(weather):=20default=20to=20T=C3=A2n=20Thu?= =?UTF-8?q?=E1=BA=ADn=20and=20accept=20lat,long=20coordinates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forecast commands without a location now use phường Tân Thuận (formerly Quận 7), shared with the flood view, instead of the Ho Chi Minh City centre; city aliases still resolve to the city. A decimal "lat,long" argument skips geocoding and is used directly. --- README.md | 2 +- cmd/server/command_menu_test.go | 8 +-- internal/modules/weather/flood.go | 12 ----- internal/modules/weather/location.go | 39 ++++++++++++++- internal/modules/weather/location_test.go | 27 ++++++++++ internal/modules/weather/weather.go | 32 +++++++----- internal/modules/weather/weather_test.go | 61 ++++++++++++++++++----- 7 files changed, 137 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4c3cc23..417a371 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,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 | -| `weather` | 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. Flood alerts for Tân Thuận (Q.7): `/thuyvan` shows the 5-day tide-peak forecast at Phú An and Nhà Bè (Đài KTTV Nam Bộ bulletin), the rain forecast, and live VNDMS river gauges within 30 km; `/thuyvan_subscribe` / `/thuyvan_unsubscribe` toggle a 10:30 ICT push (retried at 12:30 if that run fails) sent only when a forecast peak reaches báo động I (1.40 m) or rain reaches 50 mm | +| `weather` | 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... | lat,long]` (Vietnamese with or without diacritics, shorthand like `hcm`/`hn`, a foreign city, or decimal coordinates such as `10.74,106.72`); the default is Tân Thuận (formerly Q.7). Flood alerts for Tân Thuận (Q.7): `/thuyvan` shows the 5-day tide-peak forecast at Phú An and Nhà Bè (Đài KTTV Nam Bộ bulletin), the rain forecast, and live VNDMS river gauges within 30 km; `/thuyvan_subscribe` / `/thuyvan_unsubscribe` toggle a 10:30 ICT push (retried at 12:30 if that run fails) sent only when a forecast peak reaches báo động I (1.40 m) or rain reaches 50 mm | 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 04c3fb5..b7bacd4 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -96,10 +96,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "stock_sell": " ", "stock_cash_dividend": " ", "stock_share_dividend": " ", - "thoitiet": "[location...]", - "thoitiethomnay": "[location...]", - "thoitietngaymai": "[location...]", - "thoitiettuannay": "[location...]", + "thoitiet": "[location... | lat,long]", + "thoitiethomnay": "[location... | lat,long]", + "thoitietngaymai": "[location... | lat,long]", + "thoitiettuannay": "[location... | lat,long]", "trongtruonghop": "[target...]", "tth": "[target...]", "wheelofnames": "", diff --git a/internal/modules/weather/flood.go b/internal/modules/weather/flood.go index f900206..d5e8621 100644 --- a/internal/modules/weather/flood.go +++ b/internal/modules/weather/flood.go @@ -9,18 +9,6 @@ import ( "time" ) -// tanThuanPlace is the flood view's fixed location: phường Tân Thuận, Quận 7. -// The coordinates are Open-Meteo's geocoding result for "Tân Thuận" in Quận -// Bảy; geocoding the name at runtime returns 14 Vietnamese places. -var tanThuanPlace = place{ - Name: "Tân Thuận", - Latitude: 10.74111, - Longitude: 106.71806, - CountryCode: "VN", - Country: "Việt Nam", - Admin1: "Thành phố Hồ Chí Minh", -} - const ( // floodDays is the window the flood view and alert cover, starting today; // it matches the tide bulletin's 5-day forecast. diff --git a/internal/modules/weather/location.go b/internal/modules/weather/location.go index 9275dfc..fb93c7c 100644 --- a/internal/modules/weather/location.go +++ b/internal/modules/weather/location.go @@ -1,6 +1,8 @@ package weather import ( + "regexp" + "strconv" "strings" "unicode" @@ -9,8 +11,22 @@ import ( "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. +// tanThuanPlace is the default location for every forecast command and the +// flood view's fixed location: phường Tân Thuận, formerly in Quận 7. The +// coordinates are Open-Meteo's geocoding result for "Tân Thuận" in Quận Bảy; +// geocoding the name at runtime returns 14 Vietnamese places. +var tanThuanPlace = place{ + Name: "Tân Thuận", + Latitude: 10.74111, + Longitude: 106.71806, + CountryCode: "VN", + Country: "Việt Nam", + Admin1: "Thành phố Hồ Chí Minh", +} + +// hcmPlace is the location for a Ho Chi Minh City alias. Its coordinates are +// Open-Meteo's own geocoding result for the city, so it needs no geocoding +// request. var hcmPlace = place{ Name: "Thành phố Hồ Chí Minh", Latitude: 10.82302, @@ -40,6 +56,25 @@ var queryAliases = map[string]string{ "dn": "Da Nang", } +// coordsPattern matches "lat,long" in decimal degrees, separated by a comma, +// a semicolon, or whitespace: "10.74,106.72", "10.74, 106.72", "10.74 106.72". +var coordsPattern = regexp.MustCompile(`^([+-]?\d{1,3}(?:\.\d+)?)\s*[,;\s]\s*([+-]?\d{1,3}(?:\.\d+)?)$`) + +// parseCoords reads a "lat,long" query as a place named by its coordinates. +// Open-Meteo has no reverse geocoding, so the coordinates are the label. +func parseCoords(query string) (place, bool) { + m := coordsPattern.FindStringSubmatch(strings.TrimSpace(query)) + if m == nil { + return place{}, false + } + lat, errLat := strconv.ParseFloat(m[1], 64) + lon, errLon := strconv.ParseFloat(m[2], 64) + if errLat != nil || errLon != nil || lat < -90 || lat > 90 || lon < -180 || lon > 180 { + return place{}, false + } + return place{Name: m[1] + ", " + m[2], Latitude: lat, Longitude: lon}, true +} + // 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) diff --git a/internal/modules/weather/location_test.go b/internal/modules/weather/location_test.go index 8b4dae1..5cfe3be 100644 --- a/internal/modules/weather/location_test.go +++ b/internal/modules/weather/location_test.go @@ -22,6 +22,32 @@ func TestNormalizeQuery(t *testing.T) { } } +func TestParseCoords(t *testing.T) { + valid := map[string][2]float64{ + "10.74111,106.71806": {10.74111, 106.71806}, + " 10.74, 106.72 ": {10.74, 106.72}, + "10.74 106.72": {10.74, 106.72}, + "10.74;106.72": {10.74, 106.72}, + "-33.8688, 151.2093": {-33.8688, 151.2093}, + "+40.7128 -74.0060": {40.7128, -74.006}, + "0,0": {0, 0}, + } + for in, want := range valid { + p, ok := parseCoords(in) + if !ok || p.Latitude != want[0] || p.Longitude != want[1] { + t.Errorf("parseCoords(%q) = %+v, %v, want %v", in, p, ok, want) + } + } + if p, _ := parseCoords(" 10.74 , 106.72 "); p.Name != "10.74, 106.72" { + t.Errorf("parseCoords name = %q", p.Name) + } + for _, in := range []string{"", "hcm", "10.74", "91,0", "0,181", "10.74,106.72,5", "10,74 106,72", "Quận 7"} { + if p, ok := parseCoords(in); ok { + t.Errorf("parseCoords(%q) = %+v, want no match", in, p) + } + } +} + func TestPickPlace(t *testing.T) { jp := place{Name: "Tokyo", CountryCode: "JP"} vn := place{Name: "Huế", CountryCode: "VN"} @@ -44,6 +70,7 @@ func TestDisplayName(t *testing.T) { want string }{ {hcmPlace, "Thành phố Hồ Chí Minh"}, + {tanThuanPlace, "Tân Thuận, 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"}, diff --git a/internal/modules/weather/weather.go b/internal/modules/weather/weather.go index 8a3d643..e1b0511 100644 --- a/internal/modules/weather/weather.go +++ b/internal/modules/weather/weather.go @@ -1,11 +1,12 @@ // Package weather is the weather and flood 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; that data comes from Open-Meteo, -// which needs no API key. /thuyvan shows the flood risk at Tân Thuận (Quận 7) -// from the KTTV Nam Bộ tide bulletin, the Open-Meteo rain forecast and VNDMS -// river gauges, and /thuyvan_subscribe opts a chat into a daily alert sent -// only when a tide or rain threshold is forecast. +// a location or "lat,long" coordinates, Tân Thuận (formerly Quận 7) by +// default; that data comes from Open-Meteo, which needs no API key. /thuyvan +// shows the flood risk at Tân Thuận from the KTTV Nam Bộ tide bulletin, the +// Open-Meteo rain forecast and VNDMS river gauges, and /thuyvan_subscribe +// opts a chat into a daily alert sent only when a tide or rain threshold is +// forecast. package weather import ( @@ -25,7 +26,7 @@ import ( ) const ( - locationParameter = "[location...]" + locationParameter = "[location... | lat,long]" fetchErrorText = "Không lấy được dữ liệu thời tiết. Thử lại sau nhé." ) @@ -76,10 +77,10 @@ func New(deps modules.Deps) modules.Module { } return modules.Module{ Commands: append([]modules.Command{ - command("thoitiethomnay", "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), + command("thoitiethomnay", "Thời tiết hôm nay (mặc định Tân Thuận, Q.7)", todayView), + command("thoitiet", "Thời tiết từng giờ trong 6 giờ tới (mặc định Tân Thuận, Q.7)", hourlyView), + command("thoitietngaymai", "Thời tiết ngày mai (mặc định Tân Thuận, Q.7)", tomorrowView), + command("thoitiettuannay", "Thời tiết 7 ngày tới (mặc định Tân Thuận, Q.7)", weekView), }, fl.commands()...), Crons: fl.crons(), } @@ -110,7 +111,8 @@ func handler(client *http.Client, v view) modules.CommandHandler { } // lookup resolves the user's location and fetches its forecast. An empty -// query or a Ho Chi Minh City alias skips geocoding. +// query (Tân Thuận), "lat,long" coordinates, 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 { @@ -121,8 +123,14 @@ func lookup(ctx context.Context, client *http.Client, query string) (place, fore } func resolvePlace(ctx context.Context, client *http.Client, query string) (place, error) { + if p, ok := parseCoords(query); ok { + return p, nil + } q := normalizeQuery(query) - if q == "" || hcmAliases[q] { + if q == "" { + return tanThuanPlace, nil + } + if hcmAliases[q] { return hcmPlace, nil } if expanded, ok := queryAliases[q]; ok { diff --git a/internal/modules/weather/weather_test.go b/internal/modules/weather/weather_test.go index 6ec2643..1bcfe8b 100644 --- a/internal/modules/weather/weather_test.go +++ b/internal/modules/weather/weather_test.go @@ -105,10 +105,10 @@ func send(rb *testutil.RecordingBot, text string) string { func TestCommands_RegistrationAndParameters(t *testing.T) { mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(CollectionName)}) want := []struct{ name, parameters string }{ - {"thoitiethomnay", "[location...]"}, - {"thoitiet", "[location...]"}, - {"thoitietngaymai", "[location...]"}, - {"thoitiettuannay", "[location...]"}, + {"thoitiethomnay", "[location... | lat,long]"}, + {"thoitiet", "[location... | lat,long]"}, + {"thoitietngaymai", "[location... | lat,long]"}, + {"thoitiettuannay", "[location... | lat,long]"}, {"thuyvan", ""}, {"thuyvan_subscribe", ""}, {"thuyvan_unsubscribe", ""}, @@ -132,30 +132,65 @@ func TestCommands_RegistrationAndParameters(t *testing.T) { } } -func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) { +func TestToday_DefaultsToTanThuanWithoutGeocoding(t *testing.T) { f := &fakeOpenMeteo{forecastBody: forecastFixture} stubOpenMeteo(t, f) rb := installWeather(t) - want := "🌦️ Thời tiết hôm nay 01/10 — Thành phố Hồ Chí Minh\n" + + want := "🌦️ Thời tiết hôm nay 01/10 — Tân Thuận, 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", "/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) + if got := send(rb, "/thoitiethomnay"); got != want { + t.Errorf("reply =\n%s\nwant\n%s", got, want) + } + if len(f.geocodeQueries) != 0 { + t.Errorf("geocode called with %v, want no calls", f.geocodeQueries) + } + if f.lastForecastQ["latitude"] != "10.74111" || f.lastForecastQ["longitude"] != "106.71806" || + f.lastForecastQ["timezone"] != "auto" || + f.lastForecastQ["forecast_days"] != "7" || f.lastForecastQ["forecast_hours"] != "7" { + t.Errorf("forecast query = %v", f.lastForecastQ) + } +} + +func TestToday_HCMAliasSkipsGeocoding(t *testing.T) { + f := &fakeOpenMeteo{forecastBody: forecastFixture} + stubOpenMeteo(t, f) + rb := installWeather(t) + + for _, cmd := range []string{"/thoitiethomnay hcm", "/thoitiethomnay Sài Gòn"} { + got := send(rb, cmd) + if !strings.HasPrefix(got, "🌦️ Thời tiết hôm nay 01/10 — Thành phố Hồ Chí Minh\n") { + t.Errorf("%s reply header = %q", cmd, strings.SplitN(got, "\n", 2)[0]) + } + if f.lastForecastQ["latitude"] != "10.82302" { + t.Errorf("%s forecast latitude = %q", cmd, f.lastForecastQ["latitude"]) } } 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" || f.lastForecastQ["forecast_hours"] != "7" { +} + +func TestCoordinatesSkipGeocoding(t *testing.T) { + f := &fakeOpenMeteo{forecastBody: forecastFixture} + stubOpenMeteo(t, f) + rb := installWeather(t) + + got := send(rb, "/thoitiet 10.7769, 106.7009") + if !strings.HasPrefix(got, "🕐 Thời tiết 6 giờ tới — 10.7769, 106.7009\n") { + t.Errorf("reply header = %q", strings.SplitN(got, "\n", 2)[0]) + } + if f.lastForecastQ["latitude"] != "10.7769" || f.lastForecastQ["longitude"] != "106.7009" { t.Errorf("forecast query = %v", f.lastForecastQ) } + if len(f.geocodeQueries) != 0 { + t.Errorf("geocode called with %v, want no calls", f.geocodeQueries) + } } func TestHourly_ListsNextSixHours(t *testing.T) { @@ -163,7 +198,7 @@ func TestHourly_ListsNextSixHours(t *testing.T) { stubOpenMeteo(t, f) rb := installWeather(t) - want := "🕐 Thời tiết 6 giờ tới — Thành phố Hồ Chí Minh\n" + + want := "🕐 Thời tiết 6 giờ tới — Tân Thuận, 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" + @@ -220,7 +255,7 @@ func TestWeek_ListsSevenDays(t *testing.T) { stubOpenMeteo(t, f) rb := installWeather(t) - want := "📅 Thời tiết 7 ngày tới — Thành phố Hồ Chí Minh\n" + + want := "📅 Thời tiết 7 ngày tới — Tân Thuận, 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" +