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,
))
}