diff --git a/README.md b/README.md index 14953a6..d01419f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| | `util` | `/help`, `/info`, `/stickerid` | -| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | +| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/giaxang`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | | `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | diff --git a/internal/modules/misc/giaxang_command.go b/internal/modules/misc/giaxang_command.go new file mode 100644 index 0000000..b8d739d --- /dev/null +++ b/internal/modules/misc/giaxang_command.go @@ -0,0 +1,185 @@ +package misc + +import ( + "cmp" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + "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" +) + +// petrolimexSearchURL is the CMS search endpoint behind the "Giá bán lẻ xăng +// dầu" widget on petrolimex.com.vn. It is not a published API: the site calls +// it from the browser, and it answers plain unauthenticated GETs. A variable so +// tests can point it at an httptest server. +var petrolimexSearchURL = "https://portals.petrolimex.com.vn/~apis/portals/cms.item/search" + +// petrolimexPriceFilter selects the published retail fuel price records. The +// IDs identify Petrolimex's CMS system, the price repository, and its entity; +// they are kept readable here and base64-encoded into the x-request parameter +// the endpoint expects. +const petrolimexPriceFilter = `{"FilterBy":{"And":[` + + `{"SystemID":{"Equals":"6783dc1271ff449e95b74a9520964169"}},` + + `{"RepositoryID":{"Equals":"a95451e23b474fe5886bfb7cf843f53c"}},` + + `{"RepositoryEntityID":{"Equals":"3801378fe1e045b1afa10de7c5776124"}},` + + `{"Status":{"Equals":"Published"}}]},` + + `"SortBy":{"LastModified":"Descending"},` + + `"Pagination":{"TotalRecords":-1,"TotalPages":0,"PageSize":0,"PageNumber":0}}` + +const ( + petrolimexHTTPTimeout = 5 * time.Second + petrolimexMaxBytes = 1 << 20 + + giaxangErrorText = "Không lấy được giá xăng dầu từ Petrolimex. Thử lại sau nhé." +) + +// giaxangLocation renders the update time in Vietnam time. FixedZone matches +// the rest of the repo and needs no tzdata in the container image. +var giaxangLocation = time.FixedZone("Asia/Saigon", 7*60*60) + +// fuelPrice is one retail product in VND per liter for each price zone. +type fuelPrice struct { + Title string `json:"Title"` + Zone1Price float64 `json:"Zone1Price"` + Zone2Price float64 `json:"Zone2Price"` + DisplayOrder int `json:"DIsplayOrder"` // upstream spelling + LastModified string `json:"LastModified"` +} + +type petrolimexSearchResponse struct { + Objects []fuelPrice `json:"Objects"` +} + +// fetchFuelPrices returns the current Petrolimex retail prices in display +// order. Records without a title or a positive Zone 1 price are dropped; an +// empty result is an error so the caller never renders a blank table. +func fetchFuelPrices(ctx context.Context, client *http.Client) ([]fuelPrice, error) { + q := url.Values{} + q.Set("object-identity", "search") + q.Set("x-request", base64.StdEncoding.EncodeToString([]byte(petrolimexPriceFilter))) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, petrolimexSearchURL+"?"+q.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("petrolimex: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("petrolimex: request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("petrolimex: status %d", resp.StatusCode) + } + + var body petrolimexSearchResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, petrolimexMaxBytes)).Decode(&body); err != nil { + return nil, fmt.Errorf("petrolimex: decode: %w", err) + } + prices := make([]fuelPrice, 0, len(body.Objects)) + for _, p := range body.Objects { + p.Title = strings.TrimSpace(p.Title) + if p.Title == "" || p.Zone1Price <= 0 { + continue + } + prices = append(prices, p) + } + if len(prices) == 0 { + return nil, errors.New("petrolimex: no prices in response") + } + slices.SortStableFunc(prices, func(a, b fuelPrice) int { return cmp.Compare(a.DisplayOrder, b.DisplayOrder) }) + return prices, nil +} + +// formatFuelPrices renders the price table as Telegram HTML. The table sits in +// a
 block so the zone columns line up in Telegram's monospace font.
