mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-17 08:24:46 +00:00
feat: refine command names and behavior
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 <champion></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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 <i>desc</i>",
|
||||
// Locks html.EscapeString contract: & → &, " → ".
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user