mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 12:15:05 +00:00
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:
1 parent
512b9fc995
commit
9801a64e18
7 files changed
+137
-44
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,...>",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" +
|
||||
|
||||
Reference in new issue
Block a user