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

@@ -1,4 +1,4 @@
package thoitiet
package weather
import (
"context"
@@ -164,21 +164,41 @@ func fetchForecast(ctx context.Context, client *http.Client, p place) (forecast,
}
func getJSON(ctx context.Context, client *http.Client, rawURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
body, err := getBody(ctx, client, rawURL, maxBodySize, http.Header{"Accept": {"application/json"}})
if err != nil {
return fmt.Errorf("build request: %w", err)
return 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 {
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
}
// getBody GETs rawURL with the given headers and returns at most limit bytes
// of a 200 response. A longer body is an error rather than a silent cut, so a
// truncated JSON or PDF never reaches a parser.
func getBody(ctx context.Context, client *http.Client, rawURL string, limit int64, header http.Header) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
for k, v := range header {
req.Header[k] = v
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("body exceeds %d bytes", limit)
}
return body, nil
}
+270
View File
@@ -0,0 +1,270 @@
package weather
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"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.
floodDays = 5
// nearbyRadiusKm bounds the live gauge list around Tân Thuận. Phú An
// (2.9 km) and Nhà Bè (6.8 km) are the gauges that matter; 30 km adds
// the upstream Sài Gòn and Đồng Nai stations.
nearbyRadiusKm = 30
// heavyRainMm is the daily rain forecast that triggers an alert even at
// low tide.
heavyRainMm = 50
// floodFetchTimeout covers the bulletin chain (homepage, article, PDF).
floodFetchTimeout = 15 * time.Second
// maxBulletinAge is how old the newest linked bulletin may be. Before the
// day's bulletin is out the homepage still links yesterday's; anything
// older means the site stopped publishing, and its dates no longer cover
// the window.
maxBulletinAge = 24 * time.Hour
floodSourceLine = "Nguồn: Đài KTTV Nam Bộ, VNDMS, Open-Meteo"
)
// tideAlarmLevels are the báo động I/II/III thresholds, in metres, that KTTV
// sets for both Phú An and Nhà Bè. The bulletin prints them and VNDMS
// returns the same values.
var tideAlarmLevels = [alarmTiers]float64{1.40, 1.50, 1.60}
// tideTier maps a water level to its báo động tier, 0 below BĐ I.
func tideTier(height float64) int {
tier := 0
for i, level := range tideAlarmLevels {
if height >= level {
tier = i + 1
}
}
return tier
}
var tierNames = [...]string{"", "BĐ I", "BĐ II", "BĐ III"}
// floodReport gathers the three sources. Each fails on its own, so the view
// can still show what it has.
type floodReport struct {
Today time.Time // start of today, ICT
Bulletin tideBulletin
BulletinErr error
Rain dailyWeather
RainErr error
Stations []nearbyStation
StationsErr error
}
// fetchFloodReport queries the tide bulletin, the Tân Thuận rain forecast and
// the live gauges concurrently.
func fetchFloodReport(ctx context.Context, client *http.Client, now time.Time) floodReport {
r := floodReport{Today: startOfDay(now.In(ictZone))}
var wg sync.WaitGroup
wg.Go(func() {
r.Bulletin, r.BulletinErr = fetchTideBulletin(ctx, client)
if r.BulletinErr == nil && r.Today.Sub(r.Bulletin.Issued) > maxBulletinAge {
r.BulletinErr = fmt.Errorf("tide bulletin: newest is from %s", r.Bulletin.Issued.Format("2006-01-02"))
}
})
wg.Go(func() {
var f forecast
f, r.RainErr = fetchForecast(ctx, client, tanThuanPlace)
r.Rain = f.Daily
})
wg.Go(func() {
var all []gaugeStation
if all, r.StationsErr = fetchGaugeStations(ctx, client); r.StationsErr == nil {
r.Stations = stationsNear(all, tanThuanPlace, nearbyRadiusKm)
}
})
wg.Wait()
return r
}
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// floodDay is one day of the flood view.
type floodDay struct {
Date time.Time
Peaks []tidePeak // highest tide per bulletin station: Phú An, Nhà Bè
HasTide bool
Tier int // báo động tier of the higher peak
RainMm float64
HasRain bool
}
// atRisk reports whether the day should raise an alert.
func (d floodDay) atRisk() bool { return d.Tier > 0 || (d.HasRain && d.RainMm >= heavyRainMm) }
// days builds the floodDays-day window from today, joining the bulletin's
// Phú An and Nhà Bè peaks with the rain forecast by date. A day either
// source lacks is still listed, with that part missing.
func (r floodReport) days() []floodDay {
out := make([]floodDay, floodDays)
for i := range out {
d := floodDay{Date: r.Today.AddDate(0, 0, i)}
if r.BulletinErr == nil {
d.Peaks, d.HasTide = r.Bulletin.peaksOn(d.Date)
for _, p := range d.Peaks {
d.Tier = max(d.Tier, tideTier(p.Height))
}
}
if r.RainErr == nil {
d.RainMm, d.HasRain = r.Rain.precipitationOn(d.Date)
}
out[i] = d
}
return out
}
// peaksOn returns the day's highest tide at Phú An and at Nhà Bè.
func (b tideBulletin) peaksOn(date time.Time) ([]tidePeak, bool) {
var peaks []tidePeak
for _, station := range b.Stations[:2] {
for _, d := range station {
if !d.Date.Equal(date) {
continue
}
if p, ok := d.maxPeak(); ok {
peaks = append(peaks, p)
}
}
}
return peaks, len(peaks) > 0
}
// precipitationOn returns the forecast rain total for date.
func (d dailyWeather) precipitationOn(date time.Time) (float64, bool) {
key := date.Format("2006-01-02")
for i := range min(len(d.Time), len(d.PrecipitationSum)) {
if d.Time[i] == key {
return d.PrecipitationSum[i], true
}
}
return 0, false
}
// anySource reports whether at least one source answered.
func (r floodReport) anySource() bool {
return r.BulletinErr == nil || r.RainErr == nil || r.StationsErr == nil
}
// formatFloodReport renders /thuyvan.
func formatFloodReport(r floodReport) string {
var b strings.Builder
b.WriteString("🌊 Thuỷ văn — Tân Thuận (Q.7)\n")
switch {
case r.BulletinErr == nil && r.RainErr != nil:
fmt.Fprintf(&b, "Chưa lấy được dự báo mưa.\nĐỉnh triều dự báo, Phú An / Nhà Bè (bản tin %s):\n", r.Bulletin.Issued.Format("02/01"))
case r.BulletinErr == nil:
fmt.Fprintf(&b, "Đỉnh triều dự báo, Phú An / Nhà Bè (bản tin %s):\n", r.Bulletin.Issued.Format("02/01"))
case r.RainErr == nil:
b.WriteString("Chưa lấy được bản tin dự báo triều.\nMưa dự báo:\n")
default:
b.WriteString("Chưa lấy được dự báo triều và mưa.\n")
}
if r.BulletinErr == nil || r.RainErr == nil {
for _, d := range r.days() {
b.WriteString(formatFloodDay(d) + "\n")
}
}
switch {
case r.StationsErr != nil:
b.WriteString("Chưa lấy được mực nước hiện tại.\n")
case len(r.Stations) > 0:
b.WriteString("Mực nước hiện tại:\n")
for _, s := range r.Stations {
b.WriteString(formatNearbyStation(s) + "\n")
}
}
fmt.Fprintf(&b, "Báo động I/II/III tại Phú An, Nhà Bè: %s / %s / %s m\n",
formatMetres(tideAlarmLevels[0]), formatMetres(tideAlarmLevels[1]), formatMetres(tideAlarmLevels[2]))
b.WriteString(floodSourceLine)
return b.String()
}
// formatFloodAlert renders the push for the at-risk days, or "" when no day
// is at risk.
func formatFloodAlert(r floodReport) string {
var lines []string
for _, d := range r.days() {
if d.atRisk() {
lines = append(lines, formatFloodDay(d))
}
}
if len(lines) == 0 {
return ""
}
return "⚠️ Cảnh báo ngập — Tân Thuận (Q.7)\n" + strings.Join(lines, "\n") +
"\nXem chi tiết: /thuyvan\n" + floodSourceLine
}
// formatFloodDay renders one day, for example
// "⚠️ 29/09: triều 1.60 / 1.58 m (17:00), mưa 62 mm — BĐ III, mưa lớn".
func formatFloodDay(d floodDay) string {
var parts []string
if d.HasTide {
heights := make([]string, len(d.Peaks))
top := d.Peaks[0]
for i, p := range d.Peaks {
heights[i] = formatMetres(p.Height)
if p.Height > top.Height {
top = p
}
}
parts = append(parts, fmt.Sprintf("triều %s m (%s)", strings.Join(heights, " / "), top.Time))
}
if d.HasRain {
parts = append(parts, "mưa "+decimal(d.RainMm)+" mm")
}
if len(parts) == 0 {
parts = append(parts, "chưa có số liệu")
}
var warnings []string
if d.Tier > 0 {
warnings = append(warnings, tierNames[d.Tier])
}
if d.HasRain && d.RainMm >= heavyRainMm {
warnings = append(warnings, "mưa lớn")
}
line := d.Date.Format("02/01") + ": " + strings.Join(parts, ", ")
if len(warnings) > 0 {
line = "⚠️ " + line + " — " + strings.Join(warnings, ", ")
}
return line
}
// formatNearbyStation renders one live gauge, for example
// "⚠️ Thủ Dầu Một (Sài Gòn, 26.1 km): 1.56 m lúc 7h 02/10 — BĐ II".
func formatNearbyStation(s nearbyStation) string {
line := fmt.Sprintf("%s (%s, %s km): %s m lúc %dh %02d/%02d",
s.Name, s.River, decimal(s.DistanceKm), formatMetres(s.Level), s.Hour, s.Day, s.Month)
if s.Tier > 0 {
line = "⚠️ " + line + " — " + tierNames[s.Tier]
}
return line
}
// formatMetres renders a water level to the centimetre with the Vietnamese
// comma separator, as KTTV publishes it.
func formatMetres(m float64) string { return strings.Replace(fmt.Sprintf("%.2f", m), ".", ",", 1) }
+216
View File
@@ -0,0 +1,216 @@
package weather
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"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"
"github.com/tiennm99/miti99bot/internal/modules/util/subscription"
)
const (
floodFetchErrorText = "Không lấy được dữ liệu thuỷ văn. Thử lại sau nhé."
// floodPushCronName is the cron's registry and scheduler key; it must be
// unique across all modules' crons.
floodPushCronName = "thuyvan_flood_push"
// floodPushSchedule is UTC: 03:30 UTC == 10:30 ICT, after the tide
// bulletin is published (about 09:20 ICT).
floodPushSchedule = "30 3 * * *"
// floodRetryCronName and floodRetrySchedule rerun the push at 12:30 ICT.
// The day is claimed only when an alert goes out, so the retry sends
// nothing after a successful 10:30 push and covers a 10:30 run that
// failed or found the day's bulletin not yet published.
floodRetryCronName = "thuyvan_flood_push_retry"
floodRetrySchedule = "30 5 * * *"
// floodPushDateKey records the ICT date of the last claimed push, so a
// double fire (a rolling deploy briefly running two containers) sends
// once.
floodPushDateKey = "flood_push:last_date"
)
// flood is the /thuyvan state: its HTTP client and the alert subscribers.
type flood struct {
client *http.Client
subscribers subscription.Store
pushDate subscription.DayStore
// subscribersMu serializes Get→mutate→Put on the single subscriber slot.
subscribersMu sync.Mutex
// nowFn lets tests pin the clock; nil means time.Now.
nowFn func() time.Time
}
func (f *flood) now() time.Time {
if f.nowFn != nil {
return f.nowFn()
}
return time.Now()
}
func (f *flood) commands() []modules.Command {
return []modules.Command{
{
Name: "thuyvan",
Visibility: modules.VisibilityPublic,
Description: "Dự báo triều cường, mưa và mực nước gần Tân Thuận (Q.7)",
Handler: f.handleReport,
},
{
Name: "thuyvan_subscribe",
Visibility: modules.VisibilityPublic,
Description: "Nhận cảnh báo ngập Tân Thuận lúc 10:30 khi có triều cường hoặc mưa lớn",
Handler: f.handleSubscribe,
},
{
Name: "thuyvan_unsubscribe",
Visibility: modules.VisibilityPublic,
Description: "Ngừng nhận cảnh báo ngập Tân Thuận",
Handler: f.handleUnsubscribe,
},
}
}
func (f *flood) crons() []modules.Cron {
return []modules.Cron{
{Name: floodPushCronName, Schedule: floodPushSchedule, Handler: f.pushHandler},
{Name: floodRetryCronName, Schedule: floodRetrySchedule, Handler: f.pushHandler},
}
}
func (f *flood) handleReport(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
fetchCtx, cancelFetch := chathelper.FetchContext(ctx)
fetchCtx, cancel := context.WithTimeout(fetchCtx, floodFetchTimeout)
r := fetchFloodReport(fetchCtx, f.client, f.now())
cancel()
cancelFetch()
logFloodErrors("/thuyvan", r)
if !r.anySource() {
return chathelper.Reply(ctx, b, msg, floodFetchErrorText)
}
return chathelper.Reply(ctx, b, msg, formatFloodReport(r))
}
func (f *flood) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
f.subscribersMu.Lock()
defer f.subscribersMu.Unlock()
added, err := subscription.Add(ctx, f.subscribers, msg.Chat.ID, msg.MessageThreadID)
if err != nil {
return err
}
if added {
return chathelper.Reply(ctx, b, msg, "✅ Đã đăng ký cảnh báo ngập Tân Thuận cho "+scopeVI(msg)+
". Bot nhắn lúc 10:30 khi 5 ngày tới có triều cường từ báo động I hoặc mưa từ 50 mm.")
}
return chathelper.Reply(ctx, b, msg, capitalizeScopeVI(msg)+" đã đăng ký rồi.")
}
func (f *flood) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
f.subscribersMu.Lock()
defer f.subscribersMu.Unlock()
removed, err := subscription.Remove(ctx, f.subscribers, msg.Chat.ID, msg.MessageThreadID)
if err != nil {
return err
}
if removed {
return chathelper.Reply(ctx, b, msg, "Đã huỷ cảnh báo ngập cho "+scopeVI(msg)+".")
}
return chathelper.Reply(ctx, b, msg, capitalizeScopeVI(msg)+" chưa đăng ký.")
}
// scopeVI names where a subscription applies, in Vietnamese.
func scopeVI(msg *models.Message) string {
if msg.MessageThreadID != 0 {
return "topic này"
}
return "chat này"
}
func capitalizeScopeVI(msg *models.Message) string {
if msg.MessageThreadID != 0 {
return "Topic này"
}
return "Chat này"
}
func (f *flood) pushHandler(ctx context.Context, deps modules.Deps) error {
if deps.Bot == nil {
return errors.New("flood push: deps.Bot is nil (BuildOptions.Bot not wired)")
}
return runFloodPush(ctx, f, deps.Bot)
}
// runFloodPush sends the alert to every subscriber when a day in the window
// is at risk, and sends nothing otherwise. The day is claimed only after the
// fetch, so a failed fetch leaves it unclaimed.
func runFloodPush(ctx context.Context, f *flood, sender subscription.Sender) error {
subs, err := subscription.List(ctx, f.subscribers)
if err != nil {
return fmt.Errorf("flood push: list subscribers: %w", err)
}
if len(subs) == 0 {
return nil
}
fetchCtx, cancel := context.WithTimeout(ctx, floodFetchTimeout)
r := fetchFloodReport(fetchCtx, f.client, f.now())
cancel()
logFloodErrors("flood push", r)
if r.BulletinErr != nil && r.RainErr != nil {
return fmt.Errorf("flood push: no tide or rain forecast: %w", errors.Join(r.BulletinErr, r.RainErr))
}
text := formatFloodAlert(r)
if text == "" {
// Tide is the main flood signal here; without the bulletin a quiet
// rain forecast proves nothing, so fail loudly and let the retry
// run try again.
if r.BulletinErr != nil {
return fmt.Errorf("flood push: no tide forecast, rain alone shows no risk: %w", r.BulletinErr)
}
log.Info("flood push: no risk in the next days, skipping")
return nil
}
day := r.Today.Format("2006-01-02")
won, err := subscription.ClaimDay(ctx, f.pushDate, floodPushDateKey, day)
if err != nil {
return fmt.Errorf("flood push: claim date: %w", err)
}
if !won {
log.Info("flood push: already pushed today, skipping", "date", day)
return nil
}
res, err := subscription.Fanout(ctx, "flood", f.subscribers, &f.subscribersMu, subs, sender, bot.SendMessageParams{Text: text})
if err != nil {
return err
}
log.Info("flood push complete", "subscribers", len(subs),
"sent", res.Sent, "failed", res.Failed, "pruned", res.Pruned, "throttled", res.Throttled)
return nil
}
func logFloodErrors(where string, r floodReport) {
for source, err := range map[string]error{"tide bulletin": r.BulletinErr, "rain": r.RainErr, "gauges": r.StationsErr} {
if err != nil {
log.Warn("flood source failed", "module", "weather", "where", where, "source", source, "err", err)
}
}
}
+352
View File
@@ -0,0 +1,352 @@
package weather
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/util/subscription"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// floodNow is 2026-10-02 12:00 ICT, the day of the bulletin fixture.
func floodNow() time.Time { return time.Date(2026, 10, 2, 5, 0, 0, 0, time.UTC) }
// fakeFloodUpstreams serves KTTV Nam Bộ, VNDMS and Open-Meteo from one
// httptest server. A non-zero status fails that upstream.
type fakeFloodUpstreams struct {
forecastBody string
bulletin string // issue date YYYYMMDD of the served PDF; default 20261002
kttvnbStatus int
vndmsStatus int
forecastStatus int
vndmsReferers []string
mu sync.Mutex
}
func stubFloodUpstreams(t *testing.T, f *fakeFloodUpstreams) {
t.Helper()
if f.bulletin == "" {
f.bulletin = "20261002"
}
pdfData, err := os.ReadFile("testdata/HCMC_TVHN_" + f.bulletin + ".pdf")
if err != nil {
t.Fatal(err)
}
lv0, _ := os.ReadFile("testdata/vndms_lv0.json")
lv2, _ := os.ReadFile("testdata/vndms_lv2.json")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fail := func(status int) bool {
if status != 0 {
http.Error(w, "unavailable", status)
return true
}
return false
}
switch {
case r.URL.Path == "/kttvnb/":
if !fail(f.kttvnbStatus) {
d := f.bulletin
_, _ = w.Write([]byte(`<a href="/kttvnb/index.php/thuy-van/29194-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-` +
d[6:8] + "-" + d[4:6] + "-" + d[0:4] + `">`))
}
case strings.HasPrefix(r.URL.Path, "/kttvnb/index.php/"):
_, _ = w.Write([]byte(`<a class="at_url" href="/kttvnb/attachments/article/29194/HCMC_TVHN_` + f.bulletin + `.pdf">`))
case strings.HasSuffix(r.URL.Path, ".pdf"):
_, _ = w.Write(pdfData)
case r.URL.Path == "/vndms/water_level":
f.mu.Lock()
f.vndmsReferers = append(f.vndmsReferers, r.Header.Get("Referer"))
f.mu.Unlock()
if fail(f.vndmsStatus) {
return
}
switch r.URL.Query().Get("lv") {
case "0":
_, _ = w.Write(lv0)
case "2":
_, _ = w.Write(lv2)
default:
_, _ = w.Write([]byte(`{"type":"FeatureCollection","features":[]}`))
}
case r.URL.Path == "/forecast":
if !fail(f.forecastStatus) {
_, _ = w.Write([]byte(f.forecastBody))
}
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
origK, origV, origF := kttvnbURL, vndmsURL, forecastURL
kttvnbURL, vndmsURL, forecastURL = server.URL+"/kttvnb", server.URL+"/vndms", server.URL+"/forecast"
t.Cleanup(func() { kttvnbURL, vndmsURL, forecastURL = origK, origV, origF })
}
func newTestFlood() *flood {
col := storage.NewMemoryProvider().Collection(CollectionName)
return &flood{
client: http.DefaultClient,
subscribers: storage.Typed[subscription.Doc](col),
pushDate: storage.Typed[subscription.DayDoc](col),
nowFn: floodNow,
}
}
func installFlood(t *testing.T, f *flood) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg := &modules.Registry{
Modules: []modules.Module{{Name: CollectionName, Commands: f.commands()}},
AllCommands: map[string]modules.Command{},
}
for _, c := range f.commands() {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
func TestThuyvan_RendersAllSources(t *testing.T) {
up := &fakeFloodUpstreams{forecastBody: forecastFixture}
stubFloodUpstreams(t, up)
rb := installFlood(t, newTestFlood())
want := "🌊 Thuỷ văn — Tân Thuận (Q.7)\n" +
"Đỉnh triều dự báo, Phú An / Nhà Bè (bản tin 02/10):\n" +
"02/10: triều 1,33 / 1,34 m (06:00), mưa 7,9 mm\n" +
"03/10: triều 1,18 / 1,19 m (07:00), mưa 7,0 mm\n" +
"04/10: triều 1,01 / 1,03 m (21:30), mưa 5,7 mm\n" +
"05/10: triều 0,84 / 0,88 m (22:30), mưa 13,9 mm\n" +
"06/10: triều 1,13 / 0,70 m (01:30), mưa 4,0 mm\n" +
"Mực nước hiện tại:\n" +
"Phú An (Sài Gòn, 2,9 km): 1,33 m lúc 7h 02/10\n" +
"Nhà Bè (Đồng Điền, 6,8 km): 1,18 m lúc 7h 02/10\n" +
"Biên Hòa (Đồng Nai, 25,0 km): 1,74 m lúc 7h 02/10\n" +
"⚠️ Thủ Dầu Một (Sài Gòn, 26,1 km): 1,56 m lúc 7h 02/10 — BĐ II\n" +
"Báo động I/II/III tại Phú An, Nhà Bè: 1,40 / 1,50 / 1,60 m\n" +
floodSourceLine
if got := send(rb, "/thuyvan"); got != want {
t.Errorf("reply:\n%s\nwant:\n%s", got, want)
}
for _, ref := range up.vndmsReferers {
if ref != vndmsURL+"/" {
t.Errorf("VNDMS Referer = %q, want %q", ref, vndmsURL+"/")
}
}
}
func TestThuyvan_PartialFailureKeepsOtherSources(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, kttvnbStatus: http.StatusBadGateway, vndmsStatus: http.StatusForbidden})
rb := installFlood(t, newTestFlood())
got := send(rb, "/thuyvan")
for _, part := range []string{"Chưa lấy được bản tin dự báo triều.", "02/10: mưa 7,9 mm", "Chưa lấy được mực nước hiện tại."} {
if !strings.Contains(got, part) {
t.Errorf("reply missing %q:\n%s", part, got)
}
}
if strings.Contains(got, "triều 1,") {
t.Errorf("reply shows tide numbers without a bulletin:\n%s", got)
}
}
func TestThuyvan_AllSourcesDown(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{kttvnbStatus: 500, vndmsStatus: 500, forecastStatus: 500})
rb := installFlood(t, newTestFlood())
if got := send(rb, "/thuyvan"); got != floodFetchErrorText {
t.Errorf("reply = %q, want %q", got, floodFetchErrorText)
}
}
func TestThuyvanSubscribe_Idempotent(t *testing.T) {
f := newTestFlood()
rb := installFlood(t, f)
if got := send(rb, "/thuyvan_subscribe"); !strings.Contains(got, "Đã đăng ký cảnh báo ngập") {
t.Errorf("first subscribe = %q", got)
}
if got := send(rb, "/thuyvan_subscribe"); got != "Chat này đã đăng ký rồi." {
t.Errorf("second subscribe = %q", got)
}
subs, _ := subscription.List(context.Background(), f.subscribers)
if len(subs) != 1 || subs[0].ChatID != 7 {
t.Errorf("subscribers = %+v, want chat 7", subs)
}
if got := send(rb, "/thuyvan_unsubscribe"); got != "Đã huỷ cảnh báo ngập cho chat này." {
t.Errorf("unsubscribe = %q", got)
}
if got := send(rb, "/thuyvan_unsubscribe"); got != "Chat này chưa đăng ký." {
t.Errorf("second unsubscribe = %q", got)
}
}
type recordingSender struct {
calls []bot.SendMessageParams
err error
}
func (s *recordingSender) SendMessage(_ context.Context, p *bot.SendMessageParams) (*models.Message, error) {
s.calls = append(s.calls, *p)
return &models.Message{}, s.err
}
func TestRunFloodPush_SkipsWithoutRisk(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture})
f := newTestFlood()
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
sender := &recordingSender{}
if err := runFloodPush(context.Background(), f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 0 {
t.Errorf("pushed %d messages on a day without risk", len(sender.calls))
}
if _, _, err := f.pushDate.Get(context.Background(), floodPushDateKey); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("a no-risk day must not claim the push date: err=%v", err)
}
}
func TestRunFloodPush_HeavyRainAlertsOncePerDay(t *testing.T) {
// 62 mm forecast for 03/10.
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: strings.Replace(forecastFixture,
`"precipitation_sum":[5.30,7.90,7.00`, `"precipitation_sum":[5.30,7.90,62.00`, 1)})
f := newTestFlood()
ctx := context.Background()
_, _ = subscription.Add(ctx, f.subscribers, 7, 0)
_, _ = subscription.Add(ctx, f.subscribers, 8, 3)
sender := &recordingSender{}
if err := runFloodPush(ctx, f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 2 {
t.Fatalf("pushed %d messages, want 2", len(sender.calls))
}
want := "⚠️ Cảnh báo ngập — Tân Thuận (Q.7)\n" +
"⚠️ 03/10: triều 1,18 / 1,19 m (07:00), mưa 62,0 mm — mưa lớn\n" +
"Xem chi tiết: /thuyvan\n" + floodSourceLine
if got := sender.calls[0].Text; got != want {
t.Errorf("push:\n%s\nwant:\n%s", got, want)
}
if sender.calls[1].ChatID != int64(8) || sender.calls[1].MessageThreadID != 3 {
t.Errorf("second push target = %v/%d, want 8/3", sender.calls[1].ChatID, sender.calls[1].MessageThreadID)
}
if err := runFloodPush(ctx, f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 2 {
t.Errorf("second run the same day pushed again: %d messages", len(sender.calls))
}
}
func TestRunFloodPush_PrunesBlockedChats(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: strings.Replace(forecastFixture,
`"precipitation_sum":[5.30,7.90`, `"precipitation_sum":[5.30,80.00`, 1)})
f := newTestFlood()
ctx := context.Background()
_, _ = subscription.Add(ctx, f.subscribers, 7, 0)
sender := &recordingSender{err: errors.New("Forbidden: bot was blocked by the user")}
if err := runFloodPush(ctx, f, sender); err != nil {
t.Fatal(err)
}
if subs, _ := subscription.List(ctx, f.subscribers); len(subs) != 0 {
t.Errorf("blocked chat not pruned: %+v", subs)
}
}
func TestRunFloodPush_FailsWhenNoForecast(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{kttvnbStatus: 500, forecastStatus: 500})
f := newTestFlood()
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
if err := runFloodPush(context.Background(), f, &recordingSender{}); err == nil {
t.Error("want an error when neither tide nor rain forecast is available")
}
}
func TestThuyvan_RainFailureIsNoted(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastStatus: http.StatusBadGateway})
rb := installFlood(t, newTestFlood())
got := send(rb, "/thuyvan")
for _, part := range []string{"Chưa lấy được dự báo mưa.", "02/10: triều 1,33 / 1,34 m (06:00)\n"} {
if !strings.Contains(got, part) {
t.Errorf("reply missing %q:\n%s", part, got)
}
}
}
func TestThuyvan_StaleBulletinIsNotUsed(t *testing.T) {
// The newest linked bulletin is from 10/09, three weeks before today.
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, bulletin: "20260910"})
rb := installFlood(t, newTestFlood())
got := send(rb, "/thuyvan")
if !strings.Contains(got, "Chưa lấy được bản tin dự báo triều.") || strings.Contains(got, "bản tin 10/09") {
t.Errorf("stale bulletin used:\n%s", got)
}
}
func TestRunFloodPush_TideAlert(t *testing.T) {
// The 10/09 bulletin forecasts báo động I–II at Phú An and Nhà Bè from
// 11/09; the rain fixture's dates do not overlap, so tide alone triggers.
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, bulletin: "20260910"})
f := newTestFlood()
f.nowFn = func() time.Time { return time.Date(2026, 9, 10, 4, 0, 0, 0, time.UTC) }
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
sender := &recordingSender{}
if err := runFloodPush(context.Background(), f, sender); err != nil {
t.Fatal(err)
}
if len(sender.calls) != 1 {
t.Fatalf("pushed %d messages, want 1", len(sender.calls))
}
got := sender.calls[0].Text
for _, part := range []string{"⚠️ 11/09: triều 1,37 / 1,41 m (03:30) — BĐ I", "⚠️ 12/09: triều 1,45 / 1,46 m (16:00) — BĐ I"} {
if !strings.Contains(got, part) {
t.Errorf("push missing %q:\n%s", part, got)
}
}
if strings.Contains(got, "10/09") {
t.Errorf("a day below báo động I is in the push:\n%s", got)
}
}
func TestRunFloodPush_FailsWithoutBulletinWhenRainIsQuiet(t *testing.T) {
stubFloodUpstreams(t, &fakeFloodUpstreams{forecastBody: forecastFixture, kttvnbStatus: 500})
f := newTestFlood()
_, _ = subscription.Add(context.Background(), f.subscribers, 7, 0)
sender := &recordingSender{}
if err := runFloodPush(context.Background(), f, sender); err == nil {
t.Error("want an error so the missing tide forecast is not read as no risk")
}
if len(sender.calls) != 0 {
t.Errorf("pushed %d messages", len(sender.calls))
}
}
func TestFormatFloodDay_TideTiers(t *testing.T) {
cases := []struct {
height float64
want string
}{
{1.39, "29/09: triều 1,39 m (17:00)"},
{1.40, "⚠️ 29/09: triều 1,40 m (17:00) — BĐ I"},
{1.55, "⚠️ 29/09: triều 1,55 m (17:00) — BĐ II"},
{1.60, "⚠️ 29/09: triều 1,60 m (17:00) — BĐ III"},
}
for _, c := range cases {
d := floodDay{Date: time.Date(2026, 9, 29, 0, 0, 0, 0, ictZone), Peaks: []tidePeak{{c.height, "17:00"}},
HasTide: true, Tier: tideTier(c.height)}
if got := formatFloodDay(d); got != c.want {
t.Errorf("height %.2f: %q, want %q", c.height, got, c.want)
}
}
}
@@ -1,4 +1,4 @@
package thoitiet
package weather
import (
"fmt"
@@ -1,4 +1,4 @@
package thoitiet
package weather
import (
"strings"
@@ -1,4 +1,4 @@
package thoitiet
package weather
import "testing"
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[106.63300323486328,17.433000564575195]},"properties":{"longtitude":106.633,"latitude":17.433,"label":"Đồng Hới","popupInfo":"<div class=\"station-popup-info\"><div class=\"row-popup\"><div class=\"col-popup-left\"><li>Tên trạm: <b>Đồng Hới</b> </li><li>Mã trạm: <b>71502</b></li><li>Địa điểm: <b>Quảng Trị</b></li><li> Sông: <b>Kiến Giang</b></li><li>Nguồn: <b>KTTV</b></li><li>Cảnh báo vượt mức báo động: <b>-</b></li><li><b>Mực nước (0.09(m) 11-02/10)</b> </li><li class='detalRain' data-id='71502' onclick='VNDMS.wMap.showStationInfo(`71502`,`Water`,1)' ><a class=\"description-class\">Chi tiết <i class=\"fa fa-caret-right\" aria-hidden=\"true\"></i> </a></b></li></div><li><img class='agri-chart-img' src=\"https://vndms.gov.vn/chart?height=175&c={ type: 'bar', data: { 'labels': ['0h \n1/10','1h \n1/10','2h \n1/10','3h \n1/10','4h \n1/10','5h \n1/10','6h \n1/10','7h \n1/10','8h \n1/10','9h \n1/10','10h \n1/10','11h \n1/10','12h \n1/10','13h \n1/10','14h \n1/10','15h \n1/10','16h \n1/10','17h \n1/10','18h \n1/10','19h \n1/10','20h \n1/10','21h \n1/10','22h \n1/10','23h \n1/10','0h \n2/10','1h \n2/10','2h \n2/10','3h \n2/10','4h \n2/10','5h \n2/10','6h \n2/10','7h \n2/10','8h \n2/10','9h \n2/10','10h \n2/10','11h \n2/10'], datasets: [{ type: 'line', label: 'Dataset 1', borderColor: 'green', borderWidth: 2, fill: false, data: [0.02,0.27,0.46,0.66,0.77,0.69,0.52,0.37,0.17,0.07,0.03,0.05,0.15,0.29,0.32,0.29,0.16,-0.02,-0.23,-0.37,-0.5,-0.55,-0.5,-0.36,-0.15,-0.02,0.15,0.38,0.55,0.64,0.62,0.49,0.37,0.25,0.12,0.09 ] } ] }, options: { responsive: true, maintainAspectRatio:false,layout:{padding:{bottom:10,top:20,right:10}}, scales:{yAxes:[{display: false}]},legend:{ display: false},plugins:{datalabels:{display:true,borderRadius:3,align:'top',font: {weight: 'bold'}}}}}\" /></li></div></div>"}},{"type":"Feature","geometry":{"type":"Point","coordinates":[106.82499694824219,10.9399995803833]},"properties":{"longtitude":106.825,"latitude":10.94,"label":"Biên Hòa","popupInfo":"<div class=\"station-popup-info\"><div class=\"row-popup\"><div class=\"col-popup-left\"><li>Tên trạm: <b>Biên Hòa</b> </li><li>Mã trạm: <b>71594</b></li><li>Địa điểm: <b>Đồng Nai</b></li><li> Sông: <b>Đồng Nai</b></li><li>Nguồn: <b>KTTV</b></li><li>Cảnh báo vượt mức báo động: <b>-</b></li><li><b>Mực nước (1.74(m) 7-02/10)</b> </li><li class='detalRain' data-id='71594' onclick='VNDMS.wMap.showStationInfo(`71594`,`Water`,1)' ><a class=\"description-class\">Chi tiết <i class=\"fa fa-caret-right\" aria-hidden=\"true\"></i> </a></b></li></div><li><img class='agri-chart-img' src=\"https://vndms.gov.vn/chart?height=175&c={ type: 'bar', data: { 'labels': ['1h \n1/10','2h \n1/10','6h \n1/10','7h \n1/10','13h \n1/10','15h \n1/10','19h \n1/10','1h \n2/10','2h \n2/10','7h \n2/10'], datasets: [{ type: 'line', label: 'Dataset 1', borderColor: 'green', borderWidth: 2, fill: false, data: [0.61,0.4,1.88,1.87,0.14,-0.41,1.8,0.84,0.55,1.74 ] } ] }, options: { responsive: true, maintainAspectRatio:false,layout:{padding:{bottom:10,top:20,right:10}}, scales:{yAxes:[{display: false}]},legend:{ display: false},plugins:{datalabels:{display:true,borderRadius:3,align:'top',font: {weight: 'bold'}}}}}\" /></li></div></div>"}},{"type":"Feature","geometry":{"type":"Point","coordinates":[106.71700286865234,10.767000198364258]},"properties":{"longtitude":106.717,"latitude":10.767,"label":"Phú An","popupInfo":"<div class=\"station-popup-info\"><div class=\"row-popup\"><div class=\"col-popup-left\"><li>Tên trạm: <b>Phú An</b> </li><li>Mã trạm: <b>71600</b></li><li>Địa điểm: <b>TP. Hồ Chí Minh</b></li><li> Sông: <b>Sài Gòn</b></li><li>Nguồn: <b>KTTV</b></li><li>Cảnh báo vượt mức báo động: <b>-</b></li><li><b>Mực nước (1.33(m) 7-02/10)</b> </li><li class='detalRain' data-id='71600' onclick='VNDMS.wMap.showStationInfo(`71600`,`Water`,1)' ><a class=\"description-class\">Chi tiết <i class=\"fa fa-caret-right\" aria-hidden=\"true\"></i> </a></b></li></div><li><img class='agri-chart-img' src=\"https://vndms.gov.vn/chart?height=175&c={ type: 'bar', data: { 'labels': ['1h \n1/10','6h \n1/10','7h \n1/10','13h \n1/10','14h \n1/10','19h \n1/10','1h \n2/10','7h \n2/10'], datasets: [{ type: 'line', label: 'Dataset 1', borderColor: 'green', borderWidth: 2, fill: false, data: [-0.46,1.44,1.35,-1.57,-1.72,1.4,-0.04,1.33 ] } ] }, options: { responsive: true, maintainAspectRatio:false,layout:{padding:{bottom:10,top:20,right:10}}, scales:{yAxes:[{display: false}]},legend:{ display: false},plugins:{datalabels:{display:true,borderRadius:3,align:'top',font: {weight: 'bold'}}}}}\" /></li></div></div>"}},{"type":"Feature","geometry":{"type":"Point","coordinates":[106.65299987792969,10.967000007629395]},"properties":{"longtitude":106.653,"latitude":10.967,"label":"Thủ DLine truncated
+1
View File
@@ -0,0 +1 @@
{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[106.65299987792969,10.967000007629395]},"properties":{"longtitude":106.653,"latitude":10.967,"label":"Thủ Dầu Một","popupInfo":"<div class=\"station-popup-info\"><div class=\"row-popup\"><div class=\"col-popup-left\"><li>Tên trạm: <b>Thủ Dầu Một</b> </li><li>Mã trạm: <b>71586</b></li><li>Địa điểm: <b>TP. Hồ Chí Minh</b></li><li> Sông: <b>Sài Gòn</b></li><li>Nguồn: <b>KTTV</b></li><li>Cảnh báo vượt mức báo động: <b>-</b></li><li><b>Mực nước (1.56(m) 7-02/10)</b> </li><li class='detalRain' data-id='71586' onclick='VNDMS.wMap.showStationInfo(`71586`,`Water`,1)' ><a class=\"description-class\">Chi tiết <i class=\"fa fa-caret-right\" aria-hidden=\"true\"></i> </a></b></li></div><li><img class='agri-chart-img' src=\"https://vndms.gov.vn/chart?height=175&c={ type: 'bar', data: { 'labels': ['1h \n1/10','2h \n1/10','7h \n1/10','13h \n1/10','15h \n1/10','19h \n1/10','1h \n2/10','2h \n2/10','7h \n2/10'], datasets: [{ type: 'line', label: 'Dataset 1', borderColor: 'green', borderWidth: 2, fill: false, data: [0.5,0.34,1.66,0.01,-0.55,1.4,0.69,0.5,1.56 ] } ] }, options: { responsive: true, maintainAspectRatio:false,layout:{padding:{bottom:10,top:20,right:10}}, scales:{yAxes:[{display: false}]},legend:{ display: false},plugins:{datalabels:{display:true,borderRadius:3,align:'top',font: {weight: 'bold'}}}}}\" /></li></div></div>"}}]}
+365
View File
@@ -0,0 +1,365 @@
package weather
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/ledongthuc/pdf"
"golang.org/x/text/unicode/norm"
)
// ictZone is Vietnam's time zone; the bulletin's dates and times are local.
var ictZone = time.FixedZone("ICT", 7*60*60)
// kttvnbURL is Đài KTTV Nam Bộ's site. Its homepage links each day's
// "bản tin dự báo thủy văn TPHCM" article, and the article links the 5-day
// tide bulletin PDF. A variable so tests can point it at an httptest server.
var kttvnbURL = "https://kttvnb.vn"
const (
// maxPageSize caps the homepage and article HTML (about 50 KB each).
maxPageSize = 1 << 20
// maxBulletinSize caps the bulletin PDF (about 320 KB).
maxBulletinSize = 4 << 20
// bulletinDays is how many forecast days each station block carries.
bulletinDays = 5
// maxTideHeight bounds a plausible peak; anything outside (0, max] means
// the parser read the wrong column.
maxTideHeight = 3.0
)
// bulletinStations are the station blocks of the forecast table, in the
// order the PDF prints them. Only the first two are used, but all three are
// checked so a reordered table fails instead of mislabelling peaks.
var bulletinStations = []string{"Phú An", "Nhà Bè", "Thủ Dầu Một"}
var (
// articleLinkRE matches the homepage link to a TPHCM hydrology bulletin
// article, capturing the issue date. The slug varies
// ("...-tphcm-ra-nga-y-02-10-2026", "...-khu-va-c-tphcm-ra-nga-y-..."), so
// only its tail is matched.
articleLinkRE = regexp.MustCompile(`href="([^"]*tphcm-ra-nga-y-(\d{2})-(\d{2})-(\d{4}))"`)
// bulletinLinkRE matches the article's PDF attachment link.
bulletinLinkRE = regexp.MustCompile(`href="([^"]*/HCMC_TVHN_(\d{8})\.pdf)"`)
dayTokenRE = regexp.MustCompile(`^\d{1,2}/$`)
monthTokenRE = regexp.MustCompile(`^\d{1,2}$`)
heightTokenRE = regexp.MustCompile(`^\d+\.\d+$`)
timeTokenRE = regexp.MustCompile(`^([01]?\d|2[0-3])\.[0-5]\d$`)
)
// tidePeak is one forecast high tide. Time is "HH:MM" local time.
type tidePeak struct {
Height float64
Time string
}
// tideDay is one forecast day at one station: up to two high tides.
type tideDay struct {
Date time.Time
Peaks []tidePeak
}
// maxPeak returns the day's highest tide, or false when the day has none.
func (d tideDay) maxPeak() (tidePeak, bool) {
if len(d.Peaks) == 0 {
return tidePeak{}, false
}
return slices.MaxFunc(d.Peaks, func(a, b tidePeak) int {
switch {
case a.Height < b.Height:
return -1
case a.Height > b.Height:
return 1
}
return 0
}), true
}
// tideBulletin is the parsed forecast table: one entry per station in
// bulletinStations order, each with bulletinDays days.
type tideBulletin struct {
Issued time.Time
Stations [][]tideDay
}
// fetchTideBulletin finds the newest TPHCM bulletin linked from the KTTV Nam
// Bộ homepage and parses its forecast table. Before the day's bulletin is
// published (about 09:20 ICT) that is the previous day's.
func fetchTideBulletin(ctx context.Context, client *http.Client) (tideBulletin, error) {
home, err := getBody(ctx, client, kttvnbURL+"/", maxPageSize, nil)
if err != nil {
return tideBulletin{}, fmt.Errorf("tide bulletin homepage: %w", err)
}
articleURL, err := newestArticle(home)
if err != nil {
return tideBulletin{}, err
}
article, err := getBody(ctx, client, articleURL, maxPageSize, nil)
if err != nil {
return tideBulletin{}, fmt.Errorf("tide bulletin article: %w", err)
}
pdfURL, issued, err := bulletinLink(article)
if err != nil {
return tideBulletin{}, err
}
data, err := getBody(ctx, client, pdfURL, maxBulletinSize, nil)
if err != nil {
return tideBulletin{}, fmt.Errorf("tide bulletin pdf: %w", err)
}
b, err := parseTideBulletin(data, issued)
if err != nil {
return tideBulletin{}, fmt.Errorf("tide bulletin %s: %w", pdfURL, err)
}
return b, nil
}
// newestArticle returns the absolute URL of the latest-dated bulletin article
// linked from the homepage.
func newestArticle(home []byte) (string, error) {
var best string
var bestDate time.Time
for _, m := range articleLinkRE.FindAllSubmatch(home, -1) {
d, err := time.Parse("02-01-2006", string(m[2])+"-"+string(m[3])+"-"+string(m[4]))
if err != nil || !d.After(bestDate) {
continue
}
best, bestDate = string(m[1]), d
}
if best == "" {
return "", errors.New("tide bulletin: no article link on homepage")
}
return resolveKTTVNB(best)
}
// bulletinLink returns the article's PDF URL and the issue date encoded in
// its file name.
func bulletinLink(article []byte) (string, time.Time, error) {
m := bulletinLinkRE.FindSubmatch(article)
if m == nil {
return "", time.Time{}, errors.New("tide bulletin: no PDF link in article")
}
issued, err := time.ParseInLocation("20060102", string(m[2]), ictZone)
if err != nil {
return "", time.Time{}, fmt.Errorf("tide bulletin: issue date: %w", err)
}
u, err := resolveKTTVNB(string(m[1]))
return u, issued, err
}
// resolveKTTVNB makes a possibly relative link absolute against kttvnbURL.
func resolveKTTVNB(link string) (string, error) {
base, err := url.Parse(kttvnbURL + "/")
if err != nil {
return "", err
}
ref, err := url.Parse(link)
if err != nil {
return "", fmt.Errorf("tide bulletin link %q: %w", link, err)
}
return base.ResolveReference(ref).String(), nil
}
// parseTideBulletin extracts the forecast table from the bulletin PDF.
//
// The PDF's text layer garbles labels (letters split and reordered), but each
// forecast row keeps its date and values in reading order:
//
// 02/ 10 1.33 07.00 1.29 21.00 - 1.79 15.00 - 0.04 01.00
//
// that is date, high 1 (height, time), high 2, low 1, low 2, with "ct" for a
// missing high. The date tokens can come out swapped ("10 04/"), with the
// station label in front on a block's middle row. Rows are matched by their
// date token, and the result is accepted only if it has bulletinDays
// consecutive dates for every station in bulletinStations, the station's
// label is an anagram of its name, and every height is plausible. The label
// sits in front of the block's middle row, or on its own text row a point
// away from it.
func parseTideBulletin(data []byte, issued time.Time) (b tideBulletin, err error) {
// The pdf package panics on some malformed files instead of returning an
// error; a bad download must not take the handler down with it.
defer func() {
if p := recover(); p != nil {
b, err = tideBulletin{}, fmt.Errorf("read pdf: %v", p)
}
}()
if !bytes.HasPrefix(data, []byte("%PDF-")) {
return tideBulletin{}, errors.New("not a PDF")
}
r, err := pdf.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return tideBulletin{}, fmt.Errorf("open pdf: %w", err)
}
var rows []bulletinRow
var labels []textLine
for i := 1; i <= r.NumPage(); i++ {
page := r.Page(i)
if page.V.IsNull() {
continue
}
textRows, err := page.GetTextByRow()
if err != nil {
return tideBulletin{}, fmt.Errorf("read page %d: %w", i, err)
}
for _, tr := range textRows {
var tokens []string
for _, t := range tr.Content {
tokens = append(tokens, strings.Fields(t.S)...)
}
line := textLine{page: i, y: tr.Position, text: strings.Join(tokens, "")}
if row, ok := parseBulletinRow(tokens, issued); ok {
row.line = line
rows = append(rows, row)
} else {
labels = append(labels, line)
}
}
}
return assembleBulletin(rows, labels, issued)
}
// textLine is one text row of the PDF and where it sits.
type textLine struct {
page int
y int64
text string
}
// labelRowGap is how far, in PDF points, a station label on its own row may
// sit from the forecast row it labels.
const labelRowGap = 3
// bulletinRow is one parsed forecast row plus the label text in front of it.
type bulletinRow struct {
label string
day tideDay
line textLine
}
// parseBulletinRow reads one text row. It reports false for rows that are not
// forecast rows (headers, observed data, notes).
func parseBulletinRow(tokens []string, issued time.Time) (bulletinRow, bool) {
dayIdx := slices.IndexFunc(tokens, dayTokenRE.MatchString)
if dayIdx < 0 {
return bulletinRow{}, false
}
monthIdx := -1
for _, i := range []int{dayIdx + 1, dayIdx - 1} {
if i >= 0 && i < len(tokens) && monthTokenRE.MatchString(tokens[i]) {
monthIdx = i
break
}
}
if monthIdx < 0 {
return bulletinRow{}, false
}
day, _ := strconv.Atoi(strings.TrimSuffix(tokens[dayIdx], "/"))
month, _ := strconv.Atoi(tokens[monthIdx])
date, ok := bulletinDate(day, month, issued)
if !ok {
return bulletinRow{}, false
}
first := min(dayIdx, monthIdx)
values := tokens[max(dayIdx, monthIdx)+1:]
if len(values) < 4 {
return bulletinRow{}, false
}
var peaks []tidePeak
for i := 0; i < 4; i += 2 {
h, t := values[i], values[i+1]
if h == "ct" && t == "ct" {
continue
}
if !heightTokenRE.MatchString(h) || !timeTokenRE.MatchString(t) {
return bulletinRow{}, false
}
height, _ := strconv.ParseFloat(h, 64)
hour, minute, _ := strings.Cut(t, ".")
if len(hour) == 1 {
hour = "0" + hour
}
peaks = append(peaks, tidePeak{Height: height, Time: hour + ":" + minute})
}
return bulletinRow{label: strings.Join(tokens[:first], ""), day: tideDay{Date: date, Peaks: peaks}}, true
}
// bulletinDate builds a forecast date from the row's day and month. The table
// has no year: a month far before the issue month means the forecast crossed
// into the next year.
func bulletinDate(day, month int, issued time.Time) (time.Time, bool) {
if month < 1 || month > 12 || day < 1 || day > 31 {
return time.Time{}, false
}
year := issued.Year()
if month < int(issued.Month())-6 {
year++
}
d := time.Date(year, time.Month(month), day, 0, 0, 0, 0, ictZone)
if d.Day() != day {
return time.Time{}, false
}
return d, true
}
func assembleBulletin(rows []bulletinRow, labels []textLine, issued time.Time) (tideBulletin, error) {
want := len(bulletinStations) * bulletinDays
if len(rows) != want {
return tideBulletin{}, fmt.Errorf("found %d forecast rows, want %d", len(rows), want)
}
b := tideBulletin{Issued: issued}
for s, name := range bulletinStations {
block := rows[s*bulletinDays : (s+1)*bulletinDays]
if !labelled(block[bulletinDays/2], labels, name) {
return tideBulletin{}, fmt.Errorf("block %d is not labelled %s", s, name)
}
days := make([]tideDay, 0, bulletinDays)
for i, row := range block {
if !row.day.Date.Equal(rows[i].day.Date) || (i > 0 && !row.day.Date.Equal(days[i-1].Date.AddDate(0, 0, 1))) {
return tideBulletin{}, fmt.Errorf("%s: dates are not %d consecutive days", name, bulletinDays)
}
for _, p := range row.day.Peaks {
if p.Height <= 0 || p.Height > maxTideHeight {
return tideBulletin{}, fmt.Errorf("%s: implausible peak %.2f m", name, p.Height)
}
}
days = append(days, row.day)
}
b.Stations = append(b.Stations, days)
}
return b, nil
}
// labelled reports whether row carries name as its label, either in front of
// its values or on a separate text row right next to it.
func labelled(row bulletinRow, labels []textLine, name string) bool {
if sameLetters(row.label, name) {
return true
}
for _, l := range labels {
gap := l.y - row.line.y
if l.page == row.line.page && gap >= -labelRowGap && gap <= labelRowGap && sameLetters(l.text, name) {
return true
}
}
return false
}
// sameLetters reports whether a and b use the same letters, ignoring spaces
// and order; the PDF text layer scrambles a label's letters but keeps them.
func sameLetters(a, b string) bool {
letters := func(s string) []rune {
r := []rune(strings.Join(strings.Fields(norm.NFC.String(s)), ""))
slices.Sort(r)
return r
}
return slices.Equal(letters(a), letters(b))
}
@@ -0,0 +1,130 @@
package weather
import (
"os"
"testing"
"time"
)
func loadBulletinFixture(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile("testdata/HCMC_TVHN_20261002.pdf")
if err != nil {
t.Fatal(err)
}
return data
}
func TestParseTideBulletin_RealPDF(t *testing.T) {
issued := time.Date(2026, 10, 2, 0, 0, 0, 0, ictZone)
b, err := parseTideBulletin(loadBulletinFixture(t), issued)
if err != nil {
t.Fatalf("parseTideBulletin: %v", err)
}
if len(b.Stations) != len(bulletinStations) {
t.Fatalf("stations = %d, want %d", len(b.Stations), len(bulletinStations))
}
phuAn, nhaBe := b.Stations[0], b.Stations[1]
if got := phuAn[0].Date; !got.Equal(issued) {
t.Errorf("first date = %v, want %v", got, issued)
}
want := []tidePeak{{1.33, "07:00"}, {1.29, "21:00"}}
if len(phuAn[0].Peaks) != 2 || phuAn[0].Peaks[0] != want[0] || phuAn[0].Peaks[1] != want[1] {
t.Errorf("Phú An 02/10 = %+v, want %+v", phuAn[0].Peaks, want)
}
// 05/10 has no second high tide ("ct").
if got := phuAn[3].Peaks; len(got) != 1 || got[0] != (tidePeak{0.84, "10:00"}) {
t.Errorf("Phú An 05/10 = %+v, want one peak 0.84 m at 10:00", got)
}
// The middle row's date tokens come out swapped ("10 04/").
if got := nhaBe[2]; !got.Date.Equal(issued.AddDate(0, 0, 2)) || got.Peaks[0] != (tidePeak{1.02, "08:00"}) {
t.Errorf("Nhà Bè 04/10 = %+v", got)
}
if p, _ := b.Stations[2][0].maxPeak(); p.Height != 1.60 {
t.Errorf("Thủ Dầu Một 02/10 max = %v, want 1.60", p.Height)
}
}
// In the 10/09 bulletin the Thủ Dầu Một label is a text row of its own, one
// point from the block's middle row, and the week is at alarm level.
func TestParseTideBulletin_SeparateLabelRow(t *testing.T) {
data, err := os.ReadFile("testdata/HCMC_TVHN_20260910.pdf")
if err != nil {
t.Fatal(err)
}
b, err := parseTideBulletin(data, time.Date(2026, 9, 10, 0, 0, 0, 0, ictZone))
if err != nil {
t.Fatalf("parseTideBulletin: %v", err)
}
if p, _ := b.Stations[1][2].maxPeak(); p != (tidePeak{1.46, "16:00"}) {
t.Errorf("Nhà Bè 12/09 max = %+v, want 1.46 m at 16:00", p)
}
if p, _ := b.Stations[2][2].maxPeak(); p != (tidePeak{1.55, "17:30"}) {
t.Errorf("Thủ Dầu Một 12/09 max = %+v, want 1.55 m at 17:30", p)
}
}
func TestParseTideBulletin_RejectsNonBulletin(t *testing.T) {
if _, err := parseTideBulletin([]byte("not a pdf"), time.Now()); err == nil || err.Error() != "not a PDF" {
t.Error("want an error for non-PDF input")
}
}
func TestParseBulletinRow(t *testing.T) {
issued := time.Date(2026, 12, 30, 0, 0, 0, 0, ictZone)
row, ok := parseBulletinRow([]string{"P", "n", "A", "ú", "h", "1", "02/", "1.01", "09.00", "ct", "ct", "1.78", "-"}, issued)
if !ok {
t.Fatal("row not parsed")
}
if want := time.Date(2027, 1, 2, 0, 0, 0, 0, ictZone); !row.day.Date.Equal(want) {
t.Errorf("date = %v, want %v (year rollover)", row.day.Date, want)
}
if !sameLetters(row.label, "Phú An") {
t.Errorf("label %q should match Phú An", row.label)
}
if len(row.day.Peaks) != 1 || row.day.Peaks[0] != (tidePeak{1.01, "09:00"}) {
t.Errorf("peaks = %+v, want one at 09:00", row.day.Peaks)
}
// A one-digit hour ("1.30") is padded.
if row, ok := parseBulletinRow([]string{"06/", "10", "0.68", "11.00", "1.13", "1.30"}, issued); !ok || row.day.Peaks[1].Time != "01:30" {
t.Errorf("one-digit hour: %+v ok=%v, want 01:30", row.day.Peaks, ok)
}
for _, tokens := range [][]string{
{"đ", "ế", "n", "7h", "02/", "10"}, // header, no values
{"02/", "10", "61.56", "1538", "776", "256.0"}, // reservoir row, no times
{"MỰC", "NGÀY", "01/", "10/", "2026"}, // date without a month token
{"02/", "10", "1.33", "25.00", "1.29", "21.00"}, // hour out of range
} {
if _, ok := parseBulletinRow(tokens, issued); ok {
t.Errorf("parseBulletinRow(%q) should be rejected", tokens)
}
}
}
func TestNewestArticleAndBulletinLink(t *testing.T) {
home := []byte(`<a href="/index.php/100-thong-tin-kttv/thuy-van/29180-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-01-10-2026">
<a href="/index.php/100-thong-tin-kttv/thuy-van/29194-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-02-10-2026" itemprop="url">
<a href="/index.php/100-thong-tin-kttv/thuy-van/29196-ba-n-tin-da-ba-o-tha-y-v-n-pha-c-va-quy-tra-nh-ra-nga-y-02-10-2026">`)
got, err := newestArticle(home)
if err != nil {
t.Fatal(err)
}
if want := kttvnbURL + "/index.php/100-thong-tin-kttv/thuy-van/29194-ba-n-tin-da-ba-o-tha-y-v-n-tphcm-ra-nga-y-02-10-2026"; got != want {
t.Errorf("newestArticle = %q, want %q", got, want)
}
if _, err := newestArticle([]byte("<html></html>")); err == nil {
t.Error("want an error when no article is linked")
}
article := []byte(`<a class="at_icon" href="https://kttvnb.vn/attachments/article/29194/HCMC_TVHN_20261002.pdf" title="x">`)
pdfURL, issued, err := bulletinLink(article)
if err != nil {
t.Fatal(err)
}
if pdfURL != "https://kttvnb.vn/attachments/article/29194/HCMC_TVHN_20261002.pdf" {
t.Errorf("pdfURL = %q", pdfURL)
}
if !issued.Equal(time.Date(2026, 10, 2, 0, 0, 0, 0, ictZone)) {
t.Errorf("issued = %v", issued)
}
}
+178
View File
@@ -0,0 +1,178 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"html"
"math"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
)
// vndmsURL is the Vietnam Disaster Monitoring System (Cục Quản lý đê điều và
// PCTT), which republishes KTTV river gauge readings. Its station-map
// endpoint is undocumented and answers 403 without a same-site Referer. A
// variable so tests can point it at an httptest server.
var vndmsURL = "https://vndms.gov.vn"
const (
// maxWaterLevelSize caps one station-map response; the full list is
// about 430 KB decoded.
maxWaterLevelSize = 4 << 20
// alarmTiers is the number of báo động levels (I, II, III). The map
// endpoint's lv=N lists only the stations at tier N; lv=0 lists all.
alarmTiers = 3
)
var (
popupNameRE = regexp.MustCompile(`Tên trạm: <b>([^<]+)</b>`)
popupCodeRE = regexp.MustCompile(`Mã trạm: <b>([^<]+)</b>`)
popupProvinceRE = regexp.MustCompile(`Địa điểm: <b>([^<]+)</b>`)
popupRiverRE = regexp.MustCompile(`Sông: <b>([^<]+)</b>`)
// popupLevelRE matches "Mực nước (1.33(m) 7-02/10)": metres, then the
// reading's hour and day/month. Stations without data print
// "Mực nước :Không có số liệu" instead and are skipped.
popupLevelRE = regexp.MustCompile(`Mực nước \((-?\d+(?:\.\d+)?)\(m\) (\d{1,2})-(\d{1,2})/(\d{1,2})\)`)
)
// gaugeStation is one river gauge with its latest reading.
type gaugeStation struct {
Code, Name, River, Province string
Latitude, Longitude float64
Level float64 // metres, in the station's own datum
Hour, Day, Month int // reading time, local
Tier int // báo động tier 1–3, or 0 below alarm
}
type stationMap struct {
Features []struct {
Properties struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longtitude"` // sic
Popup string `json:"popupInfo"`
} `json:"properties"`
} `json:"features"`
}
// fetchGaugeStations returns every station with a current reading, each
// tagged with its alarm tier. The four map requests run concurrently, and
// any failure fails the whole lookup so a missing tier list can never read as
// "below alarm".
func fetchGaugeStations(ctx context.Context, client *http.Client) ([]gaugeStation, error) {
maps := make([]stationMap, alarmTiers+1)
errs := make([]error, alarmTiers+1)
var wg sync.WaitGroup
for lv := range maps {
wg.Go(func() {
maps[lv], errs[lv] = fetchStationMap(ctx, client, lv)
})
}
wg.Wait()
for lv, err := range errs {
if err != nil {
return nil, fmt.Errorf("water level lv=%d: %w", lv, err)
}
}
stations := parseStationMap(maps[0])
if len(stations) == 0 {
return nil, fmt.Errorf("water level: no station readings")
}
tiers := map[string]int{}
for lv := 1; lv <= alarmTiers; lv++ {
for _, s := range parseStationMap(maps[lv]) {
tiers[s.Code] = max(tiers[s.Code], lv)
}
}
for i := range stations {
stations[i].Tier = tiers[stations[i].Code]
}
return stations, nil
}
func fetchStationMap(ctx context.Context, client *http.Client, lv int) (stationMap, error) {
body, err := getBody(ctx, client, vndmsURL+"/water_level?lv="+strconv.Itoa(lv), maxWaterLevelSize,
http.Header{"Referer": {vndmsURL + "/"}, "Accept": {"application/json"}})
if err != nil {
return stationMap{}, err
}
var m stationMap
if err := json.Unmarshal(body, &m); err != nil {
return stationMap{}, fmt.Errorf("decode: %w", err)
}
return m, nil
}
// parseStationMap reads the station fields out of each feature's popup HTML,
// keeping only stations that have a code and a current reading.
func parseStationMap(m stationMap) []gaugeStation {
var out []gaugeStation
for _, f := range m.Features {
p := f.Properties.Popup
lm := popupLevelRE.FindStringSubmatch(p)
code := popupField(popupCodeRE, p)
if lm == nil || code == "" {
continue
}
level, err := strconv.ParseFloat(lm[1], 64)
if err != nil {
continue
}
hour, _ := strconv.Atoi(lm[2])
day, _ := strconv.Atoi(lm[3])
month, _ := strconv.Atoi(lm[4])
out = append(out, gaugeStation{
Code: code,
Name: popupField(popupNameRE, p),
River: popupField(popupRiverRE, p),
Province: popupField(popupProvinceRE, p),
Latitude: f.Properties.Latitude,
Longitude: f.Properties.Longitude,
Level: level,
Hour: hour,
Day: day,
Month: month,
})
}
return out
}
func popupField(re *regexp.Regexp, popup string) string {
m := re.FindStringSubmatch(popup)
if m == nil {
return ""
}
return html.UnescapeString(strings.TrimSpace(m[1]))
}
// nearbyStation is a station with its distance from a reference point.
type nearbyStation struct {
gaugeStation
DistanceKm float64
}
// stationsNear returns the stations within radiusKm of p, nearest first.
func stationsNear(stations []gaugeStation, p place, radiusKm float64) []nearbyStation {
var out []nearbyStation
for _, s := range stations {
if d := distanceKm(p.Latitude, p.Longitude, s.Latitude, s.Longitude); d <= radiusKm {
out = append(out, nearbyStation{gaugeStation: s, DistanceKm: d})
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].DistanceKm < out[j].DistanceKm })
return out
}
// distanceKm is the haversine great-circle distance.
func distanceKm(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKm = 6371
rad := func(d float64) float64 { return d * math.Pi / 180 }
dLat, dLon := rad(lat2-lat1), rad(lon2-lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * earthRadiusKm * math.Asin(math.Sqrt(a))
}
@@ -0,0 +1,59 @@
package weather
import (
"encoding/json"
"os"
"testing"
)
func loadStationMap(t *testing.T, name string) stationMap {
t.Helper()
data, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatal(err)
}
var m stationMap
if err := json.Unmarshal(data, &m); err != nil {
t.Fatal(err)
}
return m
}
func TestParseStationMap_SkipsStationsWithoutReadings(t *testing.T) {
stations := parseStationMap(loadStationMap(t, "vndms_lv0.json"))
// The fixture has 9 features; Việt Lâm and the duplicate Phước Hòa and
// Đồng Hới entries have no data, leaving 6 readings.
if len(stations) != 6 {
t.Fatalf("stations = %d, want 6: %+v", len(stations), stations)
}
var phuAn gaugeStation
for _, s := range stations {
if s.Name == "Việt Lâm" {
t.Error("Việt Lâm has no reading and should be skipped")
}
if s.Name == "Phú An" {
phuAn = s
}
}
want := gaugeStation{Code: "71600", Name: "Phú An", River: "Sài Gòn", Province: "TP. Hồ Chí Minh",
Latitude: phuAn.Latitude, Longitude: phuAn.Longitude, Level: 1.33, Hour: 7, Day: 2, Month: 10}
if phuAn != want {
t.Errorf("Phú An = %+v, want %+v", phuAn, want)
}
}
func TestStationsNear_TanThuan(t *testing.T) {
near := stationsNear(parseStationMap(loadStationMap(t, "vndms_lv0.json")), tanThuanPlace, nearbyRadiusKm)
want := []string{"Phú An", "Nhà Bè", "Biên Hòa", "Thủ Dầu Một"}
if len(near) != len(want) {
t.Fatalf("near = %d stations, want %v", len(near), want)
}
for i, s := range near {
if s.Name != want[i] {
t.Errorf("near[%d] = %s, want %s", i, s.Name, want[i])
}
}
if d := near[0].DistanceKm; d < 2.5 || d > 3.3 {
t.Errorf("Phú An distance = %.2f km, want about 2.9", d)
}
}
@@ -1,8 +1,12 @@
// Package thoitiet is the weather 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. Data comes from Open-Meteo, which needs no API key.
package thoitiet
// 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.
package weather
import (
"context"
@@ -16,6 +20,8 @@ import (
"github.com/tiennm99/miti99bot/internal/log"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
"github.com/tiennm99/miti99bot/internal/modules/util/subscription"
"github.com/tiennm99/miti99bot/internal/storage"
)
const (
@@ -46,9 +52,19 @@ var (
weekView = view{command: "thoitiettuannay", ready: hasDays(1), render: formatWeek}
)
// New is the thoitiet module Factory. The module keeps no state.
func New(_ modules.Deps) modules.Module {
// CollectionName is the module's registry key and storage collection, which
// holds the /thuyvan alert subscribers.
const CollectionName = "weather"
// New is the weather module Factory. Only the flood alert keeps state: its
// subscriber list and last-push date.
func New(deps modules.Deps) modules.Module {
client := &http.Client{Timeout: httpTimeout}
fl := &flood{
client: &http.Client{Timeout: floodFetchTimeout},
subscribers: storage.Typed[subscription.Doc](deps.Store),
pushDate: storage.Typed[subscription.DayDoc](deps.Store),
}
command := func(name, description string, v view) modules.Command {
return modules.Command{
Name: name,
@@ -59,12 +75,13 @@ func New(_ modules.Deps) modules.Module {
}
}
return modules.Module{
Commands: []modules.Command{
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),
},
}, fl.commands()...),
Crons: fl.crons(),
}
}
@@ -85,7 +102,7 @@ func handler(client *http.Client, v view) modules.CommandHandler {
err = fmt.Errorf("forecast: missing rows for /%s", v.command)
}
if err != nil {
log.Error("weather fetch failed", "module", "thoitiet", "command", v.command, "err", err)
log.Error("weather fetch failed", "module", "weather", "command", v.command, "err", err)
return chathelper.Reply(ctx, b, msg, fetchErrorText)
}
return chathelper.Reply(ctx, b, msg, v.render(p, f))
@@ -1,4 +1,4 @@
package thoitiet
package weather
import (
"context"
@@ -82,12 +82,12 @@ func stubOpenMeteo(t *testing.T, f *fakeOpenMeteo) {
t.Cleanup(func() { geocodeURL, forecastURL = origGeocode, origForecast })
}
func installThoitiet(t *testing.T) *testutil.RecordingBot {
func installWeather(t *testing.T) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("thoitiet")})
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(CollectionName)})
reg := &modules.Registry{
Modules: []modules.Module{{Name: "thoitiet", Commands: mod.Commands}},
Modules: []modules.Module{{Name: CollectionName, Commands: mod.Commands}},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
@@ -103,28 +103,39 @@ func send(rb *testutil.RecordingBot, text string) string {
}
func TestCommands_RegistrationAndParameters(t *testing.T) {
mod := New(modules.Deps{})
want := []string{"thoitiethomnay", "thoitiet", "thoitietngaymai", "thoitiettuannay"}
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(CollectionName)})
want := []struct{ name, parameters string }{
{"thoitiethomnay", "[location...]"},
{"thoitiet", "[location...]"},
{"thoitietngaymai", "[location...]"},
{"thoitiettuannay", "[location...]"},
{"thuyvan", ""},
{"thuyvan_subscribe", ""},
{"thuyvan_unsubscribe", ""},
}
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.Name != want[i].name {
t.Errorf("commands[%d] = %q, want %q", i, c.Name, want[i].name)
}
if c.Parameters != "[location...]" {
t.Errorf("/%s parameters = %q", c.Name, c.Parameters)
if c.Parameters != want[i].parameters {
t.Errorf("/%s parameters = %q, want %q", c.Name, c.Parameters, want[i].parameters)
}
if c.Visibility != modules.VisibilityPublic {
t.Errorf("/%s is not public", c.Name)
}
}
if len(mod.Crons) != 2 || mod.Crons[0].Schedule != "30 3 * * *" || mod.Crons[1].Schedule != "30 5 * * *" {
t.Errorf("crons = %+v, want the 10:30 ICT flood push and its 12:30 retry", mod.Crons)
}
}
func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(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" +
@@ -150,7 +161,7 @@ func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) {
func TestHourly_ListsNextSixHours(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(t)
want := "🕐 Thời tiết 6 giờ tới — 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" +
@@ -175,7 +186,7 @@ func TestHourly_ListsNextSixHours(t *testing.T) {
func TestHourly_NoUpcomingHoursRepliesError(t *testing.T) {
stale := strings.Replace(forecastFixture, `"time":"2026-10-01T10:30"`, `"time":"2026-10-01T16:30"`, 1)
stubOpenMeteo(t, &fakeOpenMeteo{forecastBody: stale})
rb := installThoitiet(t)
rb := installWeather(t)
if got := send(rb, "/thoitiet"); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)
@@ -185,7 +196,7 @@ func TestHourly_NoUpcomingHoursRepliesError(t *testing.T) {
func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) {
f := &fakeOpenMeteo{geocodeBody: daLatGeocodeFixture, forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(t)
got := send(rb, "/thoitietngaymai Đà Lạt ")
want := "🌦️ Thời tiết ngày mai T6 02/10 — Ðà Lạt, Lam Dong\n" +
@@ -207,7 +218,7 @@ func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) {
func TestWeek_ListsSevenDays(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(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" +
@@ -231,7 +242,7 @@ func TestAliasExpansionAndForeignPlace(t *testing.T) {
forecastBody: forecastFixture,
}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(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") {
@@ -246,7 +257,7 @@ func TestAliasExpansionAndForeignPlace(t *testing.T) {
func TestUnknownLocation(t *testing.T) {
f := &fakeOpenMeteo{geocodeBody: `{"generationtime_ms":0.3}`}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(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)
@@ -266,7 +277,7 @@ func TestUpstreamFailureRepliesError(t *testing.T) {
for name, f := range cases {
t.Run(name, func(t *testing.T) {
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(t)
cmd := "/thoitiet"
if f.geocodeBody != "" {
cmd = "/thoitiet Hue"
@@ -284,7 +295,7 @@ func TestTomorrow_NeedsTwoDailyRows(t *testing.T) {
).Replace(forecastFixture)
f := &fakeOpenMeteo{forecastBody: oneDay}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
rb := installWeather(t)
if got := send(rb, "/thoitietngaymai"); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)