feat(thoitiet): add today, tomorrow, and 7-day weather commands

This commit is contained in:
tiennm99 committed 2026-10-01 10:28:10 +07:00
1 parent 4607ae5f14
commit f748d34095
11 files changed
+956

No files matched your search

+1
View File
@@ -21,6 +21,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 |
| `thoitiet` | Weather from Open-Meteo: `/thoitiethomnay` (alias `/thoitiet`) 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 |
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
View File
@@ -94,6 +94,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...]",
"trongtruonghop": "[target...]",
"tth": "[target...]",
"wheelofnames": "<option,...>",
+2
View File
@@ -34,6 +34,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/sticker"
"github.com/tiennm99/miti99bot/internal/modules/stock"
"github.com/tiennm99/miti99bot/internal/modules/thoitiet"
"github.com/tiennm99/miti99bot/internal/modules/util"
"github.com/tiennm99/miti99bot/internal/modules/wordle"
"github.com/tiennm99/miti99bot/internal/server"
@@ -103,6 +104,7 @@ func factories() map[string]modules.Factory {
sticker.CollectionName: sticker.New,
"alias": alias.New,
"blacklist": blacklist.New,
"thoitiet": thoitiet.New,
}
}
+139
View File
@@ -0,0 +1,139 @@
package thoitiet
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// Open-Meteo endpoints. Both are free for non-commercial use with no API key
// (under 10,000 calls a day). Variables so tests can point them at an
// httptest server.
var (
geocodeURL = "https://geocoding-api.open-meteo.com/v1/search"
forecastURL = "https://api.open-meteo.com/v1/forecast"
)
const (
httpTimeout = 5 * time.Second
maxBodySize = 1 << 20
// geocodeCount is how many candidates to fetch so a Vietnamese match can
// win over a same-named place abroad.
geocodeCount = 10
// forecastDays covers today plus the six days after it.
forecastDays = 7
currentFields = "temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m"
dailyFields = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum," +
"precipitation_probability_max,uv_index_max,sunrise,sunset"
)
// place is one geocoding result.
type place struct {
Name string `json:"name"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
CountryCode string `json:"country_code"`
Country string `json:"country"`
Admin1 string `json:"admin1"`
}
type geocodeResponse struct {
Results []place `json:"results"`
}
// currentWeather is the "current" block of a forecast response.
type currentWeather struct {
Temperature float64 `json:"temperature_2m"`
ApparentTemperature float64 `json:"apparent_temperature"`
Humidity float64 `json:"relative_humidity_2m"`
WeatherCode int `json:"weather_code"`
WindSpeed float64 `json:"wind_speed_10m"`
}
// dailyWeather holds parallel per-day arrays; index 0 is today in the
// location's own timezone. A null upstream value decodes as zero.
type dailyWeather struct {
Time []string `json:"time"`
WeatherCode []int `json:"weather_code"`
TemperatureMax []float64 `json:"temperature_2m_max"`
TemperatureMin []float64 `json:"temperature_2m_min"`
PrecipitationSum []float64 `json:"precipitation_sum"`
PrecipitationProbability []float64 `json:"precipitation_probability_max"`
UVIndexMax []float64 `json:"uv_index_max"`
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
}
type forecast struct {
Current currentWeather `json:"current"`
Daily dailyWeather `json:"daily"`
}
// days reports how many complete daily rows the response carries, the length
// of the shortest per-day array, so renderers can index every array safely.
func (d dailyWeather) days() int {
return min(len(d.Time), len(d.WeatherCode), len(d.TemperatureMax), len(d.TemperatureMin),
len(d.PrecipitationSum), len(d.PrecipitationProbability), len(d.UVIndexMax),
len(d.Sunrise), len(d.Sunset))
}
// geocode returns Open-Meteo's matches for name, with Vietnamese labels.
func geocode(ctx context.Context, client *http.Client, name string) ([]place, error) {
q := url.Values{}
q.Set("name", name)
q.Set("count", strconv.Itoa(geocodeCount))
q.Set("language", "vi")
q.Set("format", "json")
var body geocodeResponse
if err := getJSON(ctx, client, geocodeURL+"?"+q.Encode(), &body); err != nil {
return nil, fmt.Errorf("geocode: %w", err)
}
return body.Results, nil
}
// fetchForecast returns current conditions and a 7-day daily forecast. The
// timezone is the location's own, so "today" is its local date.
func fetchForecast(ctx context.Context, client *http.Client, p place) (forecast, error) {
q := url.Values{}
q.Set("latitude", strconv.FormatFloat(p.Latitude, 'f', -1, 64))
q.Set("longitude", strconv.FormatFloat(p.Longitude, 'f', -1, 64))
q.Set("timezone", "auto")
q.Set("forecast_days", strconv.Itoa(forecastDays))
q.Set("current", currentFields)
q.Set("daily", dailyFields)
var body forecast
if err := getJSON(ctx, client, forecastURL+"?"+q.Encode(), &body); err != nil {
return forecast{}, fmt.Errorf("forecast: %w", err)
}
if body.Daily.days() == 0 {
return forecast{}, fmt.Errorf("forecast: no daily data")
}
return body, nil
}
func getJSON(ctx context.Context, client *http.Client, rawURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("build request: %w", 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 {
return fmt.Errorf("decode: %w", err)
}
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package thoitiet
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"}
// 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")
}
+88
View File
@@ -0,0 +1,88 @@
package thoitiet
import (
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"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.
var hcmPlace = place{
Name: "Thành phố Hồ Chí Minh",
Latitude: 10.82302,
Longitude: 106.62965,
CountryCode: "VN",
Country: "Việt Nam",
}
// hcmAliases are normalized spellings that mean Ho Chi Minh City. Geocoding
// finds nothing for the short forms and a Bình Thạnh ward for "saigon".
var hcmAliases = map[string]bool{
"hcm": true,
"tphcm": true,
"tp hcm": true,
"tp.hcm": true,
"sg": true,
"saigon": true,
"sai gon": true,
"ho chi minh": true,
"tp ho chi minh": true,
"thanh pho ho chi minh": true,
}
// queryAliases expands common short forms the geocoder does not know.
var queryAliases = map[string]string{
"hn": "Ha Noi",
"dn": "Da Nang",
}
// 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)
// normalizeQuery lowercases a location, removes Vietnamese diacritics, and
// collapses whitespace. Đ is not a decomposable letter, so it is mapped by
// hand, together with the look-alike Ð (U+00D0) that GeoNames uses in names
// such as "Ðà Lạt"; leaving either in place makes the geocoder miss the city.
func normalizeQuery(s string) string {
s = strings.NewReplacer("Đ", "D", "đ", "d", "Ð", "D", "ð", "d").Replace(s)
if stripped, _, err := transform.String(stripMarks, s); err == nil {
s = stripped
}
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
// pickPlace prefers the first Vietnamese match, since most lookups are for
// Vietnamese places and the geocoder may rank a same-named place abroad first,
// and otherwise falls back to the top result.
func pickPlace(results []place) (place, bool) {
for _, p := range results {
if p.CountryCode == "VN" {
return p, true
}
}
if len(results) == 0 {
return place{}, false
}
return results[0], true
}
// displayName labels a place with its province when that adds information,
// and with its country when it is outside Vietnam. The province is skipped
// when it only respells the name, as in "Hà Nội" with admin1 "Hanoi".
func displayName(p place) string {
parts := []string{p.Name}
if p.Admin1 != "" && compactName(p.Admin1) != compactName(p.Name) {
parts = append(parts, p.Admin1)
}
if p.CountryCode != "VN" && p.Country != "" {
parts = append(parts, p.Country)
}
return strings.Join(parts, ", ")
}
func compactName(s string) string { return strings.ReplaceAll(normalizeQuery(s), " ", "") }
@@ -0,0 +1,56 @@
package thoitiet
import "testing"
func TestNormalizeQuery(t *testing.T) {
cases := map[string]string{
"": "",
" Đà Lạt ": "da lat",
"Ðà Lạt": "da lat", // GeoNames' look-alike Ð (U+00D0)
"Hồ Chí Minh": "ho chi minh",
"Thừa Thiên Huế": "thua thien hue",
"Sài Gòn": "sai gon",
"TP.HCM": "tp.hcm",
"New York": "new york",
"Buôn Ma Thuột": "buon ma thuot",
"Phan Rang–Tháp Chàm": "phan rang–thap cham",
}
for in, want := range cases {
if got := normalizeQuery(in); got != want {
t.Errorf("normalizeQuery(%q) = %q, want %q", in, got, want)
}
}
}
func TestPickPlace(t *testing.T) {
jp := place{Name: "Tokyo", CountryCode: "JP"}
vn := place{Name: "Huế", CountryCode: "VN"}
et := place{Name: "Humera", CountryCode: "ET"}
if got, ok := pickPlace([]place{et, vn}); !ok || got != vn {
t.Errorf("pickPlace prefers VN: got %+v, %v", got, ok)
}
if got, ok := pickPlace([]place{jp, et}); !ok || got != jp {
t.Errorf("pickPlace falls back to first: got %+v, %v", got, ok)
}
if _, ok := pickPlace(nil); ok {
t.Error("pickPlace(nil) ok = true, want false")
}
}
func TestDisplayName(t *testing.T) {
cases := []struct {
p place
want string
}{
{hcmPlace, "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"},
}
for _, c := range cases {
if got := displayName(c.p); got != c.want {
t.Errorf("displayName(%+v) = %q, want %q", c.p, got, c.want)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
// Package thoitiet is the weather module: /thoitiethomnay (alias /thoitiet),
// /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
import (
"context"
"errors"
"fmt"
"net/http"
"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"
)
const (
locationParameter = "[location...]"
fetchErrorText = "Không lấy được dữ liệu thời tiết. Thử lại sau nhé."
)
// errPlaceNotFound means the geocoder had no match for the user's location.
var errPlaceNotFound = errors.New("place not found")
// view is one forecast command's renderer plus the daily rows it reads.
type view struct {
command string
minDays int
render func(place, forecast) string
}
var (
todayView = view{command: "thoitiethomnay", minDays: 1, render: formatToday}
tomorrowView = view{command: "thoitietngaymai", minDays: 2, render: formatTomorrow}
weekView = view{command: "thoitiettuannay", minDays: 1, render: formatWeek}
)
// New is the thoitiet module Factory. The module keeps no state.
func New(_ modules.Deps) modules.Module {
client := &http.Client{Timeout: httpTimeout}
command := func(name, description string, v view) modules.Command {
return modules.Command{
Name: name,
Visibility: modules.VisibilityPublic,
Description: description,
Parameters: locationParameter,
Handler: handler(client, v),
}
}
return modules.Module{
Commands: []modules.Command{
command("thoitiethomnay", "Thời tiết hôm nay (mặc định TP.HCM)", todayView),
command("thoitiet", "Thời tiết hôm nay (mặc định TP.HCM)", todayView),
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),
},
}
}
func handler(client *http.Client, v view) modules.CommandHandler {
return func(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
query := chathelper.ArgAfterCommand(msg.Text)
fetchCtx, cancel := chathelper.FetchContext(ctx)
p, f, err := lookup(fetchCtx, client, query)
cancel()
if errors.Is(err, errPlaceNotFound) {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Không tìm thấy địa điểm %q.", query))
}
if err == nil && f.Daily.days() < v.minDays {
err = fmt.Errorf("forecast: %d daily rows, need %d", f.Daily.days(), v.minDays)
}
if err != nil {
log.Error("weather fetch failed", "module", "thoitiet", "command", v.command, "err", err)
return chathelper.Reply(ctx, b, msg, fetchErrorText)
}
return chathelper.Reply(ctx, b, msg, v.render(p, f))
}
}
// lookup resolves the user's location and fetches its forecast. An empty
// query 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 {
return place{}, forecast{}, err
}
f, err := fetchForecast(ctx, client, p)
return p, f, err
}
func resolvePlace(ctx context.Context, client *http.Client, query string) (place, error) {
q := normalizeQuery(query)
if q == "" || hcmAliases[q] {
return hcmPlace, nil
}
if expanded, ok := queryAliases[q]; ok {
q = expanded
}
results, err := geocode(ctx, client, q)
if err != nil {
return place{}, err
}
p, ok := pickPlace(results)
if !ok {
return place{}, errPlaceNotFound
}
return p, nil
}
+250
View File
@@ -0,0 +1,250 @@
package thoitiet
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// forecastFixture mirrors a live Open-Meteo response for Ho Chi Minh City.
const forecastFixture = `{
"current":{"temperature_2m":29.0,"apparent_temperature":35.7,"relative_humidity_2m":78,"weather_code":3,"wind_speed_10m":3.2},
"daily":{
"time":["2026-10-01","2026-10-02","2026-10-03","2026-10-04","2026-10-05","2026-10-06","2026-10-07"],
"weather_code":[80,80,81,95,95,95,53],
"temperature_2m_max":[32.7,31.8,32.0,32.2,32.5,31.1,31.1],
"temperature_2m_min":[24.3,23.9,24.2,23.7,24.5,24.0,23.9],
"precipitation_sum":[5.30,7.90,7.00,5.70,13.90,4.00,7.60],
"precipitation_probability_max":[70,88,85,92,98,100,89],
"uv_index_max":[8.90,8.20,8.60,8.40,8.85,7.80,8.70],
"sunrise":["2026-10-01T05:42","2026-10-02T05:42","2026-10-03T05:42","2026-10-04T05:42","2026-10-05T05:42","2026-10-06T05:42","2026-10-07T05:42"],
"sunset":["2026-10-01T17:44","2026-10-02T17:43","2026-10-03T17:42","2026-10-04T17:42","2026-10-05T17:41","2026-10-06T17:40","2026-10-07T17:40"]
}
}`
const daLatGeocodeFixture = `{"results":[
{"name":"Ðà Lạt","latitude":11.94646,"longitude":108.44193,"country_code":"VN","country":"Việt Nam","admin1":"Lam Dong"}
]}`
// fakeOpenMeteo serves both Open-Meteo endpoints for the duration of t and
// records the geocoding queries it received.
type fakeOpenMeteo struct {
geocodeBody string
forecastBody string
forecastStatus int
geocodeQueries []string
forecastCalls atomic.Int32
lastForecastQ map[string]string
}
func stubOpenMeteo(t *testing.T, f *fakeOpenMeteo) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
f.geocodeQueries = append(f.geocodeQueries, r.URL.Query().Get("name"))
_, _ = w.Write([]byte(f.geocodeBody))
case "/forecast":
f.forecastCalls.Add(1)
f.lastForecastQ = map[string]string{}
for k := range r.URL.Query() {
f.lastForecastQ[k] = r.URL.Query().Get(k)
}
if f.forecastStatus != 0 {
http.Error(w, "unavailable", f.forecastStatus)
return
}
_, _ = w.Write([]byte(f.forecastBody))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
origGeocode, origForecast := geocodeURL, forecastURL
geocodeURL, forecastURL = server.URL+"/search", server.URL+"/forecast"
t.Cleanup(func() { geocodeURL, forecastURL = origGeocode, origForecast })
}
func installThoitiet(t *testing.T) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("thoitiet")})
reg := &modules.Registry{
Modules: []modules.Module{{Name: "thoitiet", Commands: mod.Commands}},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{})
return rb
}
func send(rb *testutil.RecordingBot, text string) string {
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
return rb.LastSent().Text()
}
func TestCommands_RegistrationAndParameters(t *testing.T) {
mod := New(modules.Deps{})
want := []string{"thoitiethomnay", "thoitiet", "thoitietngaymai", "thoitiettuannay"}
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.Parameters != "[location...]" {
t.Errorf("/%s parameters = %q", c.Name, c.Parameters)
}
if c.Visibility != modules.VisibilityPublic {
t.Errorf("/%s is not public", c.Name)
}
}
}
func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(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" +
"Độ ẩ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", "/thoitiet", "/thoitiet hcm", "/thoitiet Sài Gòn"} {
if got := send(rb, cmd); got != want {
t.Errorf("%s reply =\n%s\nwant\n%s", cmd, got, want)
}
}
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" {
t.Errorf("forecast query = %v", f.lastForecastQ)
}
}
func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) {
f := &fakeOpenMeteo{geocodeBody: daLatGeocodeFixture, forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
got := send(rb, "/thoitietngaymai Đà Lạt ")
want := "🌦️ Thời tiết ngày mai T6 02/10 — Ðà Lạt, Lam Dong\n" +
"Dự báo: 24–32°C, Mưa rào nhẹ 🌦️\n" +
"Khả năng mưa 88% (7,9 mm), UV 8,2\n" +
"Mặt trời mọc 05:42, lặn 17:43\n" +
"Nguồn: Open-Meteo"
if got != want {
t.Errorf("reply =\n%s\nwant\n%s", got, want)
}
if len(f.geocodeQueries) != 1 || f.geocodeQueries[0] != "da lat" {
t.Errorf("geocode queries = %q, want [\"da lat\"]", f.geocodeQueries)
}
if f.lastForecastQ["latitude"] != "11.94646" {
t.Errorf("forecast latitude = %q", f.lastForecastQ["latitude"])
}
}
func TestWeek_ListsSevenDays(t *testing.T) {
f := &fakeOpenMeteo{forecastBody: forecastFixture}
stubOpenMeteo(t, f)
rb := installThoitiet(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" +
"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" +
"CN 04/10: 24–32°C ⛈️ Dông, mưa 92%\n" +
"T2 05/10: 25–33°C ⛈️ Dông, mưa 98%\n" +
"T3 06/10: 24–31°C ⛈️ Dông, mưa 100%\n" +
"T4 07/10: 24–31°C 🌦️ Mưa phùn, mưa 89%\n" +
"Nguồn: Open-Meteo"
if got := send(rb, "/thoitiettuannay"); got != want {
t.Errorf("reply =\n%s\nwant\n%s", got, want)
}
}
func TestAliasExpansionAndForeignPlace(t *testing.T) {
f := &fakeOpenMeteo{
geocodeBody: `{"results":[
{"name":"Tokyo","latitude":35.6895,"longitude":139.69171,"country_code":"JP","country":"Nhật Bản","admin1":"Tokyo"}
]}`,
forecastBody: forecastFixture,
}
stubOpenMeteo(t, f)
rb := installThoitiet(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") {
t.Errorf("reply header = %q", strings.SplitN(got, "\n", 2)[0])
}
send(rb, "/thoitiet HN")
if want := []string{"tokyo", "Ha Noi"}; strings.Join(f.geocodeQueries, "|") != strings.Join(want, "|") {
t.Errorf("geocode queries = %q, want %q", f.geocodeQueries, want)
}
}
func TestUnknownLocation(t *testing.T) {
f := &fakeOpenMeteo{geocodeBody: `{"generationtime_ms":0.3}`}
stubOpenMeteo(t, f)
rb := installThoitiet(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)
}
if n := f.forecastCalls.Load(); n != 0 {
t.Errorf("forecast calls = %d, want 0", n)
}
}
func TestUpstreamFailureRepliesError(t *testing.T) {
cases := map[string]*fakeOpenMeteo{
"forecast non-200": {forecastStatus: http.StatusServiceUnavailable},
"forecast bad json": {forecastBody: "<html>"},
"no daily rows": {forecastBody: `{"daily":{"time":[]}}`},
"geocode bad json": {geocodeBody: "<html>"},
}
for name, f := range cases {
t.Run(name, func(t *testing.T) {
stubOpenMeteo(t, f)
rb := installThoitiet(t)
cmd := "/thoitiet"
if f.geocodeBody != "" {
cmd = "/thoitiet Hue"
}
if got := send(rb, cmd); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)
}
})
}
}
func TestTomorrow_NeedsTwoDailyRows(t *testing.T) {
oneDay := strings.NewReplacer(
`,"2026-10-02","2026-10-03","2026-10-04","2026-10-05","2026-10-06","2026-10-07"`, "",
).Replace(forecastFixture)
f := &fakeOpenMeteo{forecastBody: oneDay}
stubOpenMeteo(t, f)
rb := installThoitiet(t)
if got := send(rb, "/thoitietngaymai"); got != fetchErrorText {
t.Errorf("reply = %q, want %q", got, fetchErrorText)
}
if got := send(rb, "/thoitiettuannay"); !strings.Contains(got, "Thời tiết 1 ngày tới") {
t.Errorf("week reply = %q, want a one-day list", got)
}
}
@@ -0,0 +1,28 @@
# Plan: `thoitiet` weather module
Status: done (2026-10-01). Design and decisions:
[research report](../reports/research-261001-0900-thoitiet-weather-module.md).
## Outcome
Public commands `/thoitiethomnay` (alias `/thoitiet`), `/thoitietngaymai`, and
`/thoitiettuannay`, each taking `[location...]` and defaulting to Ho Chi Minh
City, backed by Open-Meteo geocoding and forecast APIs.
## Steps
1. `internal/modules/thoitiet`: `thoitiet.go` (registration + handlers),
`api_client.go` (geocode + forecast), `location.go` (diacritic stripping,
aliases, result choice), `format.go` (WMO labels, three renderers).
2. Tests for location parsing, formatting, and handlers against `httptest`.
3. Wire `"thoitiet": thoitiet.New` in `cmd/server/main.go`; pin the new
parameter strings in `cmd/server/command_menu_test.go`.
4. README module table row.
## Acceptance criteria
- No argument shows Ho Chi Minh City without a geocoding request.
- `Đà Lạt`, `Da Lat`, `hcm`, `saigon`, and foreign names like `Tokyo` resolve.
- `/thoitiettuannay` lists 7 days starting today in the location's timezone.
- Unknown location and upstream failure reply with Vietnamese error text.
- `go test ./...`, `go vet ./...`, and `golangci-lint run` pass.
@@ -0,0 +1,123 @@
# Research + Brainstorm: `thoitiet` weather module
Conducted 2026-10-01. Scope: weather data source and command design for a new
`thoitiet` module with today, tomorrow, and this-week forecasts, an optional
location argument, and Ho Chi Minh City as the default.
## Recommendation
Build a new `internal/modules/thoitiet` module on **Open-Meteo** (forecast +
geocoding). It needs no API key, no new env var, and no storage. All payload
fields below were verified with live requests on 2026-10-01.
## Commands
| Command | Parameters | Behaviour |
|---|---|---|
| `/thoitiethomnay` | `[location...]` | Current conditions + today's forecast |
| `/thoitiet` | `[location...]` | Alias of `/thoitiethomnay` (same handler) |
| `/thoitietngaymai` | `[location...]` | Tomorrow's daily forecast |
| `/thoitiettuannay` | `[location...]` | One line per day for the next 7 days, starting today |
`[location...]` follows `docs/command-parameter-conventions.md` (optional
remaining text, so `/thoitiet Đà Lạt` works). An empty argument means Ho Chi
Minh City. The alias is registered as a second `modules.Command` sharing the
handler, the same pattern as `/trongtruonghop` + `/tth` in `misc`. Stats will
count the two names separately, which matches that existing alias.
## Data source comparison
| Source | Key | Free limit | VN geocoding | Verdict |
|---|---|---|---|---|
| Open-Meteo | None | 10,000/day, 5,000/h, 600/min, non-commercial | Yes, `language=vi` returns Vietnamese names | **Pick** |
| OpenWeatherMap | Required | 1,000/day (One Call 3.0) | Yes | Needs secret + card on file |
| WeatherAPI.com | Required | 1M/month | Yes, `lang=vi` | Needs secret; no benefit here |
A personal Telegram bot is non-commercial and nowhere near the limits, so
Open-Meteo is fine without caching. Its data is CC BY 4.0, so replies end with a
short `Nguồn: Open-Meteo` credit.
## Verified API behaviour
Geocoding: `GET https://geocoding-api.open-meteo.com/v1/search?name=<q>&count=10&language=vi`
| Query | Result |
|---|---|
| `Ho Chi Minh`, `Hồ Chí Minh` | Thành phố Hồ Chí Minh (10.823, 106.630) |
| `Ha Noi`, `Hanoi` | Hà Nội |
| `Da Lat` | Ðà Lạt |
| `Đà Lạt` (typed with diacritics) | **Wrong: "Đã Tịch", Quảng Trị** |
| `Da Nang`, `Can Tho`, `Nha Trang`, `Hue`, `Vung Tau`, `Tokyo` | Correct |
| `hcm`, `Thu Duc` | No results |
| `Saigon` | A ward in Bình Thạnh, not the city |
Two consequences for the design. First, strip Vietnamese diacritics (including
`Đ/đ` → `D/d`) before querying, because GeoNames stores Đà Lạt with the
look-alike `Ð` (U+00D0) and diacritic input misses it. Second, keep a small
alias map for common shorthand: `hcm`, `tphcm`, `sg`, `saigon`, `sai gon` → HCM;
`hn` → Hà Nội; `dn` → Đà Nẵng. Pick the first `country_code=VN` result, else the
first result, so foreign cities still work in a single request.
Forecast: `GET https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&timezone=auto&forecast_days=7`
with
- `current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m`
- `daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunrise,sunset`
`timezone=auto` makes "today" the location's own date. HCM returns
`utc_offset_seconds=25200`. Weather is a WMO code that maps to a Vietnamese
label plus emoji (about 28 codes, for example 0 Trời quang ☀️, 3 Nhiều mây ☁️,
61–65 Mưa 🌧️, 80–82 Mưa rào 🌦️, 95–99 Dông ⛈️).
## Design
```text
handler(range) ── parse args ──► resolveLocation(q)
├─ empty → HCM constant (no geocoding call)
├─ alias map → constant
└─ geocode(stripDiacritics(q))
──► fetchForecast(lat, lon) ──► format(range) ──► send HTML
```
Files, mirroring `lol` and `giaxang`:
- `thoitiet.go`: `New(deps)` and the four command registrations.
- `api_client.go`: geocode + forecast with an `*http.Client` timeout,
`io.LimitReader`, and package-level URL vars so tests can use `httptest`.
- `location.go`: diacritic stripping, alias map, result selection.
- `format.go`: WMO table and the today/tomorrow/week renderers.
- `handlers.go` and tests for each file.
- Wiring: `cmd/server/main.go` catalog entry `"thoitiet": thoitiet.New`, the
expected `cmd/server/command_menu_test.go` entries, and the README module table.
Sample `/thoitiet` reply:
```text
🌤 Thời tiết hôm nay — Thành phố Hồ Chí Minh
Hiện tại: 29°C (cảm giác 35.7°C), Nhiều mây ☁️, độ ẩm 78%, gió 3 km/h
Cả ngày: 24–33°C, Mưa rào 🌦️, khả năng mưa 70% (5.3 mm), UV 8.9
Mặt trời: 05:42 – 17:44
Nguồn: Open-Meteo
```
Errors: unknown location → `Không tìm thấy địa điểm "<q>".`; upstream failure →
`Không lấy được dữ liệu thời tiết. Thử lại sau nhé.` (same tone as `/giaxang`).
## Rejected options
- Caching forecasts in Mongo: the limits make it unnecessary, and it adds a
storage schema for no user-visible gain.
- A per-user saved default location: not requested.
- A keyed provider (OWM, WeatherAPI): adds a secret and setup for no gain.
## Next steps
1. Write the plan under `plans/261001-…-thoitiet-weather-module/` and implement.
2. Run `go test ./internal/modules/thoitiet/... ./cmd/server/...`, `go vet ./...`,
and `golangci-lint run`.
## Decisions
- `/thoitiet` is an alias of `/thoitiethomnay` (user request, 2026-10-01).
- `/thoitiettuannay` covers the next 7 days starting today, not the Mon–Sun
calendar week (user choice, 2026-10-01).