From 64dac77c2e476ebcdeec84458db9e736b5896dd4 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 16 May 2026 12:10:56 +0700 Subject: [PATCH] fix(reply): forward message_thread_id so replies stay in the same forum topic Telegram routes outgoing messages with no message_thread_id to a forum supergroup's General topic. Commands sent in a topic were being answered in General. chathelper.Reply / ReplyHTML now take *models.Message and forward both ChatID and MessageThreadID; help.go SendMessage and loldle trySendSticker do the same. Adds regression tests asserting the field is forwarded for forum topics and omitted for private/regular groups. --- internal/modules/loldle/handlers.go | 51 +++++----- internal/modules/lolschedule/handlers.go | 22 ++--- internal/modules/misc/misc.go | 6 +- internal/modules/trading/handlers.go | 82 ++++++++-------- internal/modules/twentyq/handlers.go | 40 ++++---- .../modules/util/chathelper/chathelper.go | 33 +++++-- .../util/chathelper/chathelper_test.go | 98 +++++++++++++++++++ internal/modules/util/help.go | 1 + internal/modules/util/info.go | 2 +- internal/modules/util/stickerid.go | 4 +- internal/modules/wordle/handlers.go | 30 +++--- 11 files changed, 247 insertions(+), 122 deletions(-) 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, ))