mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
feat(thoitiet): make /thoitiet an hourly forecast for the next 6 hours
This commit is contained in:
1 parent
f748d34095
commit
2fd362dd01
5 files changed
+139
-17
No files matched your search
@@ -21,7 +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 |
|
||||
| `thoitiet` | Weather from Open-Meteo: `/thoitiet` for the next 6 hours hour by hour, `/thoitiethomnay` for current conditions and today, `/thoitietngaymai` for tomorrow, `/thoitiettuannay` for the next 7 days. Each takes `[location...]` (Vietnamese with or without diacritics, shorthand like `hcm`/`hn`, or a foreign city); the default is Ho Chi Minh City |
|
||||
|
||||
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
|
||||
|
||||
@@ -28,10 +28,15 @@ const (
|
||||
geocodeCount = 10
|
||||
// forecastDays covers today plus the six days after it.
|
||||
forecastDays = 7
|
||||
// forecastHours is the hour in progress plus the six after it; hourly
|
||||
// data starts at the current hour, which the hourly view skips.
|
||||
forecastHours = 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"
|
||||
hourlyFields = "temperature_2m,apparent_temperature,weather_code,precipitation_probability,precipitation," +
|
||||
"relative_humidity_2m,wind_speed_10m"
|
||||
)
|
||||
|
||||
// place is one geocoding result.
|
||||
@@ -50,6 +55,7 @@ type geocodeResponse struct {
|
||||
|
||||
// currentWeather is the "current" block of a forecast response.
|
||||
type currentWeather struct {
|
||||
Time string `json:"time"`
|
||||
Temperature float64 `json:"temperature_2m"`
|
||||
ApparentTemperature float64 `json:"apparent_temperature"`
|
||||
Humidity float64 `json:"relative_humidity_2m"`
|
||||
@@ -71,11 +77,47 @@ type dailyWeather struct {
|
||||
Sunset []string `json:"sunset"`
|
||||
}
|
||||
|
||||
// hourlyWeather holds parallel per-hour arrays in the location's local time,
|
||||
// starting at the hour in progress.
|
||||
type hourlyWeather struct {
|
||||
Time []string `json:"time"`
|
||||
Temperature []float64 `json:"temperature_2m"`
|
||||
ApparentTemperature []float64 `json:"apparent_temperature"`
|
||||
WeatherCode []int `json:"weather_code"`
|
||||
PrecipitationProbability []float64 `json:"precipitation_probability"`
|
||||
Precipitation []float64 `json:"precipitation"`
|
||||
Humidity []float64 `json:"relative_humidity_2m"`
|
||||
WindSpeed []float64 `json:"wind_speed_10m"`
|
||||
}
|
||||
|
||||
type forecast struct {
|
||||
Current currentWeather `json:"current"`
|
||||
Hourly hourlyWeather `json:"hourly"`
|
||||
Daily dailyWeather `json:"daily"`
|
||||
}
|
||||
|
||||
// hours reports how many complete hourly rows the response carries.
|
||||
func (h hourlyWeather) hours() int {
|
||||
return min(len(h.Time), len(h.Temperature), len(h.ApparentTemperature), len(h.WeatherCode),
|
||||
len(h.PrecipitationProbability), len(h.Precipitation), len(h.Humidity), len(h.WindSpeed))
|
||||
}
|
||||
|
||||
// upcomingHours returns the indexes of the hourly rows that start after the
|
||||
// current observation time, up to limit. Both use the same local ISO format,
|
||||
// so they compare as strings.
|
||||
func (f forecast) upcomingHours(limit int) []int {
|
||||
var idx []int
|
||||
for i := range f.Hourly.hours() {
|
||||
if len(idx) == limit {
|
||||
break
|
||||
}
|
||||
if f.Hourly.Time[i] > f.Current.Time {
|
||||
idx = append(idx, i)
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -98,7 +140,8 @@ func geocode(ctx context.Context, client *http.Client, name string) ([]place, er
|
||||
return body.Results, nil
|
||||
}
|
||||
|
||||
// fetchForecast returns current conditions and a 7-day daily forecast. The
|
||||
// fetchForecast returns current conditions, the next hours, 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{}
|
||||
@@ -106,7 +149,9 @@ func fetchForecast(ctx context.Context, client *http.Client, p place) (forecast,
|
||||
q.Set("longitude", strconv.FormatFloat(p.Longitude, 'f', -1, 64))
|
||||
q.Set("timezone", "auto")
|
||||
q.Set("forecast_days", strconv.Itoa(forecastDays))
|
||||
q.Set("forecast_hours", strconv.Itoa(forecastHours))
|
||||
q.Set("current", currentFields)
|
||||
q.Set("hourly", hourlyFields)
|
||||
q.Set("daily", dailyFields)
|
||||
var body forecast
|
||||
if err := getJSON(ctx, client, forecastURL+"?"+q.Encode(), &body); err != nil {
|
||||
|
||||
@@ -59,6 +59,31 @@ func conditionFor(code int) condition {
|
||||
// vnWeekdays are the short Vietnamese weekday names, indexed by time.Weekday.
|
||||
var vnWeekdays = [...]string{"CN", "T2", "T3", "T4", "T5", "T6", "T7"}
|
||||
|
||||
// hourlyViewHours is how many upcoming hours /thoitiet lists.
|
||||
const hourlyViewHours = 6
|
||||
|
||||
// formatHourly renders current conditions followed by one block per upcoming
|
||||
// hour.
|
||||
func formatHourly(p place, f forecast) string {
|
||||
c := f.Current
|
||||
cur := conditionFor(c.WeatherCode)
|
||||
h := f.Hourly
|
||||
idx := f.upcomingHours(hourlyViewHours)
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "🕐 Thời tiết %d giờ tới — %s\n", len(idx), displayName(p))
|
||||
fmt.Fprintf(&sb, "Hiện tại %s: %s (cảm giác %s), %s %s\n", clock(c.Time), temp(c.Temperature),
|
||||
temp(c.ApparentTemperature), cur.label, cur.emoji)
|
||||
for _, i := range idx {
|
||||
hc := conditionFor(h.WeatherCode[i])
|
||||
fmt.Fprintf(&sb, "\n%s %s %s, %s (cảm giác %s)\n", clock(h.Time[i]), hc.emoji, hc.label,
|
||||
temp(h.Temperature[i]), temp(h.ApparentTemperature[i]))
|
||||
fmt.Fprintf(&sb, "Mưa %d%% (%s mm), độ ẩm %d%%, gió %s km/h\n", round(h.PrecipitationProbability[i]),
|
||||
decimal(h.Precipitation[i]), round(h.Humidity[i]), decimal(h.WindSpeed[i]))
|
||||
}
|
||||
sb.WriteString("\n" + sourceLine)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatToday renders current conditions plus today's daily summary.
|
||||
func formatToday(p place, f forecast) string {
|
||||
c := f.Current
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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 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
|
||||
|
||||
import (
|
||||
@@ -26,17 +26,24 @@ const (
|
||||
// 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.
|
||||
// view is one forecast command's renderer plus a check that the response
|
||||
// carries the rows it reads.
|
||||
type view struct {
|
||||
command string
|
||||
minDays int
|
||||
ready func(forecast) bool
|
||||
render func(place, forecast) string
|
||||
}
|
||||
|
||||
func hasDays(n int) func(forecast) bool {
|
||||
return func(f forecast) bool { return f.Daily.days() >= n }
|
||||
}
|
||||
|
||||
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}
|
||||
hourlyView = view{command: "thoitiet", render: formatHourly,
|
||||
ready: func(f forecast) bool { return len(f.upcomingHours(hourlyViewHours)) > 0 }}
|
||||
todayView = view{command: "thoitiethomnay", ready: hasDays(1), render: formatToday}
|
||||
tomorrowView = view{command: "thoitietngaymai", ready: hasDays(2), render: formatTomorrow}
|
||||
weekView = view{command: "thoitiettuannay", ready: hasDays(1), render: formatWeek}
|
||||
)
|
||||
|
||||
// New is the thoitiet module Factory. The module keeps no state.
|
||||
@@ -54,7 +61,7 @@ func New(_ modules.Deps) modules.Module {
|
||||
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("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),
|
||||
},
|
||||
@@ -74,8 +81,8 @@ func handler(client *http.Client, v view) modules.CommandHandler {
|
||||
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 && !v.ready(f) {
|
||||
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)
|
||||
|
||||
@@ -15,7 +15,17 @@ import (
|
||||
|
||||
// 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},
|
||||
"current":{"time":"2026-10-01T10:30","temperature_2m":29.0,"apparent_temperature":35.7,"relative_humidity_2m":78,"weather_code":3,"wind_speed_10m":3.2},
|
||||
"hourly":{
|
||||
"time":["2026-10-01T10:00","2026-10-01T11:00","2026-10-01T12:00","2026-10-01T13:00","2026-10-01T14:00","2026-10-01T15:00","2026-10-01T16:00"],
|
||||
"temperature_2m":[30.4,31.4,32.1,32.1,32.2,32.7,31.9],
|
||||
"apparent_temperature":[38.1,40.2,41.3,40.9,40.2,38.2,36.6],
|
||||
"weather_code":[3,3,3,3,51,1,2],
|
||||
"precipitation_probability":[2,2,4,10,23,41,55],
|
||||
"precipitation":[0.00,0.00,0.00,0.00,0.10,0.00,0.00],
|
||||
"relative_humidity_2m":[73,71,68,68,64,57,60],
|
||||
"wind_speed_10m":[3.0,2.4,1.6,1.1,1.4,2.8,5.7]
|
||||
},
|
||||
"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],
|
||||
@@ -123,7 +133,7 @@ func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) {
|
||||
"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"} {
|
||||
for _, cmd := range []string{"/thoitiethomnay", "/thoitiethomnay hcm", "/thoitiethomnay Sài Gòn"} {
|
||||
if got := send(rb, cmd); got != want {
|
||||
t.Errorf("%s reply =\n%s\nwant\n%s", cmd, got, want)
|
||||
}
|
||||
@@ -132,11 +142,46 @@ func TestToday_DefaultsToHCMWithoutGeocoding(t *testing.T) {
|
||||
t.Errorf("geocode called with %v, want no calls", f.geocodeQueries)
|
||||
}
|
||||
if f.lastForecastQ["latitude"] != "10.82302" || f.lastForecastQ["timezone"] != "auto" ||
|
||||
f.lastForecastQ["forecast_days"] != "7" {
|
||||
f.lastForecastQ["forecast_days"] != "7" || f.lastForecastQ["forecast_hours"] != "7" {
|
||||
t.Errorf("forecast query = %v", f.lastForecastQ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourly_ListsNextSixHours(t *testing.T) {
|
||||
f := &fakeOpenMeteo{forecastBody: forecastFixture}
|
||||
stubOpenMeteo(t, f)
|
||||
rb := installThoitiet(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" +
|
||||
"\n11:00 ☁️ Nhiều mây, 31°C (cảm giác 40°C)\n" +
|
||||
"Mưa 2% (0,0 mm), độ ẩm 71%, gió 2,4 km/h\n" +
|
||||
"\n12:00 ☁️ Nhiều mây, 32°C (cảm giác 41°C)\n" +
|
||||
"Mưa 4% (0,0 mm), độ ẩm 68%, gió 1,6 km/h\n" +
|
||||
"\n13:00 ☁️ Nhiều mây, 32°C (cảm giác 41°C)\n" +
|
||||
"Mưa 10% (0,0 mm), độ ẩm 68%, gió 1,1 km/h\n" +
|
||||
"\n14:00 🌦️ Mưa phùn nhẹ, 32°C (cảm giác 40°C)\n" +
|
||||
"Mưa 23% (0,1 mm), độ ẩm 64%, gió 1,4 km/h\n" +
|
||||
"\n15:00 🌤️ Ít mây, 33°C (cảm giác 38°C)\n" +
|
||||
"Mưa 41% (0,0 mm), độ ẩm 57%, gió 2,8 km/h\n" +
|
||||
"\n16:00 ⛅ Mây rải rác, 32°C (cảm giác 37°C)\n" +
|
||||
"Mưa 55% (0,0 mm), độ ẩm 60%, gió 5,7 km/h\n" +
|
||||
"\nNguồn: Open-Meteo"
|
||||
if got := send(rb, "/thoitiet"); got != want {
|
||||
t.Errorf("reply =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if got := send(rb, "/thoitiet"); got != fetchErrorText {
|
||||
t.Errorf("reply = %q, want %q", got, fetchErrorText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTomorrow_GeocodesDiacriticLocation(t *testing.T) {
|
||||
f := &fakeOpenMeteo{geocodeBody: daLatGeocodeFixture, forecastBody: forecastFixture}
|
||||
stubOpenMeteo(t, f)
|
||||
|
||||
Reference in new issue
Block a user