diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go
index 7a32b57..5481103 100644
--- a/internal/modules/loldle/handlers.go
+++ b/internal/modules/loldle/handlers.go
@@ -83,14 +83,19 @@ func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses in
// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id
// must never block the text reply that carries the round outcome.
-func trySendSticker(ctx context.Context, b *bot.Bot, chatID int64, pool []string) {
+//
+// Threads MessageThreadID so stickers post in the same forum-supergroup topic
+// as the inbound command (omission posts to General โ same bug class as the
+// Reply helper).
+func trySendSticker(ctx context.Context, b *bot.Bot, msg *models.Message, pool []string) {
id := pickSticker(pool)
- if id == "" {
+ if id == "" || msg == nil {
return
}
_, _ = b.SendSticker(ctx, &bot.SendStickerParams{
- ChatID: chatID,
- Sticker: &models.InputFileString{Data: id},
+ ChatID: msg.Chat.ID,
+ MessageThreadID: msg.MessageThreadID,
+ Sticker: &models.InputFileString{Data: id},
})
}
@@ -102,7 +107,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
arg := chathelper.ArgAfterCommand(msg.Text)
@@ -120,17 +125,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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board)
+ return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+board)
}
guess := findChampion(s.champions, arg)
if guess == nil {
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg))
}
for _, prior := range game.Guesses {
if prior == guess.ChampionName {
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
+ return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"๐ %s was already guessed this round โ try another champion.",
html.EscapeString(guess.ChampionName)))
}
@@ -143,7 +148,7 @@ 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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
+ return chathelper.ReplyHTML(ctx, b, msg,
"Champion data was updated since this round started. "+newRoundHint)
}
@@ -168,9 +173,9 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
- trySendSticker(ctx, b, msg.Chat.ID, winStickers)
+ trySendSticker(ctx, b, msg, winStickers)
flavor := attemptFlavor(len(game.Guesses), maxGuesses)
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
+ return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"%s\n\n๐ %s %s\nโฑ %s ยท ๐ฅ Streak: %d (%d/%d)\n%s",
rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), maxGuesses, newRoundHint))
@@ -181,15 +186,15 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
- trySendSticker(ctx, b, msg.Chat.ID, loseStickers)
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
+ trySendSticker(ctx, b, msg, loseStickers)
+ return chathelper.ReplyHTML(ctx, b, msg, 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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
+ return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
"%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses))
}
}
@@ -202,7 +207,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
@@ -211,7 +216,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
return err
}
if existing == nil {
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
+ return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint)
}
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
return err
@@ -220,12 +225,12 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
- trySendSticker(ctx, b, msg.Chat.ID, giveupStickers)
+ trySendSticker(ctx, b, msg, giveupStickers)
answer := existing.Target
if target != nil {
answer = target.ChampionName
}
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
+ return chathelper.ReplyHTML(ctx, b, msg,
fmt.Sprintf("๐ณ๏ธ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint))
}
@@ -237,7 +242,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
st, err := loadStats(ctx, s.kv, subject)
if err != nil {
@@ -247,7 +252,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
if msg.Chat.Type == models.ChatTypePrivate {
scope = "your"
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"๐ Loldle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
}
@@ -261,15 +266,15 @@ func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Upd
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
arg := chathelper.ArgAfterCommand(msg.Text)
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > MaxGuessesCap {
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap))
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap))
}
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
return err
}
- return chathelper.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, fmt.Sprintf("โ
Loldle max guesses set to %d (applies to the next round).", n))
}
diff --git a/internal/modules/lolschedule/handlers.go b/internal/modules/lolschedule/handlers.go
index 8457d99..d2e1349 100644
--- a/internal/modules/lolschedule/handlers.go
+++ b/internal/modules/lolschedule/handlers.go
@@ -38,9 +38,9 @@ func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.U
arg := chathelper.ArgAfterCommand(msg.Text)
parsed := ParseScheduleDate(arg, s.now())
if !parsed.OK {
- return chathelper.Reply(ctx, b, msg.Chat.ID, parsed.Error)
+ return chathelper.Reply(ctx, b, msg, parsed.Error)
}
- return s.replyForRange(ctx, b, msg.Chat.ID, parsed.Date, addDays(parsed.Date, 1), false)
+ return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false)
}
// handleToday is /lolschedule_today โ today's matches.
@@ -50,7 +50,7 @@ func (s *state) handleToday(ctx context.Context, b *bot.Bot, update *models.Upda
return nil
}
from := ictDayStartOf(s.now())
- return s.replyForRange(ctx, b, msg.Chat.ID, from, addDays(from, 1), false)
+ return s.replyForRange(ctx, b, msg, from, addDays(from, 1), false)
}
// handleWeek is /lolschedule_week โ next 7 ICT days.
@@ -60,12 +60,12 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat
return nil
}
from := ictDayStartOf(s.now())
- return s.replyForRange(ctx, b, msg.Chat.ID, from, addDays(from, 7), true)
+ return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true)
}
// replyForRange fetches + filters + renders a date window. week=true uses
// RenderWeek; false uses RenderToday.
-func (s *state) replyForRange(ctx context.Context, b *bot.Bot, chatID int64, from, to time.Time, week bool) error {
+func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week bool) error {
events, err := s.client.GetEventsCached(ctx, s.kv, from, to)
if err != nil {
log.Error("lolschedule_fetch_fail", "err", err, "from", from, "to", to)
@@ -73,7 +73,7 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, chatID int64, fro
if week {
hint = "Could not fetch this week's matches. Try again later."
}
- return chathelper.Reply(ctx, b, chatID, hint)
+ return chathelper.Reply(ctx, b, msg, hint)
}
filtered := FilterMajor(events)
var text string
@@ -82,7 +82,7 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, chatID int64, fro
} else {
text = RenderToday(filtered, from)
}
- return chathelper.ReplyHTML(ctx, b, chatID, text)
+ return chathelper.ReplyHTML(ctx, b, msg, text)
}
// handleSubscribe is /lolschedule_subscribe โ opt the chat into the daily
@@ -97,10 +97,10 @@ func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.
return err
}
if added {
- return chathelper.Reply(ctx, b, msg.Chat.ID,
+ return chathelper.Reply(ctx, b, msg,
"โ
Subscribed. You'll get today's LoL schedule at 08:00 ICT (push activates with the cron rollout).")
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Already subscribed.")
+ return chathelper.Reply(ctx, b, msg, "Already subscribed.")
}
// handleUnsubscribe is /lolschedule_unsubscribe โ opt out.
@@ -114,7 +114,7 @@ func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *model
return err
}
if removed {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Unsubscribed.")
+ return chathelper.Reply(ctx, b, msg, "Unsubscribed.")
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, "You weren't subscribed.")
+ return chathelper.Reply(ctx, b, msg, "You weren't subscribed.")
}
diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go
index 405b473..095ae6a 100644
--- a/internal/modules/misc/misc.go
+++ b/internal/modules/misc/misc.go
@@ -54,7 +54,7 @@ func pingCommand(deps modules.Deps) modules.Command {
if err := deps.KV.PutJSON(ctx, lastPingKey, payload); err != nil {
log.Error("kv put failed", "module", "misc", "command", "ping", "key", lastPingKey, "err", err)
}
- return chathelper.Reply(ctx, b, update.Message.Chat.ID, "pong")
+ return chathelper.Reply(ctx, b, update.Message, "pong")
},
}
}
@@ -78,7 +78,7 @@ func mstatsCommand(deps modules.Deps) modules.Command {
case err != nil && !errors.Is(err, storage.ErrNotFound):
return fmt.Errorf("misc /mstats: %w", err)
}
- return chathelper.Reply(ctx, b, update.Message.Chat.ID, text)
+ return chathelper.Reply(ctx, b, update.Message, text)
},
}
}
@@ -92,7 +92,7 @@ func fortytwoCommand() modules.Command {
if update.Message == nil {
return nil
}
- return chathelper.Reply(ctx, b, update.Message.Chat.ID, "The answer.")
+ return chathelper.Reply(ctx, b, update.Message, "The answer.")
},
}
}
diff --git a/internal/modules/trading/handlers.go b/internal/modules/trading/handlers.go
index 43abdff..fb09120 100644
--- a/internal/modules/trading/handlers.go
+++ b/internal/modules/trading/handlers.go
@@ -48,12 +48,16 @@ func newState(kv storage.KVStore) *state {
// because state under "user:0" would collide across all such updates.
// Defensive against From.ID == 0 (anonymized senders / future Telegram
// schema drift) for the same reason.
-func senderInfo(update *models.Update) (userID int64, chatID int64, ok bool) {
+//
+// Chat targeting (ID + forum-topic thread) lives on update.Message โ pass
+// that to chathelper.Reply directly; this helper intentionally returns only
+// the per-user state key.
+func senderInfo(update *models.Update) (userID int64, ok bool) {
msg := update.Message
if msg == nil || msg.From == nil || msg.From.ID == 0 {
- return 0, 0, false
+ return 0, false
}
- return msg.From.ID, msg.Chat.ID, true
+ return msg.From.ID, true
}
// argsAfterCommand splits the command body into whitespace-separated args.
@@ -67,18 +71,18 @@ func argsAfterCommand(text string) []string {
}
func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Update) error {
- userID, chatID, ok := senderInfo(update)
+ userID, ok := senderInfo(update)
if !ok {
- return chathelper.Reply(ctx, b, update.Message.Chat.ID,
+ return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user โ trading only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 1 {
- return chathelper.Reply(ctx, b, chatID, "Usage: /trade_topup \nExample: /trade_topup 5000000")
+ return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_topup \nExample: /trade_topup 5000000")
}
amount, err := strconv.ParseFloat(args[0], 64)
if err != nil || amount <= 0 {
- return chathelper.Reply(ctx, b, chatID, "Amount must be a positive number.")
+ return chathelper.Reply(ctx, b, update.Message, "Amount must be a positive number.")
}
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
@@ -86,50 +90,50 @@ func (s *state) handleTopup(ctx context.Context, b *bot.Bot, update *models.Upda
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
p.AddCurrency("VND", amount)
p.Meta.Invested += amount
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
log.Error("trading_save_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Topped up "+FormatVND(amount)+".\nBalance: "+FormatVND(p.Currency["VND"]))
}
func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update) error {
- userID, chatID, ok := senderInfo(update)
+ userID, ok := senderInfo(update)
if !ok {
- return chathelper.Reply(ctx, b, update.Message.Chat.ID,
+ return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user โ trading only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
- return chathelper.Reply(ctx, b, chatID, "Usage: /trade_buy \nExample: /trade_buy 100 TCB")
+ return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_buy \nExample: /trade_buy 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
- return chathelper.Reply(ctx, b, chatID, "Quantity must be a positive whole number.")
+ return chathelper.Reply(ctx, b, update.Message, "Quantity must be a positive whole number.")
}
resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1])
if err != nil {
if errors.Is(err, ErrUnknownTicker) {
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".\n"+s.comingSoonMessage)
}
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not look up that ticker. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
}
price, err := s.prices.FetchPrice(ctx, resolved.Symbol)
if err != nil {
if errors.Is(err, ErrNoPrice) {
- return chathelper.Reply(ctx, b, chatID, "No price available for "+resolved.Symbol+".")
+ return chathelper.Reply(ctx, b, update.Message, "No price available for "+resolved.Symbol+".")
}
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not fetch price. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
}
cost := float64(qty) * price
@@ -138,36 +142,36 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
ok, balance := p.DeductCurrency("VND", cost)
if !ok {
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Insufficient VND. Need "+FormatVND(cost)+", have "+FormatVND(balance)+".")
}
p.AddAsset(resolved.Symbol, qty)
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
log.Error("trading_save_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Bought "+FormatStock(float64(qty))+" "+resolved.Symbol+
" @ "+FormatVND(price)+"\nCost: "+FormatVND(cost))
}
func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Update) error {
- userID, chatID, ok := senderInfo(update)
+ userID, ok := senderInfo(update)
if !ok {
- return chathelper.Reply(ctx, b, update.Message.Chat.ID,
+ return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user โ trading only works in private/group chats with a sender.")
}
args := argsAfterCommand(update.Message.Text)
if len(args) < 2 {
- return chathelper.Reply(ctx, b, chatID, "Usage: /trade_sell \nExample: /trade_sell 100 TCB")
+ return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_sell \nExample: /trade_sell 100 TCB")
}
qty, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || qty <= 0 {
- return chathelper.Reply(ctx, b, chatID, "Quantity must be a positive whole number.")
+ return chathelper.Reply(ctx, b, update.Message, "Quantity must be a positive whole number.")
}
// Resolve + fetch price BEFORE taking the per-user lock. Mirrors handleBuy:
@@ -176,19 +180,19 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
resolved, err := ResolveSymbol(ctx, s.kv, s.prices, args[1])
if err != nil {
if errors.Is(err, ErrUnknownTicker) {
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Unknown stock ticker \""+strings.ToUpper(args[1])+"\".")
}
log.Error("trading_resolve_symbol", "ticker", args[1], "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not look up that ticker. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not look up that ticker. Try again later.")
}
price, err := s.prices.FetchPrice(ctx, resolved.Symbol)
if err != nil {
if errors.Is(err, ErrNoPrice) {
- return chathelper.Reply(ctx, b, chatID, "No price available for "+resolved.Symbol+".")
+ return chathelper.Reply(ctx, b, update.Message, "No price available for "+resolved.Symbol+".")
}
log.Error("trading_fetch_price", "ticker", resolved.Symbol, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not fetch price. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not fetch price. Try again later.")
}
defer s.locks.Acquire(strconv.FormatInt(userID, 10))()
@@ -196,20 +200,20 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
ok, held := p.DeductAsset(resolved.Symbol, qty)
if !ok {
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Insufficient "+resolved.Symbol+". You have: "+FormatStock(float64(held)))
}
revenue := float64(qty) * price
p.AddCurrency("VND", revenue)
if err := SavePortfolio(ctx, s.kv, userID, p); err != nil {
log.Error("trading_save_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not save portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not save portfolio. Try again later.")
}
- return chathelper.Reply(ctx, b, chatID,
+ return chathelper.Reply(ctx, b, update.Message,
"Sold "+FormatStock(float64(qty))+" "+resolved.Symbol+
" @ "+FormatVND(price)+"\nRevenue: "+FormatVND(revenue))
}
@@ -218,22 +222,22 @@ func (s *state) handleConvert(ctx context.Context, b *bot.Bot, update *models.Up
if update.Message == nil {
return nil
}
- return chathelper.Reply(ctx, b, update.Message.Chat.ID,
+ return chathelper.Reply(ctx, b, update.Message,
"Currency exchange is not available yet.\n"+s.comingSoonMessage)
}
// handleStats fetches every held ticker's current price (in parallel) and
// renders the portfolio. Read-only โ no portfolio mutation, so no keylock.
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
- userID, chatID, ok := senderInfo(update)
+ userID, ok := senderInfo(update)
if !ok {
- return chathelper.Reply(ctx, b, update.Message.Chat.ID,
+ return chathelper.Reply(ctx, b, update.Message,
"Cannot identify user โ /trade_stats needs a sender.")
}
p, err := LoadPortfolio(ctx, s.kv, userID, s.now().UnixMilli())
if err != nil {
log.Error("trading_load_portfolio", "user", userID, "err", err)
- return chathelper.Reply(ctx, b, chatID, "Could not load portfolio. Try again later.")
+ return chathelper.Reply(ctx, b, update.Message, "Could not load portfolio. Try again later.")
}
var lines []string
@@ -278,5 +282,5 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
lines = append(lines, "\nTotal value: "+FormatVND(totalValue))
lines = append(lines, "Invested: "+FormatVND(p.Meta.Invested))
lines = append(lines, "P&L: "+FormatPnL(totalValue, p.Meta.Invested))
- return chathelper.Reply(ctx, b, chatID, strings.Join(lines, "\n"))
+ return chathelper.Reply(ctx, b, update.Message, strings.Join(lines, "\n"))
}
diff --git a/internal/modules/twentyq/handlers.go b/internal/modules/twentyq/handlers.go
index f75c217..7596e26 100644
--- a/internal/modules/twentyq/handlers.go
+++ b/internal/modules/twentyq/handlers.go
@@ -78,10 +78,10 @@ func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Up
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
if s.chatter == nil {
- return chathelper.Reply(ctx, b, msg.Chat.ID, notConfig)
+ return chathelper.Reply(ctx, b, msg, notConfig)
}
defer s.locks.Acquire(subject)()
@@ -99,31 +99,31 @@ func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Up
if game == nil {
if s.limiter != nil && !s.limiter.Allow(subject) {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "โณ Slow down โ too many requests in a short window.")
+ return chathelper.Reply(ctx, b, msg, "โณ Slow down โ too many requests in a short window.")
}
fresh, err := s.startFreshGame(ctx)
if err != nil {
if errors.Is(err, ai.ErrRateLimited) {
- return chathelper.Reply(ctx, b, msg.Chat.ID, rateLimited)
+ return chathelper.Reply(ctx, b, msg, rateLimited)
}
log.Warn("twentyq roundstart failed", "err", err)
- return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
+ return chathelper.Reply(ctx, b, msg, upstreamFail)
}
if err := saveGame(ctx, s.kv, subject, fresh); err != nil {
return err
}
if arg == "" {
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatIntro(*fresh))
+ return chathelper.ReplyHTML(ctx, b, msg, formatIntro(*fresh))
}
// Fresh round + immediate question โ show intro then process turn.
- if err := chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatIntro(*fresh)); err != nil {
+ if err := chathelper.ReplyHTML(ctx, b, msg, formatIntro(*fresh)); err != nil {
return err
}
return s.submitTurn(ctx, b, msg, subject, fresh, arg)
}
if arg == "" {
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatBoard(*game))
+ return chathelper.ReplyHTML(ctx, b, msg, formatBoard(*game))
}
return s.submitTurn(ctx, b, msg, subject, game, arg)
}
@@ -131,28 +131,28 @@ func (s *state) handleTwentyq(ctx context.Context, b *bot.Bot, update *models.Up
func (s *state) submitTurn(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, raw string) error {
v := validateQuestion(raw)
if !v.OK {
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, v.Reason)
+ return chathelper.ReplyHTML(ctx, b, msg, v.Reason)
}
lower := strings.ToLower(v.Normalized)
for _, t := range game.Turns {
if strings.ToLower(t.Text) == lower {
- return chathelper.Reply(ctx, b, msg.Chat.ID,
+ return chathelper.Reply(ctx, b, msg,
"๐ You already asked that exact question โ try a new angle.")
}
}
if s.limiter != nil && !s.limiter.Allow(subject) {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "โณ Slow down โ too many turns in a short window.")
+ return chathelper.Reply(ctx, b, msg, "โณ Slow down โ too many turns in a short window.")
}
system := buildSystemPrompt(*game)
resp, err := s.chatter.Generate(ctx, system, v.Normalized)
if err != nil {
if errors.Is(err, ai.ErrRateLimited) {
- return chathelper.Reply(ctx, b, msg.Chat.ID, rateLimited)
+ return chathelper.Reply(ctx, b, msg, rateLimited)
}
log.Warn("twentyq judge failed", "err", err)
- return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
+ return chathelper.Reply(ctx, b, msg, upstreamFail)
}
payload := parseJSON(resp)
if payload == nil {
@@ -180,14 +180,14 @@ func (s *state) submitTurn(ctx context.Context, b *bot.Bot, msg *models.Message,
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
+ return chathelper.ReplyHTML(ctx, b, msg,
formatTurnReply(turn, true, game.Target, count))
}
if err := saveGame(ctx, s.kv, subject, game); err != nil {
return err
}
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
+ return chathelper.ReplyHTML(ctx, b, msg,
formatTurnReply(turn, false, game.Target, len(game.Turns)))
}
@@ -198,7 +198,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
game, err := loadGame(ctx, s.kv, subject)
@@ -206,7 +206,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
return err
}
if game == nil {
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, noRound)
+ return chathelper.ReplyHTML(ctx, b, msg, noRound)
}
if _, err := recordResult(ctx, s.kv, subject, false, len(game.Turns), chathelper.NowMillis()); err != nil {
return err
@@ -214,7 +214,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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatGiveup(*game))
+ return chathelper.ReplyHTML(ctx, b, msg, formatGiveup(*game))
}
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
@@ -224,13 +224,13 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
st, err := loadStats(ctx, s.kv, subject)
if err != nil {
return err
}
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, formatStats(*st))
+ return chathelper.ReplyHTML(ctx, b, msg, formatStats(*st))
}
func truncate(s string, n int) string {
diff --git a/internal/modules/util/chathelper/chathelper.go b/internal/modules/util/chathelper/chathelper.go
index 307ee11..0e587d5 100644
--- a/internal/modules/util/chathelper/chathelper.go
+++ b/internal/modules/util/chathelper/chathelper.go
@@ -54,18 +54,35 @@ func ArgAfterCommand(text string) string {
// 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})
+// Reply sends a plain-text response to the chat the inbound message came from.
+//
+// Forwards MessageThreadID so replies in a forum-supergroup topic stay in the
+// same topic. Telegram routes outgoing messages with an absent/zero
+// message_thread_id to the General topic โ that mis-routing is the precise
+// reason this helper takes the whole message instead of just a chat ID.
+func Reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
+ if msg == nil {
+ return nil
+ }
+ _, err := b.SendMessage(ctx, &bot.SendMessageParams{
+ ChatID: msg.Chat.ID,
+ MessageThreadID: msg.MessageThreadID,
+ 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 {
+// ReplyHTML sends a Telegram HTML-formatted response to the chat the inbound
+// message came from. Forwards MessageThreadID โ see Reply for rationale.
+func ReplyHTML(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
+ if msg == nil {
+ return nil
+ }
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
- ChatID: chatID,
- Text: text,
- ParseMode: models.ParseModeHTML,
+ ChatID: msg.Chat.ID,
+ MessageThreadID: msg.MessageThreadID,
+ Text: text,
+ ParseMode: models.ParseModeHTML,
})
return err
}
diff --git a/internal/modules/util/chathelper/chathelper_test.go b/internal/modules/util/chathelper/chathelper_test.go
index e90e946..9079065 100644
--- a/internal/modules/util/chathelper/chathelper_test.go
+++ b/internal/modules/util/chathelper/chathelper_test.go
@@ -1,9 +1,12 @@
package chathelper
import (
+ "context"
"testing"
"github.com/go-telegram/bot/models"
+
+ "github.com/tiennm99/miti99bot/internal/testutil"
)
func TestSubjectFor(t *testing.T) {
@@ -107,6 +110,101 @@ func TestNowMillis(t *testing.T) {
}
}
+// TestReply_ForwardsMessageThreadID locks in the forum-topic fix: when an
+// inbound command arrives in a forum-supergroup topic, the reply must carry
+// the same message_thread_id so Telegram posts it back to that topic. Without
+// this, Telegram routes the reply to the General topic โ the bug this whole
+// signature change exists to prevent.
+func TestReply_ForwardsMessageThreadID(t *testing.T) {
+ tests := []struct {
+ name string
+ msg *models.Message
+ wantChat string
+ wantThread string // "" means: field must be absent from form
+ }{
+ {
+ name: "forum topic โ thread id forwarded",
+ msg: &models.Message{
+ Chat: models.Chat{ID: -1001234, Type: models.ChatTypeSupergroup, IsForum: true},
+ MessageThreadID: 42,
+ Text: "/cmd",
+ },
+ wantChat: "-1001234",
+ wantThread: "42",
+ },
+ {
+ name: "private chat โ no thread id sent",
+ msg: &models.Message{
+ Chat: models.Chat{ID: 999, Type: models.ChatTypePrivate},
+ Text: "/cmd",
+ },
+ wantChat: "999",
+ wantThread: "",
+ },
+ {
+ name: "regular group (no topics) โ no thread id sent",
+ msg: &models.Message{
+ Chat: models.Chat{ID: -100, Type: models.ChatTypeGroup},
+ Text: "/cmd",
+ },
+ wantChat: "-100",
+ wantThread: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rb := testutil.NewRecordingBot(t)
+ if err := Reply(context.Background(), rb.Bot, tt.msg, "hi"); err != nil {
+ t.Fatalf("Reply: %v", err)
+ }
+ got := rb.LastSent()
+ if got.Method != "sendMessage" {
+ t.Fatalf("method: got %q, want sendMessage", got.Method)
+ }
+ if got.ChatID() != tt.wantChat {
+ t.Errorf("chat_id: got %q, want %q", got.ChatID(), tt.wantChat)
+ }
+ gotThread := got.Form["message_thread_id"]
+ if gotThread != tt.wantThread {
+ t.Errorf("message_thread_id: got %q, want %q", gotThread, tt.wantThread)
+ }
+ })
+ }
+}
+
+// TestReplyHTML_ForwardsMessageThreadID is the HTML-mode counterpart of the
+// plain Reply test; same invariant, same reason.
+func TestReplyHTML_ForwardsMessageThreadID(t *testing.T) {
+ rb := testutil.NewRecordingBot(t)
+ msg := &models.Message{
+ Chat: models.Chat{ID: -1009999, Type: models.ChatTypeSupergroup, IsForum: true},
+ MessageThreadID: 7,
+ }
+ if err := ReplyHTML(context.Background(), rb.Bot, msg, "hi"); err != nil {
+ t.Fatalf("ReplyHTML: %v", err)
+ }
+ got := rb.LastSent()
+ if got.Form["message_thread_id"] != "7" {
+ t.Errorf("message_thread_id: got %q, want %q", got.Form["message_thread_id"], "7")
+ }
+ if got.Form["parse_mode"] != "HTML" {
+ t.Errorf("parse_mode: got %q, want %q", got.Form["parse_mode"], "HTML")
+ }
+}
+
+// TestReply_NilMessage is a defensive check โ handlers occasionally inherit
+// updates without a Message (channel posts routed through future code paths),
+// and Reply must no-op rather than panic.
+func TestReply_NilMessage(t *testing.T) {
+ rb := testutil.NewRecordingBot(t)
+ if err := Reply(context.Background(), rb.Bot, nil, "ignored"); err != nil {
+ t.Fatalf("Reply(nil): %v", err)
+ }
+ if n := len(rb.Sent()); n != 0 {
+ t.Errorf("Reply(nil) sent %d calls; want 0", n)
+ }
+}
+
func TestWinRate(t *testing.T) {
tests := []struct {
wins, played, want int
diff --git a/internal/modules/util/help.go b/internal/modules/util/help.go
index 717e8d0..cc58d41 100644
--- a/internal/modules/util/help.go
+++ b/internal/modules/util/help.go
@@ -101,6 +101,7 @@ func helpCommand(reg *modules.Registry) modules.Command {
text := RenderHelp(reg)
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: update.Message.Chat.ID,
+ MessageThreadID: update.Message.MessageThreadID,
Text: text,
ParseMode: models.ParseModeHTML,
LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: bot.True()},
diff --git a/internal/modules/util/info.go b/internal/modules/util/info.go
index 907e800..aa41382 100644
--- a/internal/modules/util/info.go
+++ b/internal/modules/util/info.go
@@ -37,7 +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)
- return chathelper.Reply(ctx, b, msg.Chat.ID, text)
+ return chathelper.Reply(ctx, b, msg, text)
},
}
}
diff --git a/internal/modules/util/stickerid.go b/internal/modules/util/stickerid.go
index 136efe0..c60b0ae 100644
--- a/internal/modules/util/stickerid.go
+++ b/internal/modules/util/stickerid.go
@@ -32,7 +32,7 @@ func stickerIDCommand() modules.Command {
sticker := stickerFrom(msg)
if sticker == nil {
- return chathelper.Reply(ctx, b, msg.Chat.ID, stickerIDUsage)
+ return chathelper.Reply(ctx, b, msg, stickerIDUsage)
}
setName := sticker.SetName
@@ -52,7 +52,7 @@ func stickerIDCommand() modules.Command {
fmt.Fprintf(&sb, "set: %s ยท emoji: %s",
html.EscapeString(setName), html.EscapeString(emoji))
- return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, sb.String())
+ return chathelper.ReplyHTML(ctx, b, msg, sb.String())
},
}
}
diff --git a/internal/modules/wordle/handlers.go b/internal/modules/wordle/handlers.go
index 455b44e..ffbe27d 100644
--- a/internal/modules/wordle/handlers.go
+++ b/internal/modules/wordle/handlers.go
@@ -74,7 +74,7 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
arg := chathelper.ArgAfterCommand(msg.Text)
@@ -94,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 chathelper.Reply(ctx, b, msg.Chat.ID, header+"\n\n"+renderBoard(g.Guesses))
+ return chathelper.Reply(ctx, b, msg, header+"\n\n"+renderBoard(g.Guesses))
}
if isFinished(g) {
- return chathelper.Reply(ctx, b, msg.Chat.ID,
+ return chathelper.Reply(ctx, b, msg,
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 chathelper.Reply(ctx, b, msg.Chat.ID, rejectMessage(v.Reason))
+ return chathelper.Reply(ctx, b, msg, rejectMessage(v.Reason))
}
results := CompareWords(v.Word, g.Target)
@@ -124,16 +124,16 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd
if err != nil {
return err
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\n๐ Solved in %d/%d! Streak: %d. /wordle_new for another.",
+ return chathelper.Reply(ctx, b, msg, 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, chathelper.NowMillis()); err != nil {
return err
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\nโ Out of guesses. Answer was %s. /wordle_new to retry.",
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s\n\nโ Out of guesses. Answer was %s. /wordle_new to retry.",
rendered, strings.ToUpper(g.Target)))
default:
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses))
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses))
}
}
@@ -146,7 +146,7 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
@@ -166,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 chathelper.Reply(ctx, b, msg.Chat.ID, prelude+"๐ New round started. Use `/wordle ` to guess.")
+ return chathelper.Reply(ctx, b, msg, prelude+"๐ New round started. Use `/wordle ` to guess.")
}
// handleGiveup is /wordle_giveup โ reveals answer for the current round.
@@ -178,7 +178,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
g, err := s.getOrInit(ctx, subject)
@@ -186,10 +186,10 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
return err
}
if g.Solved {
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Already solved โ %s.", strings.ToUpper(g.Target)))
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already solved โ %s.", strings.ToUpper(g.Target)))
}
if g.Giveup {
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Already gave up โ %s.", strings.ToUpper(g.Target)))
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already gave up โ %s.", strings.ToUpper(g.Target)))
}
g.Giveup = true
if err := saveGame(ctx, s.kv, subject, g); err != nil {
@@ -198,7 +198,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil {
return err
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("๐ณ๏ธ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target)))
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf("๐ณ๏ธ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target)))
}
// handleStats is /wordle_stats โ shows lifetime score for the subject.
@@ -209,7 +209,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
- return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
+ return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
}
stats, err := loadStats(ctx, s.kv, subject)
if err != nil {
@@ -219,7 +219,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
if msg.Chat.Type == models.ChatTypePrivate {
scope = "your"
}
- return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
+ return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
"๐ Wordle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
scope, stats.Played, stats.Wins, chathelper.WinRate(stats.Wins, stats.Played), stats.Streak, stats.BestStreak,
))