feat(weather)!: add /thuyvan flood alerts for Tân Thuận

/thuyvan shows the 5-day tide-peak forecast at Phú An and Nhà Bè from
the Đài KTTV Nam Bộ bulletin PDF, the Open-Meteo rain forecast, and live
VNDMS river gauges within 30 km. /thuyvan_subscribe opts a chat into a
10:30 ICT push, retried at 12:30, sent only when a forecast peak reaches
báo động I (1.40 m) or a day's rain reaches 50 mm. A missing or stale
bulletin fails the push instead of reading as no risk.

The thoitiet module is renamed to weather; the /thoitiet* commands keep
their names.

BREAKING CHANGE: the module key is now "weather". A MODULES list that
names thoitiet fails at startup and must name weather instead.
This commit is contained in:
tiennm99 committed 2026-10-02 13:06:07 +07:00
1 parent 59c74de9a9
commit 053fd07e5a
22 files changed
+1672 -49

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
package weather
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"}
// 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
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")
}