+func formatFuelPrices(prices []fuelPrice) string {
+	width := utf8.RuneCountInString("Mặt hàng")
+	for _, p := range prices {
+		width = max(width, utf8.RuneCountInString(p.Title))
+	}
+
+	var table strings.Builder
+	writeRow := func(name, zone1, zone2 string) {
+		pad := strings.Repeat(" ", width-utf8.RuneCountInString(name))
+		fmt.Fprintf(&table, "%s%s  %6s  %6s\n", name, pad, zone1, zone2)
+	}
+	writeRow("Mặt hàng", "Vùng 1", "Vùng 2")
+	for _, p := range prices {
+		writeRow(p.Title, formatThousands(p.Zone1Price), formatThousands(p.Zone2Price))
+	}
+
+	var sb strings.Builder
+	sb.WriteString("⛽ Giá bán lẻ xăng dầu Petrolimex (đ/lít)\n")
+	if updated, ok := latestUpdate(prices); ok {
+		sb.WriteString("Cập nhật: " + updated.In(giaxangLocation).Format("15:04 02/01/2006") + "\n")
+	}
+	sb.WriteString("\n
" + html.EscapeString(strings.TrimRight(table.String(), "\n")) + "
") + return sb.String() +} + +// latestUpdate returns the newest LastModified across the records, which is +// when Petrolimex last published a price change. +func latestUpdate(prices []fuelPrice) (time.Time, bool) { + var latest time.Time + for _, p := range prices { + t, err := time.Parse(time.RFC3339, p.LastModified) + if err == nil && t.After(latest) { + latest = t + } + } + return latest, !latest.IsZero() +} + +// formatThousands renders a VND amount with dot thousands separators, the +// Vietnamese convention (27080 -> "27.080"). +func formatThousands(n float64) string { + s := strconv.FormatInt(int64(n+0.5), 10) + var sb strings.Builder + for i := 0; i < len(s); i++ { + if i > 0 && (len(s)-i)%3 == 0 { + sb.WriteByte('.') + } + sb.WriteByte(s[i]) + } + return sb.String() +} + +func giaxangCommand() modules.Command { + client := &http.Client{Timeout: petrolimexHTTPTimeout} + return modules.Command{ + Name: "giaxang", + Visibility: modules.VisibilityPublic, + Description: "Giá xăng dầu hôm nay", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + fetchCtx, cancel := chathelper.FetchContext(ctx) + prices, err := fetchFuelPrices(fetchCtx, client) + cancel() + if err != nil { + log.Error("fuel price fetch failed", "module", "misc", "command", "giaxang", "err", err) + return chathelper.Reply(ctx, b, update.Message, giaxangErrorText) + } + return chathelper.ReplyHTML(ctx, b, update.Message, formatFuelPrices(prices)) + }, + } +} diff --git a/internal/modules/misc/giaxang_command_test.go b/internal/modules/misc/giaxang_command_test.go new file mode 100644 index 0000000..04a8dea --- /dev/null +++ b/internal/modules/misc/giaxang_command_test.go @@ -0,0 +1,97 @@ +package misc + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tiennm99/miti99bot/internal/testutil" +) + +// petrolimexFixture mirrors the live response shape: records arrive sorted by +// LastModified, not display order, and may include entries with no price. +const petrolimexFixture = `{"Objects":[ + {"Title":"DO 0,001S-V","Zone1Price":32090,"Zone2Price":32730,"DIsplayOrder":6,"LastModified":"2026-09-24T07:48:49.563Z"}, + {"Title":"Xăng E10 RON 95-III","Zone1Price":27080,"Zone2Price":27620,"DIsplayOrder":4,"LastModified":"2026-09-24T07:47:51.496Z"}, + {"Title":"Mazút","Zone1Price":0,"Zone2Price":0,"DIsplayOrder":9,"LastModified":"2026-09-24T08:30:00Z"}, + {"Title":" ","Zone1Price":1,"Zone2Price":1,"DIsplayOrder":1,"LastModified":"2026-09-24T07:00:00Z"} +]}` + +const giaxangFixtureReply = "⛽ Giá bán lẻ xăng dầu Petrolimex (đ/lít)\n" + + "Cập nhật: 14:48 24/09/2026\n" + + "\n
" +
+	"Mặt hàng             Vùng 1  Vùng 2\n" +
+	"Xăng E10 RON 95-III  27.080  27.620\n" +
+	"DO 0,001S-V          32.090  32.730" +
+	"
" + +// stubPetrolimex points the command at a test server for the duration of t. +func stubPetrolimex(t *testing.T, handler http.HandlerFunc) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + orig := petrolimexSearchURL + petrolimexSearchURL = server.URL + "/search" + t.Cleanup(func() { petrolimexSearchURL = orig }) +} + +func TestGiaxang_RepliesPriceTable(t *testing.T) { + stubPetrolimex(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("object-identity"); got != "search" { + t.Errorf("object-identity = %q, want search", got) + } + filter, err := base64.StdEncoding.DecodeString(r.URL.Query().Get("x-request")) + if err != nil || string(filter) != petrolimexPriceFilter { + t.Errorf("x-request = %q (err %v), want encoded price filter", filter, err) + } + _, _ = w.Write([]byte(petrolimexFixture)) + }) + rb, _ := installMisc(t, 999) + + // Public: a non-admin sender gets the table. + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/giaxang")) + + sent := rb.LastSent() + if got := sent.Text(); got != giaxangFixtureReply { + t.Errorf("/giaxang reply =\n%s\nwant\n%s", got, giaxangFixtureReply) + } + if got := sent.Form["parse_mode"]; got != "HTML" { + t.Errorf("parse_mode = %q, want HTML", got) + } +} + +func TestGiaxang_UpstreamFailureRepliesError(t *testing.T) { + cases := map[string]http.HandlerFunc{ + "non-200": func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "blocked", http.StatusForbidden) + }, + "bad json": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("")) + }, + "no prices": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"Objects":[{"Title":"Mazút","Zone1Price":0}]}`)) + }, + } + for name, handler := range cases { + t.Run(name, func(t *testing.T) { + stubPetrolimex(t, handler) + rb, _ := installMisc(t, 999) + + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/giaxang")) + + if got := rb.LastSent().Text(); got != giaxangErrorText { + t.Errorf("/giaxang reply = %q, want %q", got, giaxangErrorText) + } + }) + } +} + +func TestFormatThousands(t *testing.T) { + for in, want := range map[float64]string{0: "0", 999: "999", 27080: "27.080", 1234567: "1.234.567"} { + if got := formatThousands(in); got != want { + t.Errorf("formatThousands(%v) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index a271e48..9ac1461 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -2,8 +2,9 @@ // /ping (public, exercises KV write), /ping_stats (protected, exercises KV // read), /random (public random picker), /wheelofnames (public wheel picker // with optional GIF), /ff (protected give-up-on-T1 rant), /xlt1 (public -// apologise-to-T1 petition, the sequel to /ff), /the_answer (private easter -// egg), and small public disclaimer commands. +// apologise-to-T1 petition, the sequel to /ff), /giaxang (public Petrolimex +// retail fuel prices), /the_answer (private easter egg), and small public +// disclaimer commands. package misc import ( @@ -57,6 +58,7 @@ func New(deps modules.Deps) modules.Module { wheelOfNamesCommand(), ffCommand(), xlt1Command(), + giaxangCommand(), theAnswerCommand(), disclaimerCommand("trongtruonghop", "Phát biểu disclaimer mặc định", defaultTarget, true), disclaimerCommand("tth", "Phát biểu disclaimer mặc định", defaultTarget, true), diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go index c2d9982..819c361 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -27,6 +27,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) { "wheelofnames": modules.VisibilityPublic, "ff": modules.VisibilityProtected, "xlt1": modules.VisibilityPublic, + "giaxang": modules.VisibilityPublic, "the_answer": modules.VisibilityPrivate, "trongtruonghop": modules.VisibilityPublic, "tth": modules.VisibilityPublic, diff --git a/plans/reports/research-260930-1127-giaxang-fuel-price-source.md b/plans/reports/research-260930-1127-giaxang-fuel-price-source.md new file mode 100644 index 0000000..8e51403 --- /dev/null +++ b/plans/reports/research-260930-1127-giaxang-fuel-price-source.md @@ -0,0 +1,129 @@ +# /giaxang — Vietnam retail fuel price source and design + +Researched: 2026-09-30 11:27 (Asia/Saigon) + +## Outcome + +Use Petrolimex's own CMS JSON endpoint. It returns all six retail products with +Zone 1 and Zone 2 prices in one unauthenticated GET, was verified live from this +Oracle ARM64 host with plain `curl` (no special headers), and its numbers match +today's press reports (E10 RON 95-III 27,080 / 27,620 đ, effective 2026-09-24). + +## Source evaluation + +| Source | Verdict | Evidence | +|---|---|---| +| Petrolimex CMS JSON (`portals.petrolimex.com.vn/~apis/portals/cms.item/search`) | **Use** | Live 200 response, 6 items, `Zone1Price`/`Zone2Price` numeric, `LastModified` per item. First-party data. | +| VNAppMob (already used by `gold`) | Not available | Docs list only Province, Gold v2, Exchange rate v2; `/api/v2/petrol*` returns 404. | +| petrolimex.com.vn HTML | Reject | Price table is rendered client-side (jQuery + crypto-js + rsa.js); nothing to parse server-side. | +| Third-party aggregators (vietfuel-api, toanqng/fuel JSON on GitHub) | Reject as primary | Extra hop on someone's hobby infrastructure that itself scrapes Petrolimex. | +| News sites (thanhnien, baomoi) | Reject | HTML scraping, unstable markup. | + +## Endpoint contract + +```text +GET https://portals.petrolimex.com.vn/~apis/portals/cms.item/search + ?object-identity=search + &x-request= +``` + +The `x-request` value is base64 of a fixed filter (SystemID +`6783dc1271ff449e95b74a9520964169`, RepositoryID +`a95451e23b474fe5886bfb7cf843f53c`, RepositoryEntityID +`3801378fe1e045b1afa10de7c5776124`, `Status=Published`, sorted by +`LastModified` descending). Build it from a JSON literal in code rather than +pasting an opaque base64 blob, so the IDs stay readable. + +Fields used from each `Objects[]` entry: + +| Field | Example | Use | +|---|---|---| +| `Title` | `Xăng E10 RON 95-III` | Product label | +| `Zone1Price` | `27080` | VND/liter, vùng 1 | +| `Zone2Price` | `27620` | VND/liter, vùng 2 | +| `DIsplayOrder` (sic) | `4` | Sort order | +| `LastModified` | `2026-09-24T07:47:51.496Z` | "Áp dụng từ" timestamp (max over items) | + +Current product list: Xăng E10 RON 95-V, Xăng E10 RON 95-III, Xăng E5 RON 92-II, +DO 0,001S-V, DO 0,05S-II, Dầu hỏa 2-K. Render whatever titles the API returns; +do not hardcode the list (it changed with the 2026 E10 rollout). + +## Brainstorm contract + +- **Outcome:** `/giaxang` in the `misc` module replies with the current + Petrolimex retail price table (all products, both zones, VND/liter) plus the + effective timestamp in Asia/Saigon time. +- **Constraints:** Go, follow existing `misc`/`gold` patterns; HTTP timeout under + the handler deadline via `chathelper.FetchContext`; https-only endpoint with a + test-overridable base URL (`httptest`); command metadata per + `docs/command-parameter-conventions.md`; update README module table. +- **Non-goals:** price history, change alerts/cron, PVOil or other retailers, + per-province lookup, storing prices in MongoDB. +- **Acceptance criteria:** handler test against an `httptest` server asserts the + exact rendered text; upstream failure (non-200, bad JSON, empty list) yields a + friendly Vietnamese error reply, not silence; registration/help tests pass; + `go test ./...`, `go vet ./...`, `golangci-lint run` clean. + +## Trade-offs + +| Approach | Load-bearing assumption | Fails first when | +|---|---|---| +| A. Fetch Petrolimex on every call (recommended) | Endpoint stays public and unauthenticated | Petrolimex adds auth/encryption to `~apis` or changes the IDs | +| B. Fetch + in-memory cache (e.g. 10 min) | Same as A | Same as A; also adds staleness logic for little gain at chat volume | +| C. Third-party aggregator JSON | Hobby project stays maintained | Maintainer stops updating (toanqng last push 2026-09-24, vietfuel 2026-07-16) | + +Recommend A. Prices change at most weekly (Thursday adjustments), traffic is +chat-scale, and the fetch is one small GET, so a cache is not needed yet. + +Better approaches: none — recommended direction is the requested one (first-party +JSON endpoint verified live). + +## Proposed reply format + +```text +⛽ Giá bán lẻ xăng dầu Petrolimex +Áp dụng từ 15:00 24/09/2026 + +Xăng E10 RON 95-V 28.080 | 28.640 +Xăng E10 RON 95-III 27.080 | 27.620 +Xăng E5 RON 92-II 26.390 | 26.910 +DO 0,001S-V 32.090 | 32.730 +DO 0,05S-II 30.490 | 31.090 +Dầu hỏa 2-K 30.020 | 30.620 + +đ/lít — Vùng 1 | Vùng 2 +``` + +Render inside `
` (HTML) so columns align. Note: `LastModified` is when
+Petrolimex edited the record (07:47Z = 14:47 ICT), which is shortly before the
+official 15:00 effective time — label it "Cập nhật" rather than claiming the
+exact effective time.
+
+## Risks
+
+- Unofficial API: Petrolimex may change IDs or add request signing (their site
+  already loads rsa.js/crypto-js). Mitigation: clear error reply + log; the
+  source lives behind one small client so swapping is cheap.
+- Petrolimex reportedly blocks some cloud IP ranges (Google). Verified working
+  from this Oracle host; the production Coolify host should be checked once
+  after deploy.
+
+## Next steps
+
+1. Add `misc/giaxang_command.go` (client + formatter + command) and register it
+   in `misc.New`.
+2. Add handler tests with `httptest`; update README module row and package doc.
+3. Run tests/vet/lint, commit `feat(misc): add /giaxang retail fuel price`.
+
+## Sources
+
+- Petrolimex endpoint (live probe, 2026-09-30)
+- [chiraitori/vngasprice](https://github.com/chiraitori/vngasprice) — Go scraper that documents the endpoint
+- [thanhnien.vn — Giá xăng dầu hôm nay 25.9.2026](https://thanhnien.vn/gia-xang-dau-hom-nay-2592026-xang-e10-len-muc-cao-nhat-28640-dong-lit-185260925083720959.htm) — cross-check of prices
+- [TranQui004/vietfuel-api](https://github.com/TranQui004/vietfuel-api), [toanqng/fuel](https://github.com/toanqng/fuel) — aggregator alternatives
+- [VNAppMob Open API docs](https://api.vnappmob.com/) — no fuel endpoint
+
+## Unresolved questions
+
+- Visibility: public (like `/random`) or protected? Recommended: public.
+- Show both zones, or Zone 1 only for a shorter reply? Recommended: both.