diff --git a/internal/champname/champname.go b/internal/champname/champname.go new file mode 100644 index 0000000..44df073 --- /dev/null +++ b/internal/champname/champname.go @@ -0,0 +1,71 @@ +// Package champname holds champion-name primitives shared by loldle and +// loldleemoji (and any future loldle variant). Normalize folds names to a +// comparable form; Find resolves user input to a single champion via exact +// or unique-prefix match. Generic over the champion type via a name-extractor +// closure so each module can keep its own data shape. +package champname + +import "strings" + +// Normalize folds a name to a comparable form: lowercase, alphanumeric only. +// JS-parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`. +// +// Used for case/space/punctuation-insensitive lookup so "Kai'Sa", "kaisa", +// and "KAI SA" all collapse to the same key. +func Normalize(s string) string { + lower := strings.ToLower(s) + out := make([]byte, 0, len(lower)) + for i := 0; i < len(lower); i++ { + c := lower[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + out = append(out, c) + } + } + return string(out) +} + +// Find resolves user input to a champion via: +// +// 1. Empty / non-alphanumeric input → no match (nil). +// 2. Exact normalised name → that champion. +// 3. Otherwise: unique normalised-prefix match → that champion. +// +// Ambiguous prefix or no hit → nil. Returning nil for ambiguous prefixes +// prevents "Ka" silently routing to whichever Ka- champion happens to be +// first in the data file. +// +// Generic over T so loldle/Champion and loldleemoji/EmojiChampion can both +// use it; nameOf extracts the champion's display name. +func Find[T any](pool []T, input string, nameOf func(*T) string) *T { + q := Normalize(input) + if q == "" { + return nil + } + for i := range pool { + if Normalize(nameOf(&pool[i])) == q { + return &pool[i] + } + } + var hit *T + for i := range pool { + if strings.HasPrefix(Normalize(nameOf(&pool[i])), q) { + if hit != nil { + return nil // ambiguous + } + hit = &pool[i] + } + } + return hit +} + +// FindByExactName looks up a champion by literal display name (no +// normalization). Used to rehydrate a stored game's target. Returns nil if +// the pool was refreshed and the target is no longer present. +func FindByExactName[T any](pool []T, name string, nameOf func(*T) string) *T { + for i := range pool { + if nameOf(&pool[i]) == name { + return &pool[i] + } + } + return nil +} diff --git a/internal/champname/champname_test.go b/internal/champname/champname_test.go new file mode 100644 index 0000000..7e20c4e --- /dev/null +++ b/internal/champname/champname_test.go @@ -0,0 +1,92 @@ +package champname + +import "testing" + +type fakeChamp struct { + Name string +} + +func nameOfFake(c *fakeChamp) string { return c.Name } + +func TestNormalize(t *testing.T) { + tests := []struct { + in, want string + }{ + {"", ""}, + {"Kai'Sa", "kaisa"}, + {"KAI SA", "kaisa"}, + {"kaisa", "kaisa"}, + {"Lee Sin", "leesin"}, + {"Dr. Mundo", "drmundo"}, + {"K9", "k9"}, + {" ", ""}, + {"!!!", ""}, + {"Aurelion Sol", "aurelionsol"}, + {"Cho'Gath", "chogath"}, + } + for _, tt := range tests { + got := Normalize(tt.in) + if got != tt.want { + t.Errorf("Normalize(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestFind(t *testing.T) { + pool := []fakeChamp{ + {Name: "Kai'Sa"}, + {Name: "Karma"}, + {Name: "Karthus"}, + {Name: "Lee Sin"}, + {Name: "Lulu"}, + {Name: "Lux"}, + } + tests := []struct { + name, in string + wantName string // "" → nil expected + }{ + {"empty input → nil", "", ""}, + {"non-alpha input → nil", "!!!", ""}, + {"exact match", "kaisa", "Kai'Sa"}, + {"exact match with punctuation", "Kai'Sa", "Kai'Sa"}, + {"exact match with spaces", "Lee Sin", "Lee Sin"}, + {"unique prefix", "kart", "Karthus"}, + {"unique prefix with case", "KART", "Karthus"}, + {"ambiguous prefix → nil", "ka", ""}, + {"ambiguous prefix lu → nil", "lu", ""}, + {"unambiguous prefix lux", "lux", "Lux"}, + {"no match", "zilean", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Find(pool, tt.in, nameOfFake) + if tt.wantName == "" { + if got != nil { + t.Errorf("Find(%q): got %v, want nil", tt.in, got) + } + return + } + if got == nil { + t.Fatalf("Find(%q): got nil, want %q", tt.in, tt.wantName) + } + if got.Name != tt.wantName { + t.Errorf("Find(%q): got %q, want %q", tt.in, got.Name, tt.wantName) + } + }) + } +} + +func TestFindByExactName(t *testing.T) { + pool := []fakeChamp{{Name: "Kai'Sa"}, {Name: "Karma"}} + + if got := FindByExactName(pool, "Kai'Sa", nameOfFake); got == nil || got.Name != "Kai'Sa" { + t.Errorf("FindByExactName(Kai'Sa) = %v, want hit", got) + } + // Normalisation must NOT apply — exact match only. + if got := FindByExactName(pool, "kaisa", nameOfFake); got != nil { + t.Errorf("FindByExactName(kaisa) = %v, want nil (case-sensitive)", got) + } + if got := FindByExactName(pool, "Zilean", nameOfFake); got != nil { + t.Errorf("FindByExactName(Zilean) = %v, want nil", got) + } +} diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go index 478a56b..5d3dc90 100644 --- a/internal/modules/loldle/handlers.go +++ b/internal/modules/loldle/handlers.go @@ -4,16 +4,15 @@ import ( "context" "fmt" "html" - "math" "math/rand" "strconv" - "strings" - "time" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" + "github.com/tiennm99/miti99bot-go/internal/champname" "github.com/tiennm99/miti99bot-go/internal/keylock" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" "github.com/tiennm99/miti99bot-go/internal/storage" ) @@ -27,37 +26,8 @@ type state struct { locks keylock.Map // serialises Get→mutate→Put per subject } -// subjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game), -// otherwise → user ID. Channels and unknown types fall through to From.ID. -// Returns "" when no usable id is present. -func subjectFor(msg *models.Message) string { - if msg == nil { - return "" - } - switch msg.Chat.Type { - case models.ChatTypeGroup, models.ChatTypeSupergroup: - return strconv.FormatInt(msg.Chat.ID, 10) - default: - if msg.From != nil { - return strconv.FormatInt(msg.From.ID, 10) - } - } - return "" -} - -// argAfterCommand returns everything after the first space in text, trimmed. -// Identical contract to wordle/util — duplicated to keep package-local; -// promoting to a shared helper buys very little until a 4th module needs it. -func argAfterCommand(text string) string { - if text == "" { - return "" - } - idx := strings.IndexByte(text, ' ') - if idx < 0 { - return "" - } - return strings.TrimSpace(text[idx+1:]) -} +// championName extracts the comparable name field for champname helpers. +func championName(c *Champion) string { return c.ChampionName } // pickRandomChampion uses math/rand's mutex-protected globals so concurrent // /loldle handlers don't race on a shared *rand.Rand. @@ -66,7 +36,7 @@ func (s *state) pickRandomChampion() *Champion { } func (s *state) findByName(name string) *Champion { - return findByExactName(s.champions, name) + return champname.FindByExactName(s.champions, name, championName) } // rehydrateGuesses recomputes board rows from the stored championNames. @@ -128,36 +98,18 @@ func trySendSticker(ctx context.Context, b *bot.Bot, chatID int64, pool []string }) } -// reply sends a plain-text response. -func reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) - return err -} - -// replyHTML sends a Telegram HTML-formatted response. -func replyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chatID, - Text: text, - ParseMode: models.ParseModeHTML, - }) - return err -} - -func nowMillis() int64 { return time.Now().UTC().UnixMilli() } - // handleLoldle is /loldle [champion] — show board if no arg, else submit guess. func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error { msg := update.Message if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() - arg := argAfterCommand(msg.Text) + arg := chathelper.ArgAfterCommand(msg.Text) maxGuesses, err := getMaxGuesses(ctx, s.kv, subject) if err != nil { @@ -172,17 +124,17 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd header := fmt.Sprintf("Guess %d/%d. Use /loldle <champion>.", len(game.Guesses), maxGuesses) board := renderBoard(s.rehydrateGuesses(game)) - return replyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board) + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board) } - guess := findChampion(s.champions, arg) + guess := champname.Find(s.champions, arg, championName) if guess == nil { - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg)) } for _, prior := range game.Guesses { if prior == guess.ChampionName { - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "🔁 %s was already guessed this round — try another champion.", html.EscapeString(guess.ChampionName))) } @@ -195,13 +147,13 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd if err := clearGame(ctx, s.kv, subject); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "Champion data was updated since this round started. "+newRoundHint) } results := CompareChampions(guess, target) if game.StartedAt == nil { - now := nowMillis() + now := chathelper.NowMillis() game.StartedAt = &now } game.Guesses = append(game.Guesses, guess.ChampionName) @@ -209,7 +161,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd rendered := renderGuess(guess.ChampionName, results) champ := html.EscapeString(target.ChampionName) - elapsed := formatDuration(nowMillis() - *game.StartedAt) + elapsed := formatDuration(chathelper.NowMillis() - *game.StartedAt) switch { case won: @@ -222,7 +174,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd } trySendSticker(ctx, b, msg.Chat.ID, winStickers) flavor := attemptFlavor(len(game.Guesses), maxGuesses) - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, 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)) @@ -234,14 +186,14 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd return err } trySendSticker(ctx, b, msg.Chat.ID, loseStickers) - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint)) default: if err := saveGame(ctx, s.kv, subject, game); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses)) } } @@ -252,9 +204,9 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() @@ -263,7 +215,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd return err } if existing == nil { - return replyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint) + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint) } if _, err := recordResult(ctx, s.kv, subject, false); err != nil { return err @@ -277,7 +229,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd if target != nil { answer = target.ChampionName } - return replyHTML(ctx, b, msg.Chat.ID, + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint)) } @@ -287,28 +239,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } st, err := loadStats(ctx, s.kv, subject) if err != nil { return err } - winRate := 0 - if st.Played > 0 { - // math.Round matches JS Math.round for positive inputs (round half - // away from zero); int(...) truncation would render 2/3 as 66% where - // the JS source shows 67%. - winRate = int(math.Round(float64(st.Wins) / float64(st.Played) * 100)) - } scope := "group" if msg.Chat.Type == models.ChatTypePrivate { scope = "your" } - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf( "📊 Loldle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", - scope, st.Played, st.Wins, winRate, st.Streak, st.BestStreak)) + scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak)) } // handleSetMax is /loldle_setmax — private command, sets the per-subject @@ -318,17 +263,17 @@ func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Upd if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } - arg := argAfterCommand(msg.Text) + arg := chathelper.ArgAfterCommand(msg.Text) n, err := strconv.Atoi(arg) if err != nil || n < 1 || n > MaxGuessesCap { - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap)) } if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil { return err } - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n)) } diff --git a/internal/modules/loldle/lookup.go b/internal/modules/loldle/lookup.go deleted file mode 100644 index 488adb9..0000000 --- a/internal/modules/loldle/lookup.go +++ /dev/null @@ -1,48 +0,0 @@ -package loldle - -import "strings" - -// findChampion resolves user input to a Champion. JS-faithful semantics: -// -// 1. Empty / non-alphanumeric input → no match (nil). -// 2. Exact normalised name → that champion. -// 3. Otherwise: unique normalised-prefix match → that champion. Ambiguous or -// no prefix match → nil. Returning nil for ambiguous prefixes prevents -// "Ka" silently routing to whichever Ka- champion happens to be first -// in champions.json. -func findChampion(champions []Champion, input string) *Champion { - q := normalize(input) - if q == "" { - return nil - } - - for i := range champions { - if normalize(champions[i].ChampionName) == q { - return &champions[i] - } - } - - var prefixHit *Champion - for i := range champions { - if strings.HasPrefix(normalize(champions[i].ChampionName), q) { - if prefixHit != nil { - // Second prefix match → ambiguous; bail. - return nil - } - prefixHit = &champions[i] - } - } - return prefixHit -} - -// findByExactName looks up a champion by literal championName (no normalization). -// Used by handlers when restoring a stored game's target — the JS `find` over -// raw championName. -func findByExactName(champions []Champion, name string) *Champion { - for i := range champions { - if champions[i].ChampionName == name { - return &champions[i] - } - } - return nil -} diff --git a/internal/modules/loldle/lookup_test.go b/internal/modules/loldle/lookup_test.go index 9b84add..9c5022b 100644 --- a/internal/modules/loldle/lookup_test.go +++ b/internal/modules/loldle/lookup_test.go @@ -1,90 +1,24 @@ package loldle -import "testing" +import ( + "testing" -func TestNormalize(t *testing.T) { - cases := map[string]string{ - "": "", - "Aatrox": "aatrox", - "Kai'Sa": "kaisa", - "KAI SA": "kaisa", - "Twisted Fate": "twistedfate", - "!@#": "", - "42 Vi": "42vi", - } - for in, want := range cases { - if got := normalize(in); got != want { - t.Errorf("normalize(%q) = %q, want %q", in, got, want) - } - } -} - -func TestFindChampion_ExactNormalizedMatch(t *testing.T) { - cs := []Champion{ - {ChampionName: "Aatrox"}, - {ChampionName: "Ahri"}, - {ChampionName: "Akali"}, - } - for _, in := range []string{"aatrox", "AATROX", "Aatrox", "A-A-T-R-O-X"} { - got := findChampion(cs, in) - if got == nil || got.ChampionName != "Aatrox" { - t.Errorf("input %q → %v, want Aatrox", in, got) - } - } -} - -func TestFindChampion_UniquePrefix(t *testing.T) { - cs := []Champion{ - {ChampionName: "Aatrox"}, - {ChampionName: "Ahri"}, - {ChampionName: "Akali"}, - } - got := findChampion(cs, "aat") - if got == nil || got.ChampionName != "Aatrox" { - t.Errorf("aat → %v, want Aatrox", got) - } -} - -func TestFindChampion_AmbiguousPrefixReturnsNil(t *testing.T) { - cs := []Champion{ - {ChampionName: "Akali"}, - {ChampionName: "Akshan"}, - } - if got := findChampion(cs, "ak"); got != nil { - t.Errorf("ambiguous ak should be nil; got %v", got) - } -} - -func TestFindChampion_EmptyInputReturnsNil(t *testing.T) { - cs := []Champion{{ChampionName: "Aatrox"}} - if got := findChampion(cs, ""); got != nil { - t.Errorf("empty input should be nil; got %v", got) - } - if got := findChampion(cs, "!!!"); got != nil { - t.Errorf("non-alphanumeric input should be nil; got %v", got) - } -} - -func TestFindByExactName(t *testing.T) { - cs := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}} - if got := findByExactName(cs, "Ahri"); got == nil || got.ChampionName != "Ahri" { - t.Errorf("exact Ahri → %v", got) - } - // findByExactName is literal — no case-folding. - if got := findByExactName(cs, "ahri"); got != nil { - t.Errorf("lowercase should not match exact name lookup; got %v", got) - } -} + "github.com/tiennm99/miti99bot-go/internal/champname" +) +// Generic name lookup primitives (Normalize, Find, FindByExactName) live in +// internal/champname and are tested there. This file only exercises the +// embedded champions.json — wiring + shape integration test. func TestLoadChampions_EmbedIsValid(t *testing.T) { cs := loadChampions() if n := len(cs); n < 150 || n > 200 { t.Errorf("champion count = %d, want ~172", n) } - // Spot-check a known champion that the JS test suite depends on. - if got := findByExactName(cs, "Aatrox"); got == nil { - t.Error("expected Aatrox in embedded list") - } else if got.Gender != "Male" || got.Resource != "Manaless" { + got := champname.FindByExactName(cs, "Aatrox", championName) + if got == nil { + t.Fatal("expected Aatrox in embedded list") + } + if got.Gender != "Male" || got.Resource != "Manaless" { t.Errorf("Aatrox shape unexpected: %+v", got) } } diff --git a/internal/modules/loldle/normalize.go b/internal/modules/loldle/normalize.go deleted file mode 100644 index d647d09..0000000 --- a/internal/modules/loldle/normalize.go +++ /dev/null @@ -1,20 +0,0 @@ -package loldle - -import "strings" - -// normalize folds a name into a comparable form: lowercase, alphanumeric only. -// JS parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`. -// -// Used for case/space/punctuation-insensitive name lookup so "Kai'Sa", -// "kaisa", and "KAI SA" all collapse to the same key. -func normalize(s string) string { - lower := strings.ToLower(s) - out := make([]byte, 0, len(lower)) - for i := 0; i < len(lower); i++ { - c := lower[i] - if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { - out = append(out, c) - } - } - return string(out) -} diff --git a/internal/modules/loldleemoji/handlers.go b/internal/modules/loldleemoji/handlers.go index cad874f..a942544 100644 --- a/internal/modules/loldleemoji/handlers.go +++ b/internal/modules/loldleemoji/handlers.go @@ -4,16 +4,15 @@ import ( "context" "fmt" "html" - "math" "math/rand" "strconv" - "strings" - "time" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" + "github.com/tiennm99/miti99bot-go/internal/champname" "github.com/tiennm99/miti99bot-go/internal/keylock" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" "github.com/tiennm99/miti99bot-go/internal/storage" ) @@ -27,43 +26,13 @@ type state struct { locks keylock.Map // serialises Get→mutate→Put per subject } -// subjectFor: group/supergroup → chat ID, otherwise → user ID. Matches the -// JS source (loldle-emoji/handlers.js getSubject) and classic loldle. Same -// helper exists in classic loldle's handlers.go — duplication accepted at -// 2 callers; will extract once a third loldle variant lands (5 total are -// expected per Phase 6). -func subjectFor(msg *models.Message) string { - if msg == nil { - return "" - } - switch msg.Chat.Type { - case models.ChatTypeGroup, models.ChatTypeSupergroup: - return strconv.FormatInt(msg.Chat.ID, 10) - default: - if msg.From != nil { - return strconv.FormatInt(msg.From.ID, 10) - } - } - return "" -} - -func argAfterCommand(text string) string { - if text == "" { - return "" - } - idx := strings.IndexByte(text, ' ') - if idx < 0 { - return "" - } - return strings.TrimSpace(text[idx+1:]) -} +// championName extracts the comparable name field for champname helpers. +func championName(c *EmojiChampion) string { return c.ChampionName } func (s *state) pickRandom() *EmojiChampion { return &s.pool[rand.Intn(len(s.pool))] } -func nowMillis() int64 { return time.Now().UTC().UnixMilli() } - func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) { target := s.pickRandom() g := &gameState{Target: target.ChampionName, Guesses: []string{}, StartedAt: nil} @@ -84,32 +53,18 @@ func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses in return s.startFreshGame(ctx, subject) } -func reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) - return err -} - -func replyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: chatID, - Text: text, - ParseMode: models.ParseModeHTML, - }) - return err -} - // handleEmoji is /loldle_emoji [champion] — show clue if no arg, else guess. func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Update) error { msg := update.Message if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() - arg := argAfterCommand(msg.Text) + arg := chathelper.ArgAfterCommand(msg.Text) maxGuesses, err := getMaxGuesses(ctx, s.kv, subject) if err != nil { @@ -119,35 +74,35 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda if err != nil { return err } - target := findByExactName(s.pool, game.Target) + target := champname.FindByExactName(s.pool, game.Target, championName) if target == nil { // Pool refreshed mid-round and the target is gone — start over. if err := clearGame(ctx, s.kv, subject); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "Emoji data was updated since this round started. "+newRoundHint) } if arg == "" { - return replyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, game.Guesses, maxGuesses)) + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, game.Guesses, maxGuesses)) } - guess := findChampion(s.pool, arg) + guess := champname.Find(s.pool, arg, championName) if guess == nil { - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg)) } for _, prior := range game.Guesses { if prior == guess.ChampionName { - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "🔁 %s was already guessed this round — try another champion.", html.EscapeString(guess.ChampionName))) } } if game.StartedAt == nil { - now := nowMillis() + now := chathelper.NowMillis() game.StartedAt = &now } game.Guesses = append(game.Guesses, guess.ChampionName) @@ -163,7 +118,7 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda if err := clearGame(ctx, s.kv, subject); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "🎉 Got it! %s — solved in %d/%d\n🔥 Streak: %d\n%s", answer, len(game.Guesses), maxGuesses, st.Streak, newRoundHint)) @@ -174,7 +129,7 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda if err := clearGame(ctx, s.kv, subject); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "%s\n\n❌ Out of guesses. Answer was %s.\n%s", renderBoard(target.Emojis, game.Guesses, maxGuesses), answer, newRoundHint)) @@ -182,7 +137,7 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda if err := saveGame(ctx, s.kv, subject, game); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "%s\n\n❌ Not %s. Guess %d/%d.", renderBoard(target.Emojis, game.Guesses, maxGuesses), html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses)) @@ -195,9 +150,9 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() @@ -206,7 +161,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd return err } if existing == nil { - return replyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint) + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint) } if _, err := recordResult(ctx, s.kv, subject, false); err != nil { return err @@ -214,7 +169,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd if err := clearGame(ctx, s.kv, subject); err != nil { return err } - return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( "🏳️ Answer was %s.\n%s", html.EscapeString(existing.Target), newRoundHint)) } @@ -224,27 +179,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } st, err := loadStats(ctx, s.kv, subject) if err != nil { return err } - winRate := 0 - if st.Played > 0 { - // math.Round matches JS Math.round; int(...) truncation would render - // 2/3 as 66% where the JS source shows 67%. - winRate = int(math.Round(float64(st.Wins) / float64(st.Played) * 100)) - } scope := "group" if msg.Chat.Type == models.ChatTypePrivate { scope = "your" } - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf( + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf( "📊 Loldle Emoji %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", - scope, st.Played, st.Wins, winRate, st.Streak, st.BestStreak)) + scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak)) } // handleSetMax is /loldle_emoji_setmax — private; per-subject override. @@ -253,17 +202,17 @@ func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Upd if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } - arg := argAfterCommand(msg.Text) + arg := chathelper.ArgAfterCommand(msg.Text) n, err := strconv.Atoi(arg) if err != nil || n < 1 || n > MaxGuessesCap { - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_emoji_setmax <1-%d>", MaxGuessesCap)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_emoji_setmax <1-%d>", MaxGuessesCap)) } if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil { return err } - return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle emoji max guesses set to %d (applies to the next round).", n)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle emoji max guesses set to %d (applies to the next round).", n)) } diff --git a/internal/modules/loldleemoji/lookup.go b/internal/modules/loldleemoji/lookup.go deleted file mode 100644 index 4a9e922..0000000 --- a/internal/modules/loldleemoji/lookup.go +++ /dev/null @@ -1,39 +0,0 @@ -package loldleemoji - -import "strings" - -// findChampion: exact normalised match first, then unique-prefix fallback. -// Ambiguous prefix or no hit → nil. JS-faithful (lookup.js). -func findChampion(pool []EmojiChampion, input string) *EmojiChampion { - q := normalize(input) - if q == "" { - return nil - } - for i := range pool { - if normalize(pool[i].ChampionName) == q { - return &pool[i] - } - } - var hit *EmojiChampion - for i := range pool { - if strings.HasPrefix(normalize(pool[i].ChampionName), q) { - if hit != nil { - return nil // ambiguous - } - hit = &pool[i] - } - } - return hit -} - -// findByExactName looks up a champion by literal championName. Used to -// rehydrate the target from a stored game. Returns nil if the pool was -// refreshed and the target is no longer present. -func findByExactName(pool []EmojiChampion, name string) *EmojiChampion { - for i := range pool { - if pool[i].ChampionName == name { - return &pool[i] - } - } - return nil -} diff --git a/internal/modules/loldleemoji/lookup_test.go b/internal/modules/loldleemoji/lookup_test.go index 4e77e29..21bc820 100644 --- a/internal/modules/loldleemoji/lookup_test.go +++ b/internal/modules/loldleemoji/lookup_test.go @@ -1,62 +1,13 @@ package loldleemoji -import "testing" +import ( + "testing" -func TestNormalize(t *testing.T) { - cases := map[string]string{ - "": "", - "Aatrox": "aatrox", - "Kai'Sa": "kaisa", - "KAI SA": "kaisa", - "Twisted Fate": "twistedfate", - "!@#": "", - "42 Vi": "42vi", - } - for in, want := range cases { - if got := normalize(in); got != want { - t.Errorf("normalize(%q) = %q, want %q", in, got, want) - } - } -} - -func TestFindChampion_ExactAndPrefixAndAmbiguous(t *testing.T) { - pool := []EmojiChampion{ - {ChampionName: "Aatrox", Emojis: "⚔️"}, - {ChampionName: "Akali", Emojis: "🥷"}, - {ChampionName: "Akshan", Emojis: "🪝"}, - } - - // Exact normalised match. - if got := findChampion(pool, "AATROX"); got == nil || got.ChampionName != "Aatrox" { - t.Errorf("AATROX → %v, want Aatrox", got) - } - // Unique prefix. - if got := findChampion(pool, "aat"); got == nil || got.ChampionName != "Aatrox" { - t.Errorf("aat → %v, want Aatrox", got) - } - // Ambiguous prefix → nil. - if got := findChampion(pool, "ak"); got != nil { - t.Errorf("ambiguous ak → %v, want nil", got) - } - // Empty / non-alphanumeric → nil. - if got := findChampion(pool, ""); got != nil { - t.Errorf("empty input → %v, want nil", got) - } - if got := findChampion(pool, "!!!"); got != nil { - t.Errorf("!!! → %v, want nil", got) - } -} - -func TestFindByExactName(t *testing.T) { - pool := []EmojiChampion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}} - if got := findByExactName(pool, "Ahri"); got == nil || got.ChampionName != "Ahri" { - t.Errorf("exact Ahri → %v", got) - } - if got := findByExactName(pool, "ahri"); got != nil { - t.Errorf("lowercase should not match exact lookup, got %v", got) - } -} + "github.com/tiennm99/miti99bot-go/internal/champname" +) +// Generic lookup primitives live in internal/champname. This file only +// exercises the embedded pool — wiring + filter integration test. func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) { pool := loadPool() if n := len(pool); n < 150 || n > 200 { @@ -67,8 +18,7 @@ func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) { t.Errorf("empty-emoji record leaked through filter: %s", c.ChampionName) } } - // Spot-check a known champion. - if got := findByExactName(pool, "Aatrox"); got == nil { + if got := champname.FindByExactName(pool, "Aatrox", championName); got == nil { t.Error("expected Aatrox in pool") } } diff --git a/internal/modules/loldleemoji/normalize.go b/internal/modules/loldleemoji/normalize.go deleted file mode 100644 index efed520..0000000 --- a/internal/modules/loldleemoji/normalize.go +++ /dev/null @@ -1,20 +0,0 @@ -package loldleemoji - -import "strings" - -// normalize folds names to a comparable form: lowercase, alphanumeric only. -// JS-parity with util/normalize-name.js. Same logic also lives in the -// classic loldle package — duplication accepted for now (two callers); a -// shared `internal/champname` helper makes sense once the next loldle -// variant lands. -func normalize(s string) string { - lower := strings.ToLower(s) - out := make([]byte, 0, len(lower)) - for i := 0; i < len(lower); i++ { - c := lower[i] - if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { - out = append(out, c) - } - } - return string(out) -} diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index 900af7a..24c81bb 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -14,6 +14,7 @@ import ( "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" "github.com/tiennm99/miti99bot-go/internal/storage" ) @@ -45,16 +46,15 @@ func pingCommand(deps modules.Deps) modules.Command { Visibility: modules.VisibilityPublic, Description: "Health check — replies pong and records last ping", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } // Best-effort write — if KV is unavailable, still reply. - payload := lastPing{At: time.Now().UTC().UnixMilli()} + payload := lastPing{At: chathelper.NowMillis()} if err := deps.KV.PutJSON(ctx, lastPingKey, payload); err != nil { log.Printf("misc /ping: putJSON failed: %v", err) } - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - Text: "pong", - }) - return err + return chathelper.Reply(ctx, b, update.Message.Chat.ID, "pong") }, } } @@ -65,6 +65,9 @@ func mstatsCommand(deps modules.Deps) modules.Command { Visibility: modules.VisibilityProtected, Description: "Show the timestamp of the last /ping", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } var last lastPing text := "last ping: never" err := deps.KV.GetJSON(ctx, lastPingKey, &last) @@ -75,11 +78,7 @@ func mstatsCommand(deps modules.Deps) modules.Command { case err != nil && !errors.Is(err, storage.ErrNotFound): return fmt.Errorf("misc /mstats: %w", err) } - _, err = b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - Text: text, - }) - return err + return chathelper.Reply(ctx, b, update.Message.Chat.ID, text) }, } } @@ -90,11 +89,10 @@ func fortytwoCommand() modules.Command { Visibility: modules.VisibilityPrivate, Description: "Easter egg — the answer", Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: update.Message.Chat.ID, - Text: "The answer.", - }) - return err + if update.Message == nil { + return nil + } + return chathelper.Reply(ctx, b, update.Message.Chat.ID, "The answer.") }, } } diff --git a/internal/modules/util/chathelper/chathelper.go b/internal/modules/util/chathelper/chathelper.go new file mode 100644 index 0000000..bab70dc --- /dev/null +++ b/internal/modules/util/chathelper/chathelper.go @@ -0,0 +1,83 @@ +// Package chathelper consolidates per-module Telegram helpers that diverged +// across wordle/loldle/loldleemoji/misc. SubjectFor, ArgAfterCommand, +// NowMillis, Reply, ReplyHTML, WinRate previously had near-identical copies +// in each module — one drift incident (winRate truncation) already bit +// Phase 5b/5c. Single source here; modules import. +package chathelper + +import ( + "context" + "math" + "strconv" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// SubjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game +// state), otherwise → user ID. Returns "" when no usable id is present +// (caller should reply with a "cannot identify chat" error). Channels and +// unknown chat types fall through to From.ID. +// +// Canonical shape: the wordle module previously had an explicit +// ChatTypePrivate branch returning From.ID, which is identical to the +// default branch — folded together here. +func SubjectFor(msg *models.Message) string { + if msg == nil { + return "" + } + switch msg.Chat.Type { + case models.ChatTypeGroup, models.ChatTypeSupergroup: + return strconv.FormatInt(msg.Chat.ID, 10) + default: + if msg.From != nil { + return strconv.FormatInt(msg.From.ID, 10) + } + } + return "" +} + +// ArgAfterCommand returns everything after the first space in text, trimmed. +// Works for `/cmd arg`, `/cmd@bot arg`, etc. JS-parity. +func ArgAfterCommand(text string) string { + if text == "" { + return "" + } + idx := strings.IndexByte(text, ' ') + if idx < 0 { + return "" + } + return strings.TrimSpace(text[idx+1:]) +} + +// NowMillis returns current UTC ms-since-epoch. +func NowMillis() int64 { return time.Now().UTC().UnixMilli() } + +// Reply sends a plain-text response to the given chat. +func Reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error { + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) + return err +} + +// ReplyHTML sends a Telegram HTML-formatted response to the given chat. +func ReplyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error { + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + ParseMode: models.ParseModeHTML, + }) + return err +} + +// WinRate computes wins/played as a percentage rounded to nearest int. +// math.Round matches JS Math.round (round half away from zero for positive +// inputs); plain int(...) truncation would render 2/3 as 66% where JS shows +// 67%. Returns 0 when played == 0 (avoids NaN). +func WinRate(wins, played int) int { + if played <= 0 { + return 0 + } + return int(math.Round(float64(wins) / float64(played) * 100)) +} diff --git a/internal/modules/util/chathelper/chathelper_test.go b/internal/modules/util/chathelper/chathelper_test.go new file mode 100644 index 0000000..e90e946 --- /dev/null +++ b/internal/modules/util/chathelper/chathelper_test.go @@ -0,0 +1,131 @@ +package chathelper + +import ( + "testing" + + "github.com/go-telegram/bot/models" +) + +func TestSubjectFor(t *testing.T) { + tests := []struct { + name string + msg *models.Message + want string + }{ + { + name: "nil message", + msg: nil, + want: "", + }, + { + name: "private chat with From", + msg: &models.Message{ + Chat: models.Chat{ID: 999, Type: models.ChatTypePrivate}, + From: &models.User{ID: 42}, + }, + want: "42", + }, + { + name: "private chat without From", + msg: &models.Message{ + Chat: models.Chat{ID: 999, Type: models.ChatTypePrivate}, + }, + want: "", + }, + { + name: "group chat → chat id (ignores From)", + msg: &models.Message{ + Chat: models.Chat{ID: -100, Type: models.ChatTypeGroup}, + From: &models.User{ID: 42}, + }, + want: "-100", + }, + { + name: "supergroup → chat id", + msg: &models.Message{ + Chat: models.Chat{ID: -1001, Type: models.ChatTypeSupergroup}, + From: &models.User{ID: 42}, + }, + want: "-1001", + }, + { + name: "channel falls through to From.ID", + msg: &models.Message{ + Chat: models.Chat{ID: -200, Type: models.ChatTypeChannel}, + From: &models.User{ID: 7}, + }, + want: "7", + }, + { + name: "channel without From", + msg: &models.Message{ + Chat: models.Chat{ID: -200, Type: models.ChatTypeChannel}, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SubjectFor(tt.msg); got != tt.want { + t.Errorf("SubjectFor: got %q, want %q", got, tt.want) + } + }) + } +} + +func TestArgAfterCommand(t *testing.T) { + tests := []struct { + in, want string + }{ + {"", ""}, + {"/cmd", ""}, + {"/cmd ", ""}, + {"/cmd ", ""}, + {"/cmd word", "word"}, + {"/cmd word ", "word"}, + {"/cmd@bot word", "word"}, + {"/cmd two words", "two words"}, + {"/cmd two words ", "two words"}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := ArgAfterCommand(tt.in); got != tt.want { + t.Errorf("ArgAfterCommand(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNowMillis(t *testing.T) { + a := NowMillis() + b := NowMillis() + if b < a { + t.Errorf("NowMillis went backwards: %d → %d", a, b) + } + if a < 1700000000000 { + t.Errorf("NowMillis too small (not ms-epoch?): %d", a) + } +} + +func TestWinRate(t *testing.T) { + tests := []struct { + wins, played, want int + }{ + {0, 0, 0}, // no games + {0, 5, 0}, // 0% + {5, 5, 100}, // 100% + {2, 3, 67}, // round-half-up: 66.67% → 67% (NOT 66%) + {1, 3, 33}, // 33.33% → 33% + {1, 6, 17}, // 16.67% → 17% + {1, 2, 50}, // exact 50% + {3, 4, 75}, // exact 75% + // negative played guards against caller bugs. + {1, -1, 0}, + } + for _, tt := range tests { + got := WinRate(tt.wins, tt.played) + if got != tt.want { + t.Errorf("WinRate(%d,%d) = %d, want %d", tt.wins, tt.played, got, tt.want) + } + } +} diff --git a/internal/modules/util/info.go b/internal/modules/util/info.go index cdb94ae..20b0949 100644 --- a/internal/modules/util/info.go +++ b/internal/modules/util/info.go @@ -8,6 +8,7 @@ import ( "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" ) // infoCommand returns /info — replies plain text with chat / thread / sender @@ -36,11 +37,7 @@ func infoCommand() modules.Command { senderID = fmt.Sprintf("%d", msg.From.ID) } text := fmt.Sprintf("chat id: %s\nthread id: %s\nsender id: %s", chatID, threadID, senderID) - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: msg.Chat.ID, - Text: text, - }) - return err + return chathelper.Reply(ctx, b, msg.Chat.ID, text) }, } } diff --git a/internal/modules/util/stickerid.go b/internal/modules/util/stickerid.go index b8435bf..fa6965f 100644 --- a/internal/modules/util/stickerid.go +++ b/internal/modules/util/stickerid.go @@ -10,6 +10,7 @@ import ( "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" ) const stickerIDUsage = "Reply to a sticker message with /stickerid to get its file_id.\n" + @@ -31,11 +32,7 @@ func stickerIDCommand() modules.Command { sticker := stickerFrom(msg) if sticker == nil { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: msg.Chat.ID, - Text: stickerIDUsage, - }) - return err + return chathelper.Reply(ctx, b, msg.Chat.ID, stickerIDUsage) } setName := sticker.SetName @@ -55,12 +52,7 @@ func stickerIDCommand() modules.Command { fmt.Fprintf(&sb, "set: %s · emoji: %s", html.EscapeString(setName), html.EscapeString(emoji)) - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: msg.Chat.ID, - Text: sb.String(), - ParseMode: models.ParseModeHTML, - }) - return err + return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, sb.String()) }, } } diff --git a/internal/modules/wordle/handlers.go b/internal/modules/wordle/handlers.go index b4f42c9..f732777 100644 --- a/internal/modules/wordle/handlers.go +++ b/internal/modules/wordle/handlers.go @@ -3,15 +3,13 @@ package wordle import ( "context" "fmt" - "math" - "strconv" "strings" - "time" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "github.com/tiennm99/miti99bot-go/internal/keylock" + "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" "github.com/tiennm99/miti99bot-go/internal/storage" ) @@ -24,41 +22,6 @@ type state struct { locks keylock.Map // per-subject mutex; serialises Get→mutate→Put } -// subjectFor mirrors JS getSubject: per-user in DMs, per-chat in groups, -// per-user fallback for channels and unknown types. Returns an empty string -// when no usable subject id is present (caller replies with an error). -func subjectFor(msg *models.Message) string { - if msg == nil { - return "" - } - switch msg.Chat.Type { - case models.ChatTypePrivate: - if msg.From != nil { - return strconv.FormatInt(msg.From.ID, 10) - } - case models.ChatTypeGroup, models.ChatTypeSupergroup: - return strconv.FormatInt(msg.Chat.ID, 10) - default: - if msg.From != nil { - return strconv.FormatInt(msg.From.ID, 10) - } - } - return "" -} - -// argAfterCommand returns everything after the first space in text, trimmed. -// JS-parity. Works for `/wordle apple`, `/wordle@bot apple`, etc. -func argAfterCommand(text string) string { - if text == "" { - return "" - } - idx := strings.IndexByte(text, ' ') - if idx < 0 { - return "" - } - return strings.TrimSpace(text[idx+1:]) -} - // rejectMessage maps a validation failure into the user-facing reply. JS // parity word-for-word. func rejectMessage(reason rejectReason) string { @@ -72,20 +35,6 @@ func rejectMessage(reason rejectReason) string { } } -// reply is a tiny helper so the four handlers don't all repeat the same -// SendMessage incantation. Returns the SendMessage error to the dispatcher. -func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error { - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ - ChatID: msg.Chat.ID, - Text: text, - }) - return err -} - -// nowMillis returns current UTC ms-since-epoch — single source of "now" so -// tests can substitute via the kv-state path if needed. -func nowMillis() int64 { return time.Now().UTC().UnixMilli() } - // startFresh writes a brand-new round and returns it. Errors propagate to // the caller (not swallowed — a KV failure means subsequent ops will lie). func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) { @@ -98,7 +47,7 @@ func (s *state) startFresh(ctx context.Context, subject string) (*GameState, err Guesses: []GuessRecord{}, Solved: false, Giveup: false, - StartedAt: nowMillis(), + StartedAt: chathelper.NowMillis(), } if err := saveGame(ctx, s.kv, subject, g); err != nil { return nil, err @@ -123,12 +72,12 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() - arg := argAfterCommand(msg.Text) + arg := chathelper.ArgAfterCommand(msg.Text) g, err := s.getOrInit(ctx, subject) if err != nil { @@ -145,17 +94,17 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd default: header = fmt.Sprintf("Guess %d/%d. Use `/wordle `.", len(g.Guesses), MaxGuesses) } - return reply(ctx, b, msg, header+"\n\n"+renderBoard(g.Guesses)) + return chathelper.Reply(ctx, b, msg.Chat.ID, header+"\n\n"+renderBoard(g.Guesses)) } if isFinished(g) { - return reply(ctx, b, msg, + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Current round is over. Use /wordle_new to start another. Answer was %s.", strings.ToUpper(g.Target))) } v := validateGuess(s.set, arg) if !v.OK { - return reply(ctx, b, msg, rejectMessage(v.Reason)) + return chathelper.Reply(ctx, b, msg.Chat.ID, rejectMessage(v.Reason)) } results := CompareWords(v.Word, g.Target) @@ -171,20 +120,20 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd rendered := renderGuess(v.Word, results) switch { case won: - stats, err := recordResult(ctx, s.kv, subject, true, nowMillis()) + stats, err := recordResult(ctx, s.kv, subject, true, chathelper.NowMillis()) if err != nil { return err } - return reply(ctx, b, msg, fmt.Sprintf("%s\n\n🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.", + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\n🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.", rendered, len(g.Guesses), MaxGuesses, stats.Streak)) case len(g.Guesses) >= MaxGuesses: - if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil { + if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil { return err } - return reply(ctx, b, msg, fmt.Sprintf("%s\n\n❌ Out of guesses. Answer was %s. /wordle_new to retry.", + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\n❌ Out of guesses. Answer was %s. /wordle_new to retry.", rendered, strings.ToUpper(g.Target))) default: - return reply(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses)) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses)) } } @@ -195,9 +144,9 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() @@ -207,7 +156,7 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update return err } if prior != nil && !isFinished(prior) { - if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil { + if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil { return err } prelude = fmt.Sprintf("🏳️ Previous round abandoned (auto-giveup). Answer was %s.\n\n", @@ -217,7 +166,7 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update if _, err := s.startFresh(ctx, subject); err != nil { return err } - return reply(ctx, b, msg, prelude+"🆕 New round started. Use `/wordle ` to guess.") + return chathelper.Reply(ctx, b, msg.Chat.ID, prelude+"🆕 New round started. Use `/wordle ` to guess.") } // handleGiveup is /wordle_giveup — reveals answer for the current round. @@ -227,9 +176,9 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } defer s.locks.Acquire(subject)() g, err := s.getOrInit(ctx, subject) @@ -237,19 +186,19 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd return err } if g.Solved { - return reply(ctx, b, msg, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target))) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target))) } if g.Giveup { - return reply(ctx, b, msg, fmt.Sprintf("Already gave up — %s.", strings.ToUpper(g.Target))) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Already gave up — %s.", strings.ToUpper(g.Target))) } g.Giveup = true if err := saveGame(ctx, s.kv, subject, g); err != nil { return err } - if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil { + if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil { return err } - return reply(ctx, b, msg, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target))) + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target))) } // handleStats is /wordle_stats — shows lifetime score for the subject. @@ -258,27 +207,20 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda if msg == nil { return nil } - subject := subjectFor(msg) + subject := chathelper.SubjectFor(msg) if subject == "" { - return reply(ctx, b, msg, "Cannot identify chat.") + return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") } stats, err := loadStats(ctx, s.kv, subject) if err != nil { return err } - winRate := 0 - if stats.Played > 0 { - // math.Round matches JS Math.round (round half away from zero for - // positive inputs); int(...) would truncate 66.66 to 66 where JS - // shows 67. - winRate = int(math.Round(float64(stats.Wins) / float64(stats.Played) * 100)) - } scope := "group" if msg.Chat.Type == models.ChatTypePrivate { scope = "your" } - return reply(ctx, b, msg, fmt.Sprintf( + return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf( "📊 Wordle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", - scope, stats.Played, stats.Wins, winRate, stats.Streak, stats.BestStreak, + scope, stats.Played, stats.Wins, chathelper.WinRate(stats.Wins, stats.Played), stats.Streak, stats.BestStreak, )) }