feat: refine command names and behavior

This commit is contained in:
2026-07-01 13:19:42 +07:00
parent 3ec608c9a1
commit a5b808ebeb
40 changed files with 667 additions and 148 deletions
+3 -3
View File
@@ -8,15 +8,15 @@ Atlas via long polling and an in-process cron scheduler.
| Module | What it does |
|---|---|
| `util` | `/help`, `/info`, `/stickerid` |
| `misc` | `/ping`, `/mstats`, `/trongtruonghop` disclaimer |
| `misc` | `/ping`, `/ping_stats`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
| `lol` | Pro-match schedule + daily push |
| `wc` | World Cup schedule + silent daily push |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (opt-in; primary VNAppMob SJC buy/sell VND/luong, fallback spot XAU) |
| `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) |
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <name>`, `/stats cmd <name>` |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <username>`, `/stats cmd <command>` |
Disable modules with the `MODULES` environment variable.
+2 -1
View File
@@ -29,6 +29,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/wordle"
"github.com/tiennm99/miti99bot/internal/server"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
"github.com/tiennm99/miti99bot/internal/telegram"
)
@@ -94,7 +95,7 @@ func main() {
}
defer closeProvider()
if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil {
if err := stats.InitStore(rootCtx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName)); err != nil {
log.Fatal("stats storage init failed", "err", err)
}
+2 -2
View File
@@ -26,13 +26,13 @@ func New(deps modules.Deps) modules.Module {
{
Name: "coin_buy",
Visibility: modules.VisibilityPublic,
Description: "Buy coin with USD amount",
Description: "Spend a USD amount to buy coin",
Handler: s.handleBuy,
},
{
Name: "coin_sell",
Visibility: modules.VisibilityPublic,
Description: "Sell coin for a USD amount",
Description: "Sell enough coin to receive a USD amount",
Handler: s.handleSell,
},
{
+4 -4
View File
@@ -70,7 +70,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy <COIN> <usd_amount>\nAlternative: /coin_buy <usd_amount> <COIN>\nExample: /coin_buy BTC 10")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy <COIN> <usd_amount>\nSpend USD to buy coin.\nAlternative: /coin_buy <usd_amount> <COIN>\nExample: /coin_buy BTC 10")
}
parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
if errors.Is(err, errInvalidUSDAmount) {
@@ -113,7 +113,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
}
return chathelper.Reply(ctx, b, update.Message,
"Bought "+FormatCoinQty(qty)+" "+coin.Symbol+" @ "+FormatUSD(price.USD)+" ("+price.Source+")"+
"\nCost: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD))
"\nSpent: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD))
}
func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -124,7 +124,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
}
args := argsAfterCommand(update.Message.Text)
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell <COIN> <usd_amount>\nAlternative: /coin_sell <usd_amount> <COIN>\nExample: /coin_sell BTC 10")
return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell <COIN> <usd_amount>\nSell enough coin to receive USD.\nAlternative: /coin_sell <usd_amount> <COIN>\nExample: /coin_sell BTC 10")
}
parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount)
if errors.Is(err, errInvalidUSDAmount) {
@@ -168,7 +168,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
}
return chathelper.Reply(ctx, b, update.Message,
"Sold "+FormatCoinQty(qty)+" "+coin.Symbol+" @ "+FormatUSD(price.USD)+" ("+price.Source+")"+
"\nProceeds: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD))
"\nReceived: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD))
}
func formatInsufficientSellMessage(coin CoinSymbol, requestedUSD, heldQty, priceUSD float64) string {
@@ -80,7 +80,7 @@ func TestGoldPriceLines(t *testing.T) {
Source: "vnappmob-sjc",
SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000},
})
want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"}
want := []string{"SJC gold price", "SJC buy (you sell):", "SJC sell (you buy):"}
if len(lines) != len(want) {
t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines)
}
+2 -2
View File
@@ -25,13 +25,13 @@ func New(deps modules.Deps) modules.Module {
{
Name: "gold_buy",
Visibility: modules.VisibilityPublic,
Description: "Buy gold at spot price (luong)",
Description: "Buy gold at SJC sell price (luong)",
Handler: s.handleBuy,
},
{
Name: "gold_sell",
Visibility: modules.VisibilityPublic,
Description: "Sell gold back to VND (luong)",
Description: "Sell gold at SJC buy price (luong)",
Handler: s.handleSell,
},
{
+5 -5
View File
@@ -38,9 +38,9 @@ func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Upda
func goldPriceLines(p GoldPrice) []string {
return []string{
"Gold Spot Price (SJC)",
"Buy: " + FormatVND(p.SJC.Buy) + "/luong",
"Sell: " + FormatVND(p.SJC.Sell) + "/luong",
"SJC gold price",
"SJC buy (you sell): " + FormatVND(p.SJC.Buy) + "/luong",
"SJC sell (you buy): " + FormatVND(p.SJC.Sell) + "/luong",
}
}
@@ -116,7 +116,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
"Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(sellPrice)+"/luong\nCost: "+FormatVND(cost)+
"Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(sellPrice)+"/luong (SJC sell)\nCost: "+FormatVND(cost)+
"\nRemaining: "+FormatVND(p.VND))
}
@@ -163,7 +163,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.")
}
return chathelper.Reply(ctx, b, update.Message,
"Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(buyPrice)+"/luong\nRevenue: "+FormatVND(revenue)+
"Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(buyPrice)+"/luong (SJC buy)\nRevenue: "+FormatVND(revenue)+
"\nRemaining: "+FormatVND(p.VND))
}
+1 -1
View File
@@ -211,7 +211,7 @@ func TestHandlePrice(t *testing.T) {
t.Fatalf("handlePrice: %v", err)
}
text := rb.LastSent().Text()
for _, want := range []string{"Gold Spot Price (SJC)", "Buy:", "Sell:", "/luong"} {
for _, want := range []string{"SJC gold price", "SJC buy (you sell):", "SJC sell (you buy):", "/luong"} {
if !strings.Contains(text, want) {
t.Fatalf("price missing %q in %q", want, text)
}
+2 -2
View File
@@ -202,7 +202,7 @@ func renderLeagueSection(g leagueGroup) string {
func RenderToday(events []ScheduleEvent, day time.Time) string {
header := "<b>LoL — " + html.EscapeString(formatIctDayLabel(day)) + "</b> (ICT)"
if len(events) == 0 {
return header + "\nNo matches today."
return header + "\nNo major LoL matches today."
}
groups := groupByLeague(events)
sections := make([]string, len(groups))
@@ -220,7 +220,7 @@ func RenderWeek(events []ScheduleEvent, from, to time.Time) string {
toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond)))
header := "<b>LoL — " + fromLbl + " → " + toLbl + "</b> (ICT)"
if len(events) == 0 {
return header + "\nNo matches this week."
return header + "\nNo major LoL matches this week."
}
leagueBlocks := make([]string, 0, len(events))
+2 -2
View File
@@ -113,8 +113,8 @@ func TestRenderToday_GroupsByLeagueInOrder(t *testing.T) {
func TestRenderToday_EmptyShowsNoMatches(t *testing.T) {
day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
got := RenderToday(nil, day)
if !strings.Contains(got, "No matches today.") {
t.Errorf("empty render missing 'No matches today.': %q", got)
if !strings.Contains(got, "No major LoL matches today.") {
t.Errorf("empty render missing major-match empty text: %q", got)
}
}
+20 -4
View File
@@ -82,6 +82,20 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa
return chathelper.ReplyHTML(ctx, b, msg, text)
}
func subscriptionScope(msg *models.Message) string {
if msg != nil && msg.MessageThreadID != 0 {
return "this topic"
}
return "this chat"
}
func subscriptionScopeSentenceSubject(msg *models.Message) string {
if msg != nil && msg.MessageThreadID != 0 {
return "This topic"
}
return "This chat"
}
// handleSubscribe is /lol_subscribe — opt the chat into the daily
// digest delivered by the in-process cron handler.
func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -95,12 +109,13 @@ func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.
if err != nil {
return err
}
scope := subscriptionScope(msg)
if added {
return chathelper.Reply(ctx, b, msg,
"✅ Subscribed. You'll get today's LoL schedule at 08:00 ICT.\n"+
"✅ Subscribed "+scope+" to the daily LoL schedule at 08:00 ICT.\n"+
"If you block the bot, you'll be auto-unsubscribed on the next push.")
}
return chathelper.Reply(ctx, b, msg, "Already subscribed.")
return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".")
}
// handleUnsubscribe is /lol_unsubscribe — opt out.
@@ -115,8 +130,9 @@ func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *model
if err != nil {
return err
}
scope := subscriptionScope(msg)
if removed {
return chathelper.Reply(ctx, b, msg, "Unsubscribed.")
return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".")
}
return chathelper.Reply(ctx, b, msg, "You weren't subscribed.")
return chathelper.Reply(ctx, b, msg, subscriptionScopeSentenceSubject(msg)+" wasn't subscribed.")
}
+7 -4
View File
@@ -122,7 +122,7 @@ func TestHandleWeek_RendersWeek(t *testing.T) {
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
if got := rb.LastSent().Text(); !strings.HasPrefix(got, "") {
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this chat") || !strings.Contains(got, "daily LoL schedule") {
t.Errorf("first subscribe should confirm; got %q", got)
}
rb.Reset()
@@ -146,6 +146,9 @@ func TestHandleSubscribe_ForumTopic_CapturesThreadID(t *testing.T) {
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this topic") {
t.Errorf("topic subscribe reply = %q", got)
}
subs, _ := listSubscribers(context.Background(), subsStore)
want := Subscriber{ChatID: 555, ThreadID: 42}
if len(subs) != 1 || subs[0] != want {
@@ -183,12 +186,12 @@ func TestHandleUnsubscribe(t *testing.T) {
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe"))
if got := rb.LastSent().Text(); got != "Unsubscribed." {
t.Errorf("unsubscribe reply = %q, want 'Unsubscribed.'", got)
if got := rb.LastSent().Text(); got != "Unsubscribed this chat." {
t.Errorf("unsubscribe reply = %q, want 'Unsubscribed this chat.'", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "weren't subscribed") {
if got := rb.LastSent().Text(); !strings.Contains(got, "This chat wasn't subscribed") {
t.Errorf("idempotent unsubscribe reply = %q", got)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func New(deps modules.Deps) modules.Module {
{
Name: "lol",
Visibility: modules.VisibilityPublic,
Description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)",
Description: "LoL matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)",
Handler: s.handleSchedule,
},
{
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const ictOffset = 7 * time.Hour
// formatHint is the user-facing usage line appended to parse errors.
const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
const formatHint = "Use dd, dd-mm, dd/mm, ddmm, dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
// IctLocation is the fixed-offset UTC+7 timezone.
var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second))
+27 -13
View File
@@ -57,12 +57,13 @@ func (s *state) rehydrateGuesses(g *gameState) []boardEntry {
}
// startFreshGame writes a new round with no startedAt clock yet.
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
func (s *state) startFreshGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
target := s.pickRandomChampion()
g := &gameState{
Target: target.ChampionName,
Guesses: []string{},
StartedAt: nil,
Target: target.ChampionName,
Guesses: []string{},
StartedAt: nil,
MaxGuesses: normalizeMaxGuesses(maxGuesses),
}
if err := saveGame(ctx, s.games, subject, g); err != nil {
return nil, err
@@ -77,10 +78,19 @@ func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses in
if err != nil {
return nil, err
}
if existing != nil && len(existing.Guesses) < maxGuesses {
return existing, nil
if existing != nil {
roundMax := existing.roundMaxGuesses()
if len(existing.Guesses) < roundMax {
if existing.MaxGuesses != roundMax {
existing.MaxGuesses = roundMax
if err := saveGame(ctx, s.games, subject, existing); err != nil {
return nil, err
}
}
return existing, nil
}
}
return s.startFreshGame(ctx, subject)
return s.startFreshGame(ctx, subject, maxGuesses)
}
// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id
@@ -122,15 +132,19 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
if err != nil {
return err
}
roundMax := game.roundMaxGuesses()
if arg == "" {
header := fmt.Sprintf("Guess %d/%d. Use <code>/loldle &lt;champion&gt;</code>.",
len(game.Guesses), maxGuesses)
len(game.Guesses), roundMax)
board := renderBoard(s.rehydrateGuesses(game))
return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+board)
}
guess := findChampion(s.champions, arg)
guess, ambiguous := findChampionMatch(s.champions, arg)
if ambiguous {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Ambiguous champion %q. Type the full champion name.", arg))
}
if guess == nil {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg))
}
@@ -176,12 +190,12 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
return err
}
trySendSticker(ctx, b, msg, winStickers)
flavor := attemptFlavor(len(game.Guesses), maxGuesses)
flavor := attemptFlavor(len(game.Guesses), roundMax)
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s",
rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), maxGuesses, newRoundHint))
rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), roundMax, newRoundHint))
case len(game.Guesses) >= maxGuesses:
case len(game.Guesses) >= roundMax:
if _, err := recordResult(ctx, s.stats, subject, false); err != nil {
return err
}
@@ -197,7 +211,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
return err
}
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses))
"%s\n\nGuess %d/%d.", rendered, len(game.Guesses), roundMax))
}
}
+29
View File
@@ -71,6 +71,16 @@ func TestLoldle_UnknownChampion(t *testing.T) {
}
}
func TestLoldle_AmbiguousChampionPrefix(t *testing.T) {
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle A"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Ambiguous champion") || !strings.Contains(got, "full champion name") {
t.Errorf("ambiguous champion reject: %q", got)
}
}
func TestLoldle_DuplicateGuessRejected(t *testing.T) {
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
// First guess: Ahri (not the target — round continues).
@@ -134,6 +144,25 @@ func TestLoldleSetMax_OwnerSucceeds(t *testing.T) {
}
}
func TestLoldleSetMax_AppliesNextRoundOnly(t *testing.T) {
rb, _, _ := installLoldle(t, 999, "999", "Aatrox")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 1"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/8") {
t.Fatalf("active round should keep default max after setmax: %q", got)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_giveup"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/1") {
t.Fatalf("new round should use changed max: %q", got)
}
}
func TestLoldleSetMax_DeniedToNonOwner(t *testing.T) {
rb, _, _ := installLoldle(t, 999, "", "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_setmax 5"))
+1 -1
View File
@@ -25,7 +25,7 @@ func New(deps modules.Deps) modules.Module {
{
Name: "loldle_giveup",
Visibility: modules.VisibilityPublic,
Description: "Reveal the current loldle answer (auto-starts a fresh round)",
Description: "Reveal the current loldle answer",
Handler: s.handleGiveup,
},
{
+9 -4
View File
@@ -27,25 +27,30 @@ func normalizeName(s string) string {
// prevents "Ka" silently routing to whichever Ka- champion happens to be
// first in the data file.
func findChampion(pool []Champion, input string) *Champion {
champion, _ := findChampionMatch(pool, input)
return champion
}
func findChampionMatch(pool []Champion, input string) (*Champion, bool) {
q := normalizeName(input)
if q == "" {
return nil
return nil, false
}
for i := range pool {
if normalizeName(pool[i].ChampionName) == q {
return &pool[i]
return &pool[i], false
}
}
var hit *Champion
for i := range pool {
if strings.HasPrefix(normalizeName(pool[i].ChampionName), q) {
if hit != nil {
return nil // ambiguous
return nil, true
}
hit = &pool[i]
}
}
return hit
return hit, false
}
// findChampionByExactName looks up a champion by literal display name (no
+18 -5
View File
@@ -26,11 +26,11 @@ func TestFindChampion(t *testing.T) {
{"aatrox", "Aatrox"},
{"kaisa", "Kai'Sa"},
{"KAI SA", "Kai'Sa"},
{"Aat", "Aatrox"}, // unique prefix
{"A", ""}, // ambiguous prefix
{"", ""}, // empty
{"!!!", ""}, // no alphanumerics
{"zed", ""}, // no match
{"Aat", "Aatrox"}, // unique prefix
{"A", ""}, // ambiguous prefix
{"", ""}, // empty
{"!!!", ""}, // no alphanumerics
{"zed", ""}, // no match
}
for _, tc := range cases {
got := findChampion(pool, tc.input)
@@ -45,3 +45,16 @@ func TestFindChampion(t *testing.T) {
}
}
}
func TestFindChampionMatchReportsAmbiguousPrefix(t *testing.T) {
pool := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}, {ChampionName: "Kai'Sa"}}
got, ambiguous := findChampionMatch(pool, "A")
if got != nil || !ambiguous {
t.Fatalf("findChampionMatch ambiguous = (%+v, %v), want (nil, true)", got, ambiguous)
}
got, ambiguous = findChampionMatch(pool, "zed")
if got != nil || ambiguous {
t.Fatalf("findChampionMatch unknown = (%+v, %v), want (nil, false)", got, ambiguous)
}
}
+22 -3
View File
@@ -25,9 +25,10 @@ const (
// time against current champions.json so a weekly data refresh updates
// historical board displays without migrating saved rounds.
type gameState struct {
Target string `json:"target" bson:"target"`
Guesses []string `json:"guesses" bson:"guesses"`
StartedAt *int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch | null
Target string `json:"target" bson:"target"`
Guesses []string `json:"guesses" bson:"guesses"`
StartedAt *int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch | null
MaxGuesses int `json:"maxGuesses,omitempty" bson:"maxGuesses,omitempty"` // frozen round budget
}
// stats lifetime score. No LastResultAt field by design (differs from
@@ -59,6 +60,24 @@ func gameKey(subject string) string { return "game:" + subject }
func statsKey(subject string) string { return "stats:" + subject }
func configKey(subject string) string { return "config:" + subject }
func validMaxGuesses(n int) bool {
return n >= 1 && n <= MaxGuessesCap
}
func normalizeMaxGuesses(n int) int {
if validMaxGuesses(n) {
return n
}
return MaxGuesses
}
func (g *gameState) roundMaxGuesses() int {
if g == nil {
return MaxGuesses
}
return normalizeMaxGuesses(g.MaxGuesses)
}
// loadGame returns the active round, or (nil, nil) if none exists.
func loadGame(ctx context.Context, games GameStore, subject string) (*gameState, error) {
g, _, err := games.Get(ctx, gameKey(subject))
+59
View File
@@ -42,6 +42,17 @@ func TestGameState_StartedAtAsNumber(t *testing.T) {
}
}
func TestGameState_MaxGuessesOmittedWhenUnset(t *testing.T) {
g := gameState{Target: "Aatrox", Guesses: []string{}, MaxGuesses: 0}
b, err := json.Marshal(g)
if err != nil {
t.Fatal(err)
}
if string(b) != `{"target":"Aatrox","guesses":[],"startedAt":null}` {
t.Errorf("marshal:\ngot %s", b)
}
}
func TestStats_NoLastResultAtField(t *testing.T) {
// loldle's stats schema deliberately differs from wordle's — no
// lastResultAt field. Lock that, since adding the field would silently
@@ -123,6 +134,54 @@ func TestGetMaxGuesses_OutOfRangeIgnored(t *testing.T) {
}
}
func TestGetOrInitGame_ExistingRoundKeepsStoredMaxGuesses(t *testing.T) {
ctx := context.Background()
games := newLoldleGames()
s := &state{games: games}
if err := saveGame(ctx, games, "u1", &gameState{
Target: "Aatrox",
Guesses: []string{"Ahri"},
MaxGuesses: 8,
}); err != nil {
t.Fatal(err)
}
got, err := s.getOrInitGame(ctx, "u1", 1)
if err != nil {
t.Fatal(err)
}
if got.MaxGuesses != 8 || len(got.Guesses) != 1 {
t.Fatalf("existing round changed by new config: %+v", got)
}
}
func TestGetOrInitGame_LegacyRoundLocksDefaultMaxGuesses(t *testing.T) {
ctx := context.Background()
games := newLoldleGames()
s := &state{games: games}
if err := saveGame(ctx, games, "u1", &gameState{
Target: "Aatrox",
Guesses: []string{"Ahri"},
}); err != nil {
t.Fatal(err)
}
got, err := s.getOrInitGame(ctx, "u1", 1)
if err != nil {
t.Fatal(err)
}
if got.MaxGuesses != MaxGuesses {
t.Fatalf("legacy round MaxGuesses = %d, want default %d", got.MaxGuesses, MaxGuesses)
}
loaded, err := loadGame(ctx, games, "u1")
if err != nil {
t.Fatal(err)
}
if loaded == nil || loaded.MaxGuesses != MaxGuesses {
t.Fatalf("legacy round was not persisted with default max: %+v", loaded)
}
}
func TestLoadGame_MissingReturnsNil(t *testing.T) {
g, err := loadGame(context.Background(), newLoldleGames(), "nobody")
if err != nil {
+30 -16
View File
@@ -15,7 +15,7 @@ import (
// installMisc wires the misc module to a recording bot with a fresh
// in-memory store. Returns the bot and the typed store (so tests can pre-seed
// or read), plus an Auth that permits Owner + Admin so /mstats /fortytwo dispatch.
// or read), plus an Auth that permits Owner + Admin so /ping_stats /the_answer dispatch.
func installMisc(t *testing.T, ownerID int64) (*testutil.RecordingBot, storage.DocStore[lastPing]) {
t.Helper()
rb := testutil.NewRecordingBot(t)
@@ -56,36 +56,36 @@ func TestPing_RepliesPongAndWritesStore(t *testing.T) {
}
}
func TestMstats_NeverWhenStoreEmpty(t *testing.T) {
func TestPingStats_NeverWhenStoreEmpty(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/mstats"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping_stats"))
if got := rb.LastSent().Text(); got != "last ping: never" {
t.Errorf("mstats reply = %q, want 'last ping: never'", got)
t.Errorf("ping_stats reply = %q, want 'last ping: never'", got)
}
}
func TestMstats_AfterPing(t *testing.T) {
func TestPingStats_AfterPing(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/mstats"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping_stats"))
got := rb.LastSent().Text()
if !strings.HasPrefix(got, "last ping: ") {
t.Errorf("mstats reply = %q, want 'last ping: ...'", got)
t.Errorf("ping_stats reply = %q, want 'last ping: ...'", got)
}
if strings.Contains(got, "never") {
t.Errorf("mstats still says 'never' after /ping: %q", got)
t.Errorf("ping_stats still says 'never' after /ping: %q", got)
}
}
func TestMstats_DeniedToNonAdmin(t *testing.T) {
func TestPingStats_DeniedToNonAdmin(t *testing.T) {
rb, _ := installMisc(t, 999) // owner = 999
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/mstats"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/ping_stats"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("non-admin /mstats produced replies: %+v", calls)
t.Errorf("non-admin /ping_stats produced replies: %+v", calls)
}
}
@@ -128,6 +128,20 @@ func TestTrongTruongHop_CustomArg(t *testing.T) {
}
}
func TestTTHAlias_CustomArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/tth Acme Corp",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
if !strings.Contains(got, "Acme Corp") {
t.Errorf("reply missing custom arg Acme Corp: %q", got)
}
if n := strings.Count(got, "@boss"); n != 2 {
t.Errorf("reply mentions @boss %d times, want 2: %q", n, got)
}
}
func TestTrongTruongHop_HTMLEscapesArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop <script>",
@@ -166,18 +180,18 @@ func TestTrongTruongHop_EmptyDisplayNameFallsBackToThanhVien(t *testing.T) {
}
}
func TestFortytwo_OwnerOnly(t *testing.T) {
func TestTheAnswer_OwnerOnly(t *testing.T) {
rb, _ := installMisc(t, 999)
// Non-owner: silent denial
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/fortytwo"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/the_answer"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("non-owner /fortytwo replied: %+v", calls)
t.Errorf("non-owner /the_answer replied: %+v", calls)
}
// Owner: reply
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/fortytwo"))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/the_answer"))
if got := rb.LastSent().Text(); got != "The answer." {
t.Errorf("owner /fortytwo reply = %q, want 'The answer.'", got)
t.Errorf("owner /the_answer reply = %q, want 'The answer.'", got)
}
}
+14 -13
View File
@@ -1,6 +1,6 @@
// Package misc is a small stub module that proves the framework end-to-end:
// /ping (public, exercises KV write), /mstats (protected, exercises KV read),
// /fortytwo (private easter egg).
// /ping (public, exercises KV write), /ping_stats (protected, exercises KV
// read), /the_answer (private easter egg).
package misc
import (
@@ -20,7 +20,7 @@ import (
"github.com/tiennm99/miti99bot/internal/storage"
)
// lastPingKey is the per-module store key /ping writes and /mstats reads.
// lastPingKey is the per-module store key /ping writes and /ping_stats reads.
const lastPingKey = "last_ping"
// defaultTarget is the substituted "investigator" name when /trongtruonghop is
@@ -45,9 +45,10 @@ func New(deps modules.Deps) modules.Module {
return modules.Module{
Commands: []modules.Command{
pingCommand(store),
mstatsCommand(store),
fortytwoCommand(),
trongTruongHopCommand(),
pingStatsCommand(store),
theAnswerCommand(),
trongTruongHopCommand("trongtruonghop"),
trongTruongHopCommand("tth"),
},
}
}
@@ -71,9 +72,9 @@ func pingCommand(store storage.DocStore[lastPing]) modules.Command {
}
}
func mstatsCommand(store storage.DocStore[lastPing]) modules.Command {
func pingStatsCommand(store storage.DocStore[lastPing]) modules.Command {
return modules.Command{
Name: "mstats",
Name: "ping_stats",
Visibility: modules.VisibilityProtected,
Description: "Show the timestamp of the last /ping",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -90,7 +91,7 @@ func mstatsCommand(store storage.DocStore[lastPing]) modules.Command {
// User-visible reply mirrors how stock/wordle/loldle handle
// transient store failures — returning the error here would leave
// the user with no reply at all.
log.Error("store get failed", "module", "misc", "command", "mstats", "key", lastPingKey, "err", err)
log.Error("store get failed", "module", "misc", "command", "ping_stats", "key", lastPingKey, "err", err)
text = "Could not load stats. Try again later."
}
return chathelper.Reply(ctx, b, update.Message, text)
@@ -117,9 +118,9 @@ func senderMention(u *models.User) string {
return fmt.Sprintf(`<a href="tg://user?id=%d">%s</a>`, u.ID, html.EscapeString(name))
}
func trongTruongHopCommand() modules.Command {
func trongTruongHopCommand(name string) modules.Command {
return modules.Command{
Name: "trongtruonghop",
Name: name,
Visibility: modules.VisibilityPublic,
Description: "Phát biểu disclaimer cho thành viên hiện tại",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -137,9 +138,9 @@ func trongTruongHopCommand() modules.Command {
}
}
func fortytwoCommand() modules.Command {
func theAnswerCommand() modules.Command {
return modules.Command{
Name: "fortytwo",
Name: "the_answer",
Visibility: modules.VisibilityPrivate,
Description: "Easter egg — the answer",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
+4 -3
View File
@@ -23,9 +23,10 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
want := map[string]modules.Visibility{
"ping": modules.VisibilityPublic,
"mstats": modules.VisibilityProtected,
"fortytwo": modules.VisibilityPrivate,
"ping_stats": modules.VisibilityProtected,
"the_answer": modules.VisibilityPrivate,
"trongtruonghop": modules.VisibilityPublic,
"tth": modules.VisibilityPublic,
}
if len(mod.Commands) != len(want) {
t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want))
@@ -64,7 +65,7 @@ func TestPing_WritesLastPingStore(t *testing.T) {
}
}
func TestMstats_MissingKeyReturnsErrNotFound(t *testing.T) {
func TestPingStats_MissingKeyReturnsErrNotFound(t *testing.T) {
ctx := context.Background()
store := newMiscStore()
+106 -3
View File
@@ -2,27 +2,130 @@ package stats
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
)
const miscCommandRenameMigrationName = "stats-command-renames-misc-20260701"
type commandRename struct {
old string
new string
}
var miscCommandRenames = []commandRename{
{old: "mstats", new: "ping_stats"},
{old: "fortytwo", new: "the_answer"},
}
// InitStore performs stats collection startup maintenance. It is safe to call
// every boot: MongoDB indexes are created idempotently and memory storage is a
// no-op.
func InitStore(ctx context.Context, statsColl storage.Collection) error {
// every boot: MongoDB indexes are created idempotently and one-time migrations
// are guarded by the shared system collection.
func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error {
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
if err := ensureUsageIndexes(ctx, mongoColl); err != nil {
return err
}
}
if err := runMiscCommandRenameMigration(ctx, statsColl, systemColl); err != nil {
return err
}
return nil
}
func runMiscCommandRenameMigration(ctx context.Context, statsColl, systemColl storage.Collection) error {
sys := systemstate.New(systemColl)
key := "migration:" + miscCommandRenameMigrationName
if rec, ok, err := sys.Get(ctx, key); err != nil {
return fmt.Errorf("stats migration state %s: %w", miscCommandRenameMigrationName, err)
} else if ok && rec.Status == "complete" {
return nil
}
count, err := migrateCommandStats(ctx, storage.Typed[usageEntry](statsColl), miscCommandRenames)
if err != nil {
return err
}
now := time.Now().UTC().UnixMilli()
if err := sys.Put(ctx, key, systemstate.Record{
Kind: "migration",
Name: miscCommandRenameMigrationName,
Status: "complete",
Count: count,
CompletedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("stats migration mark complete %s: %w", miscCommandRenameMigrationName, err)
}
return nil
}
func migrateCommandStats(ctx context.Context, docs storage.DocStore[usageEntry], renames []commandRename) (int64, error) {
renameByOld := make(map[string]string, len(renames))
for _, r := range renames {
renameByOld[r.old] = r.new
}
keys, err := docs.List(ctx, "")
if err != nil {
return 0, fmt.Errorf("stats command rename list: %w", err)
}
var moved int64
for _, key := range keys {
src, _, err := docs.Get(ctx, key)
if err != nil {
return moved, fmt.Errorf("stats command rename get %s: %w", key, err)
}
if src.Cmd == "" || src.Deleted {
continue
}
newCmd, ok := renameByOld[src.Cmd]
if !ok {
continue
}
dstKey := usageKey(newCmd, src.UserID)
dst, _, err := docs.Get(ctx, dstKey)
switch {
case errors.Is(err, storage.ErrNotFound):
dst = usageEntry{}
case err != nil:
return moved, fmt.Errorf("stats command rename get %s: %w", dstKey, err)
}
dst.Cmd = newCmd
dst.N += src.N
dst.Deleted = false
if src.UserID != 0 {
dst.UserID = src.UserID
if dst.Username == "" {
dst.Username = src.Username
}
} else {
dst.UserID = 0
dst.Username = ""
}
if err := docs.Put(ctx, dstKey, dst); err != nil {
return moved, fmt.Errorf("stats command rename put %s: %w", dstKey, err)
}
if err := docs.Delete(ctx, key); err != nil {
return moved, fmt.Errorf("stats command rename delete %s: %w", key, err)
}
moved += src.N
}
return moved, nil
}
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
models := []mongo.IndexModel{
{
+2 -1
View File
@@ -34,8 +34,9 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) {
provider := storage.NewMongoProvider(db)
statsColl := provider.Collection("stats")
systemColl := provider.Collection("system")
if err := InitStore(ctx, statsColl); err != nil {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
+75
View File
@@ -0,0 +1,75 @@
package stats
import (
"context"
"errors"
"testing"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/systemstate"
)
func TestInitStore_RenamesMiscCommandStatsOnce(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
seed := map[string]usageEntry{
usageKey("mstats", 0): {Cmd: "mstats", N: 2},
usageKey("mstats", 7): {Cmd: "mstats", UserID: 7, Username: "alice", N: 3},
usageKey("ping_stats", 7): {Cmd: "ping_stats", UserID: 7, Username: "alice", N: 5},
usageKey("fortytwo", 0): {Cmd: "fortytwo", N: 1},
usageKey("the_answer", 0): {Cmd: "the_answer", N: 4},
usageKey("fortytwo", 100): {Cmd: "fortytwo", UserID: 100, Username: "owner", N: 8},
usageKey("the_answer", 100): {Cmd: "the_answer", UserID: 100, Username: "owner", N: 9},
}
for key, entry := range seed {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore first run: %v", err)
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore second run: %v", err)
}
want := map[string]int64{
usageKey("ping_stats", 0): 2,
usageKey("ping_stats", 7): 8,
usageKey("the_answer", 0): 5,
usageKey("the_answer", 100): 17,
}
for key, wantN := range want {
got, _, err := docs.Get(ctx, key)
if err != nil {
t.Fatalf("get %s: %v", key, err)
}
if got.N != wantN {
t.Fatalf("%s N = %d, want %d", key, got.N, wantN)
}
}
for _, key := range []string{
usageKey("mstats", 0),
usageKey("mstats", 7),
usageKey("fortytwo", 0),
usageKey("fortytwo", 100),
} {
if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("old stats key %s still exists: %v", key, err)
}
}
rec, ok, err := systemstate.New(systemColl).Get(ctx, "migration:"+miscCommandRenameMigrationName)
if err != nil {
t.Fatalf("migration marker get: %v", err)
}
if !ok || rec.Status != "complete" || rec.Count != 14 {
t.Fatalf("migration marker = %+v, ok=%v; want complete count 14", rec, ok)
}
}
+13
View File
@@ -421,6 +421,19 @@ func TestRenderStats_CmdUsers(t *testing.T) {
}
}
func TestRenderStats_CmdUsersAcceptsLeadingSlash(t *testing.T) {
c := newStatsCounter()
seedFixture(t, c)
got := renderStats(context.Background(), c, "cmd /wordle")
if !strings.HasPrefix(got, "Users of /wordle:\n") {
t.Errorf("missing normalized header: %q", got)
}
if strings.Contains(got, "//wordle") {
t.Errorf("command header kept duplicate slash: %q", got)
}
}
func TestRenderStats_CmdNotFound(t *testing.T) {
c := newStatsCounter()
seedFixture(t, c)
+5 -1
View File
@@ -61,7 +61,11 @@ func renderStats(ctx context.Context, c *counter, args string) string {
if len(fields) < 2 {
return statsUsage
}
return viewCmdUsers(ctx, c, fields[1])
cmd := strings.TrimPrefix(fields[1], "/")
if cmd == "" {
return statsUsage
}
return viewCmdUsers(ctx, c, cmd)
default:
return statsUsage
}
+22 -13
View File
@@ -3,6 +3,7 @@ package stock
import (
"context"
"errors"
"math"
"strconv"
"strings"
"time"
@@ -67,6 +68,14 @@ func argsAfterCommand(text string) []string {
return parts[1:]
}
func parsePositiveFinite(raw string) (float64, bool) {
n, err := strconv.ParseFloat(raw, 64)
if err != nil || n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, false
}
return n, true
}
func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Update) error {
args := argsAfterCommand(update.Message.Text)
if len(args) != 1 {
@@ -99,12 +108,12 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 1 {
if len(args) != 1 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_topup <amount>\nExample: /stock_topup 5000000")
}
amount, err := strconv.ParseFloat(args[0], 64)
if err != nil || amount <= 0 {
return chathelper.Reply(ctx, b, update.Message, "Amount must be a positive number.")
amount, ok := parsePositiveFinite(args[0])
if !ok {
return chathelper.Reply(ctx, b, update.Message, "Amount must be a positive finite number.")
}
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
@@ -131,7 +140,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_buy <qty> <TICKER>\nExample: /stock_buy 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
@@ -188,7 +197,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message, "Usage: /stock_sell <qty> <TICKER>\nExample: /stock_sell 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
@@ -247,9 +256,9 @@ func (s *state) handleBonus(ctx context.Context, b *bot.Bot, update *models.Upda
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_bonus <qty> <TICKER>\nExample: /stock_bonus 200 TCX")
"Usage: /stock_bonus <qty> <TICKER>\nExample: /stock_bonus 200 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
@@ -294,13 +303,13 @@ func (s *state) handleDividend(ctx context.Context, b *bot.Bot, update *models.U
"Cannot identify user — stock only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
if len(args) != 2 {
return chathelper.Reply(ctx, b, update.Message,
"Usage: /stock_dividend <amount_per_share> <TICKER>\nExample: /stock_dividend 1500 TCX")
"Usage: /stock_dividend <amount_per_share> <TICKER>\nExample: /stock_dividend 1500 TCB")
}
amountPerShare, err := strconv.ParseFloat(args[0], 64)
if err != nil || amountPerShare <= 0 {
return chathelper.Reply(ctx, b, update.Message, "Amount per share must be a positive number.")
amountPerShare, ok := parsePositiveFinite(args[0])
if !ok {
return chathelper.Reply(ctx, b, update.Message, "Amount per share must be a positive finite number.")
}
symbol, err := normalizeStockSymbol(args[1])
+96
View File
@@ -8,6 +8,8 @@ import (
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
@@ -71,6 +73,100 @@ func TestHandlePriceUsage(t *testing.T) {
rb.AssertSentText(t, "Usage: /stock_price <TICKER>")
}
func TestMutableHandlersRejectExtraArgs(t *testing.T) {
ctx := context.Background()
s := &state{
store: newStockStore(),
prices: &PriceClient{},
nowFn: func() time.Time { return time.UnixMilli(123) },
}
cases := []struct {
name string
text string
run func(context.Context, *testutil.RecordingBot, *models.Update) error
want string
}{
{
name: "topup",
text: "/stock_topup 1000000 extra",
run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
return s.handleTopup(ctx, rb.Bot, upd)
},
want: "Usage: /stock_topup <amount>",
},
{
name: "buy",
text: "/stock_buy 100 TCB extra",
run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
return s.handleBuy(ctx, rb.Bot, upd)
},
want: "Usage: /stock_buy <qty> <TICKER>",
},
{
name: "sell",
text: "/stock_sell 100 TCB extra",
run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
return s.handleSell(ctx, rb.Bot, upd)
},
want: "Usage: /stock_sell <qty> <TICKER>",
},
{
name: "bonus",
text: "/stock_bonus 100 TCB extra",
run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
return s.handleBonus(ctx, rb.Bot, upd)
},
want: "Usage: /stock_bonus <qty> <TICKER>",
},
{
name: "dividend",
text: "/stock_dividend 1500 TCB extra",
run: func(ctx context.Context, rb *testutil.RecordingBot, upd *models.Update) error {
return s.handleDividend(ctx, rb.Bot, upd)
},
want: "Usage: /stock_dividend <amount_per_share> <TICKER>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rb := testutil.NewRecordingBot(t)
if err := tc.run(ctx, rb, testutil.NewPrivateMessage(7, tc.text)); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
rb.AssertSentText(t, tc.want)
})
}
p, err := LoadPortfolio(ctx, s.store, 7, 999)
if err != nil {
t.Fatalf("LoadPortfolio: %v", err)
}
if p.Currency["VND"] != 0 || len(p.Assets) != 0 {
t.Fatalf("invalid commands mutated portfolio: %+v", p)
}
}
func TestMutableHandlersRejectNonFiniteVND(t *testing.T) {
ctx := context.Background()
s := &state{
store: newStockStore(),
prices: &PriceClient{},
nowFn: func() time.Time { return time.UnixMilli(123) },
}
rb := testutil.NewRecordingBot(t)
if err := s.handleTopup(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_topup NaN")); err != nil {
t.Fatalf("topup: %v", err)
}
rb.AssertSentText(t, "positive finite")
rb.Reset()
if err := s.handleDividend(ctx, rb.Bot, testutil.NewPrivateMessage(7, "/stock_dividend Inf TCB")); err != nil {
t.Fatalf("dividend: %v", err)
}
rb.AssertSentText(t, "positive finite")
}
func modDepsForTest() modules.Deps {
return modules.Deps{Store: storage.NewMemoryProvider().Collection("stock")}
}
+6 -15
View File
@@ -19,10 +19,11 @@ var supportFooter = fmt.Sprintf(
repoURL, repoURL,
)
// RenderHelp produces the body of /help: each module's public + protected
// commands grouped under a bold module name, followed by the support footer.
// RenderHelp produces the body of /help: each module's public commands
// grouped under a bold module name, followed by the support footer.
// Modules in MODULES-env order. Modules with no visible commands are omitted.
// Private commands are always skipped.
// Protected and private commands are hidden; authorization-specific commands
// stay discoverable only through operator knowledge, not the public help/menu.
//
// Exposed (capitalised) so tests can assert on the string without spinning up
// a bot context.
@@ -34,18 +35,12 @@ func RenderHelp(reg *modules.Registry) string {
type entry struct {
name string
description string
protected bool
}
byModule := make(map[string][]entry, len(reg.Modules))
for _, c := range reg.PublicCommands() {
byModule[ownerOf(reg, c.Name)] = append(byModule[ownerOf(reg, c.Name)], entry{
name: c.Name, description: c.Description, protected: false,
})
}
for _, c := range reg.ProtectedCommands() {
byModule[ownerOf(reg, c.Name)] = append(byModule[ownerOf(reg, c.Name)], entry{
name: c.Name, description: c.Description, protected: true,
name: c.Name, description: c.Description,
})
}
@@ -58,11 +53,7 @@ func RenderHelp(reg *modules.Registry) string {
var sb strings.Builder
fmt.Fprintf(&sb, "<b>%s</b>", html.EscapeString(mod.Name))
for _, e := range es {
suffix := ""
if e.protected {
suffix = " (protected)"
}
fmt.Fprintf(&sb, "\n/%s — %s%s", e.name, html.EscapeString(e.description), suffix)
fmt.Fprintf(&sb, "\n/%s — %s", e.name, html.EscapeString(e.description))
}
sections = append(sections, sb.String())
}
+7 -4
View File
@@ -26,7 +26,7 @@ func fakeFactory(name string, cmds []modules.Command) modules.Factory {
}
}
func TestRenderHelp_GroupsByModuleAndSkipsPrivate(t *testing.T) {
func TestRenderHelp_GroupsByModuleAndSkipsNonPublic(t *testing.T) {
cmd := func(name string, vis modules.Visibility, desc string) modules.Command {
return modules.Command{Name: name, Visibility: vis, Description: desc, Handler: helpTestNoop}
}
@@ -52,7 +52,6 @@ func TestRenderHelp_GroupsByModuleAndSkipsPrivate(t *testing.T) {
"<b>alpha</b>",
"<b>beta</b>",
"/a_pub — alpha public",
"/a_prot — alpha protected (protected)",
// HTML in user descriptions must be escaped.
"beta &lt;i&gt;desc&lt;/i&gt;",
// Locks html.EscapeString contract: & → &amp;, " → &#34;.
@@ -67,6 +66,9 @@ func TestRenderHelp_GroupsByModuleAndSkipsPrivate(t *testing.T) {
if strings.Contains(out, "a_priv") {
t.Errorf("output leaked private command\n---output---\n%s", out)
}
if strings.Contains(out, "a_prot") {
t.Errorf("output leaked protected command\n---output---\n%s", out)
}
}
func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) {
@@ -100,7 +102,8 @@ func TestRenderHelp_OmitsModulesWithNoVisibleCommands(t *testing.T) {
}
factories := map[string]modules.Factory{
"shadow": fakeFactory("shadow", []modules.Command{
cmd("hidden", modules.VisibilityPrivate),
cmd("hidden_private", modules.VisibilityPrivate),
cmd("hidden_protected", modules.VisibilityProtected),
}),
"visible": fakeFactory("visible", []modules.Command{
cmd("seen", modules.VisibilityPublic),
@@ -112,7 +115,7 @@ func TestRenderHelp_OmitsModulesWithNoVisibleCommands(t *testing.T) {
}
out := util.RenderHelp(reg)
if strings.Contains(out, "<b>shadow</b>") {
t.Errorf("module with only private commands should not render a section\n%s", out)
t.Errorf("module with only non-public commands should not render a section\n%s", out)
}
if !strings.Contains(out, "<b>visible</b>") {
t.Errorf("visible module section missing\n%s", out)
+20 -4
View File
@@ -75,6 +75,20 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa
return chathelper.ReplyHTML(ctx, b, msg, text)
}
func subscriptionScope(msg *models.Message) string {
if msg != nil && msg.MessageThreadID != 0 {
return "this topic"
}
return "this chat"
}
func subscriptionScopeSentenceSubject(msg *models.Message) string {
if msg != nil && msg.MessageThreadID != 0 {
return "This topic"
}
return "This chat"
}
func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
@@ -86,12 +100,13 @@ func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.
if err != nil {
return err
}
scope := subscriptionScope(msg)
if added {
return chathelper.Reply(ctx, b, msg,
"Subscribed. You'll get today's World Cup schedule at 00:00 UTC+7.\n"+
"Subscribed "+scope+" to the daily World Cup schedule at 00:00 UTC+7.\n"+
"If you block the bot, you'll be auto-unsubscribed on the next push.")
}
return chathelper.Reply(ctx, b, msg, "Already subscribed.")
return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".")
}
func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -105,8 +120,9 @@ func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *model
if err != nil {
return err
}
scope := subscriptionScope(msg)
if removed {
return chathelper.Reply(ctx, b, msg, "Unsubscribed.")
return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".")
}
return chathelper.Reply(ctx, b, msg, "You weren't subscribed.")
return chathelper.Reply(ctx, b, msg, subscriptionScopeSentenceSubject(msg)+" wasn't subscribed.")
}
+4 -1
View File
@@ -90,7 +90,7 @@ func TestHandleWeek_RendersThisWeek(t *testing.T) {
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
rb, store := installWC(t, sampleMatchesBody, fakeNow)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wc_subscribe"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed") || !strings.Contains(got, "00:00 UTC+7") {
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this chat") || !strings.Contains(got, "00:00 UTC+7") {
t.Fatalf("first reply = %q, want subscribed with 00:00 UTC+7", got)
}
rb.Reset()
@@ -110,6 +110,9 @@ func TestHandleSubscribe_ForumTopic(t *testing.T) {
upd.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), upd)
if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this topic") {
t.Fatalf("topic subscribe reply = %q", got)
}
subs, _ := listSubscribers(context.Background(), store)
if len(subs) != 1 || subs[0] != (Subscriber{ChatID: 555, ThreadID: 42}) {
t.Fatalf("subs = %v, want topic subscription", subs)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
const ictOffset = 7 * time.Hour
const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
const formatHint = "Use dd, dd-mm, dd/mm, ddmm, dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy."
// IctLocation is the fixed-offset UTC+7 timezone for schedule display.
var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second))
+1 -1
View File
@@ -18,7 +18,7 @@ func New(deps modules.Deps) modules.Module {
{
Name: "wc",
Visibility: modules.VisibilityPublic,
Description: "World Cup matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)",
Description: "World Cup matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)",
Handler: s.handleSchedule,
},
{
+4 -1
View File
@@ -180,10 +180,13 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
g, err := s.getOrInit(ctx, subject)
g, err := loadGame(ctx, s.games, subject)
if err != nil {
return err
}
if g == nil {
return chathelper.Reply(ctx, b, msg, "No active round. /wordle_new to start one.")
}
if g.Solved {
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target)))
}
+23
View File
@@ -116,6 +116,29 @@ func TestWordleGiveup_RevealsAnswer(t *testing.T) {
}
}
func TestWordleGiveup_NoActiveRoundDoesNotStartOrRecordLoss(t *testing.T) {
rb, games := installWordle(t, 0, "", "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_giveup"))
got := rb.LastSent().Text()
if !strings.Contains(got, "No active round") {
t.Fatalf("/wordle_giveup with no round: %q", got)
}
g, err := loadGame(context.Background(), games, "1")
if err != nil {
t.Fatalf("loadGame: %v", err)
}
if g != nil {
t.Fatalf("giveup created game: %+v", g)
}
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_stats"))
if got := rb.LastSent().Text(); !strings.Contains(got, "Played: 0") {
t.Fatalf("empty giveup recorded stats: %q", got)
}
}
func TestWordleStats_Empty(t *testing.T) {
rb, _ := installWordle(t, 0, "", "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_stats"))
+16 -12
View File
@@ -4,21 +4,25 @@
"command": "help",
"description": "Show all available commands"
},
{
"command": "info",
"description": "Show chat, thread, and sender IDs"
},
{
"command": "ping",
"description": "Health check; replies pong"
},
{
"command": "trongtruonghop",
"description": "Phát biểu disclaimer cho thành viên hiện tại"
},
{
"command": "tth",
"description": "Alias for /trongtruonghop"
},
{
"command": "wordle",
"description": "Classic wordle; guess the 5-letter word"
},
{
"command": "wordle_new",
"description": "Start a new wordle round"
"description": "Start a new wordle round; active round counts as give-up"
},
{
"command": "wordle_giveup",
@@ -42,7 +46,7 @@
},
{
"command": "lol",
"description": "LoL matches for a date"
"description": "LoL matches for a date; supports dd, dd-mm, dd/mm, ddmm"
},
{
"command": "lol_this_week",
@@ -58,7 +62,7 @@
},
{
"command": "wc",
"description": "World Cup matches for a date"
"description": "World Cup matches for a date; supports dd, dd-mm, dd/mm, ddmm"
},
{
"command": "wc_this_week",
@@ -110,11 +114,11 @@
},
{
"command": "gold_buy",
"description": "Buy gold at spot price (luong)"
"description": "Buy gold at SJC sell price (luong)"
},
{
"command": "gold_sell",
"description": "Sell gold back to VND (luong)"
"description": "Sell gold at SJC buy price (luong)"
},
{
"command": "gold_portfolio",
@@ -130,11 +134,11 @@
},
{
"command": "coin_buy",
"description": "Buy coin with USD amount"
"description": "Spend a USD amount to buy coin"
},
{
"command": "coin_sell",
"description": "Sell coin for a USD amount"
"description": "Sell enough coin to receive a USD amount"
},
{
"command": "coin_portfolio",
@@ -142,7 +146,7 @@
},
{
"command": "stats",
"description": "Stats. Try: /stats users, /stats user <name>, /stats cmd <name>"
"description": "Stats. Try: /stats users, /stats user <username>, /stats cmd <command>"
}
]
}