feat(misc): add /giaxang Petrolimex retail fuel prices

This commit is contained in:
tiennm99 committed 2026-09-30 11:44:42 +07:00
1 parent 749cd516a3
commit 97aaba83ec
6 files changed
+417 -3

No files matched your search

+185
View File
@@ -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 <pre> 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<pre>" + html.EscapeString(strings.TrimRight(table.String(), "\n")) + "</pre>")
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))
},
}
}
@@ -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<pre>" +
"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" +
"</pre>"
// 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("<html>"))
},
"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)
}
}
}
+4 -2
View File
@@ -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),
+1
View File
@@ -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,