feat(weather): default to Tân Thuận and accept lat,long coordinates

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.
This commit is contained in:
tiennm99 committed 2026-10-08 17:02:05 +07:00
1 parent 512b9fc995
commit 9801a64e18
7 files changed
+137 -44

No files matched your search

+1 -1
View File
@@ -22,7 +22,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 |
| `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
+4 -4
View File
@@ -96,10 +96,10 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"stock_sell": "<quantity> <ticker>",
"stock_cash_dividend": "<vnd_per_share> <ticker>",
"stock_share_dividend": "<ratio(owned:new)> <ticker>",
"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": "<option,...>",
-12
View File
@@ -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.
+37 -2
View File
@@ -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)
+27
View File
@@ -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"},
+20 -12
View File
@@ -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 {
+48 -13
View File
@@ -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" +