refactor(modules): extract shared chathelper + champname packages

Phase 3 of the 2026-05-09 review remediation plan. Eliminates the
helper drift (subjectFor / argAfterCommand / nowMillis / reply /
replyHTML / winRate) that previously lived in 3-4 modules, plus the
loldle-specific normalize / findChampion / findByExactName.

- internal/modules/util/chathelper: SubjectFor, ArgAfterCommand,
  NowMillis, Reply, ReplyHTML, WinRate. Single canonical SubjectFor
  shape (group/supergroup -> chat ID, else user ID); WinRate uses
  math.Round to match JS Math.round (the truncation drift caught in
  Phase 5b/5c).
- internal/champname: Normalize + generic Find[T] / FindByExactName[T]
  with name-extractor closure. Loldle and loldle-emoji both consume
  via Champion / EmojiChampion.

Migrations: wordle, loldle, loldle-emoji, misc, util/info,
util/stickerid. Module-local lookup.go + normalize.go in loldle and
loldle-emoji deleted.

go test -race -count=1 ./... clean across all 12 packages. Net ~290
lines removed across handler files.
This commit is contained in:
2026-05-09 15:52:40 +07:00
parent 29bbf30923
commit 576b6b6aec
16 changed files with 503 additions and 546 deletions
+71
View File
@@ -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
}
+92
View File
@@ -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)
}
}
+31 -86
View File
@@ -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 <code>/loldle &lt;champion&gt;</code>.",
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(
"🔁 <b>%s</b> 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 <n> — 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))
}
-48
View File
@@ -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
}
+12 -78
View File
@@ -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)
}
}
-20
View File
@@ -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)
}
+30 -81
View File
@@ -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(
"🔁 <b>%s</b> 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! <b>%s</b> — 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 <b>%s</b>.\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 <b>%s</b>. 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 <b>%s</b>.\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 <n> — 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))
}
-39
View File
@@ -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
}
+7 -57
View File
@@ -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")
}
}
-20
View File
@@ -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)
}
+14 -16
View File
@@ -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.")
},
}
}
@@ -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))
}
@@ -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)
}
}
}
+2 -5
View File
@@ -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)
},
}
}
+3 -11
View File
@@ -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())
},
}
}
+27 -85
View File
@@ -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 <word>`.", 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 <word>` to guess.")
return chathelper.Reply(ctx, b, msg.Chat.ID, prelude+"🆕 New round started. Use `/wordle <word>` 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,
))
}