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.
This commit is contained in:
2026-05-16 12:10:56 +07:00
parent 53e2b22f12
commit 64dac77c2e
11 changed files with 247 additions and 122 deletions
+28 -23
View File
@@ -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 <code>/loldle &lt;champion&gt;</code>.",
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(
"🔁 <b>%s</b> 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))
}
+11 -11
View File
@@ -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.")
}
+3 -3
View File
@@ -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.")
},
}
}
+43 -39
View File
@@ -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 <amount>\nExample: /trade_topup 5000000")
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_topup <amount>\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 <qty> <TICKER>\nExample: /trade_buy 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_buy <qty> <TICKER>\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 <qty> <TICKER>\nExample: /trade_sell 100 TCB")
return chathelper.Reply(ctx, b, update.Message, "Usage: /trade_sell <qty> <TICKER>\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"))
}
+20 -20
View File
@@ -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 {
+25 -8
View File
@@ -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
}
@@ -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, "<b>hi</b>"); 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
+1
View File
@@ -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()},
+1 -1
View File
@@ -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)
},
}
}
+2 -2
View File
@@ -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())
},
}
}
+15 -15
View File
@@ -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 <word>`.", 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 <word>` to guess.")
return chathelper.Reply(ctx, b, msg, prelude+"🆕 New round started. Use `/wordle <word>` 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,
))