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:
tiennm99 committed 2026-05-16 12:10:56 +07:00
1 parent 82825661e3
commit 16c9cce91d
11 files changed
+247 -122

No files matched your search

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