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:
tiennm99 committed 2026-05-09 15:52:40 +07:00
1 parent 939c5fedab
commit 80ef664473
16 files changed
+503 -546

No files matched your search

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