mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-20 12:24:18 +00:00
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:
@@ -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 <champion></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))
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()},
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user