Route MAX replies to source TG thread + crosspost/UX fixes

- Issue #38 part 1: MAX reply to a bridged TG message now lands in the
  same TG forum thread as the original, not in the pair's default
  thread. messages table gets tg_thread_id column (migration 000014);
  SaveMsg stores the TG thread, LookupTgMsgID returns it, forwardMaxToTg
  applies source thread for reply-routing (both body.ReplyTo and
  Link.Type=reply paths).
- Fix crosspost attribution heuristic: forwardMaxToTg used
  "caption != text" to detect bridge mode, which broke when MaxToTg
  replacements or whitespace made caption differ from raw body.Text —
  MAX→TG crossposts then got [MAX] prefix and bold name. Now explicit
  isCrosspost flag.
- /bridge without key in an already-linked chat no longer generates a
  fresh key; instead shows "already linked" hint with pairing guidance
  (both TG and MAX sides).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-23 15:26:44 +04:00
parent 38e5777798
commit 46d2f4f748
12 changed files with 91 additions and 38 deletions

1
.gitignore vendored
View file

@ -21,3 +21,4 @@ bridge.db.bak
.omc/
broadcast.sh
broadcast_*.html
.broadcast_sent/

52
max.go
View file

@ -51,7 +51,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
// Обработка удаления (только bridge, не crosspost)
if delUpd, isDel := upd.(*maxschemes.MessageRemovedUpdate); isDel {
tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(delUpd.MessageId)
tgChatID, tgMsgID, _, ok := b.repo.LookupTgMsgID(delUpd.MessageId)
if !ok {
continue
}
@ -75,7 +75,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
continue
}
mid := editUpd.Message.Body.Mid
tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(mid)
tgChatID, tgMsgID, _, ok := b.repo.LookupTgMsgID(mid)
if !ok {
continue
}
@ -295,6 +295,27 @@ func (b *Bridge) listenMax(ctx context.Context) {
continue
}
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
// /bridge без ключа — если чат уже связан, не создавать новый ключ
if key == "" {
if tgID, linked := b.repo.GetTgChat(chatID); linked {
var reply string
if isGroup {
reply = fmt.Sprintf("Эта группа уже связана с Telegram (ID %d).\n\n/unbridge — удалить связку.", tgID)
} else {
reply = fmt.Sprintf("Этот личный чат уже связан с Telegram (ID %d).\n\nЧтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\n/unbridge — удалить связку этого личного чата.", tgID)
}
m := maxbot.NewMessage().SetChat(chatID).SetText(reply)
b.maxApi.Messages.Send(ctx, m)
continue
}
if !isGroup {
m := maxbot.NewMessage().SetChat(chatID).SetText(
"Чтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\nЕсли хотите связать этот личный чат с Telegram-пользователем — введите ключ от него: /bridge <ключ>.")
b.maxApi.Messages.Send(ctx, m)
continue
}
}
paired, generatedKey, err := b.repo.Register(key, "max", chatID)
if err != nil {
slog.Error("register failed", "err", err)
@ -492,7 +513,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") {
prefix := b.repo.HasPrefix("max", chatID)
caption := formatMaxCaption(msgUpd, prefix, b.cfg.MessageNewline)
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, false)
}
continue
}
@ -522,7 +543,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
caption = applyReplacements(caption, repl.MaxToTg)
}
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, true)
}
}
}
@ -903,7 +924,8 @@ func maxCrosspostStatusText(tgChatID int64, direction string) string {
}
// forwardMaxToTg пересылает MAX-сообщение (текст/медиа) в TG-чат.
func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string) {
// Если isCrosspost=true, caption используется как финальный текст (с заменами, без атрибуции).
func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string, isCrosspost bool) {
if b.cbBlocked(tgChatID) {
return
}
@ -914,17 +936,21 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
chatID := msgUpd.Message.Recipient.ChatId
text := strings.TrimSpace(body.Text)
// Reply ID
// Reply ID + маршрутизация в тред исходного TG-сообщения.
// Для reply-ответов всегда используем тред исходника (в т.ч. 0 = General),
// а не пара-дефолтный тред — иначе ответ уходит не туда, куда смотрит юзер.
var replyToID int
if body.ReplyTo != "" {
if _, rid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
if _, rid, tid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
replyToID = rid
threadID = tid
}
} else if msgUpd.Message.Link != nil {
} else if msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.REPLY {
mid := msgUpd.Message.Link.Message.Mid
if mid != "" {
if _, rid, ok := b.repo.LookupTgMsgID(mid); ok {
if _, rid, tid, ok := b.repo.LookupTgMsgID(mid); ok {
replyToID = rid
threadID = tid
}
}
}
@ -938,7 +964,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
// Определяем HTML caption: всегда для bridge-режима (жирное имя) и при наличии markups
htmlCaption := caption
hasMarkups := len(body.Markups) > 0
hasAttribution := caption != text // bridge-режим (не кросспостинг)
hasAttribution := !isCrosspost // bridge-режим (не кросспостинг)
useHTML := hasMarkups || hasAttribution
if useHTML {
var htmlText string
@ -1135,7 +1161,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
slog.Error("MigrateTgChat failed", "err", err)
} else {
// Повторяем отправку с новым ID
go b.forwardMaxToTg(ctx, msgUpd, newChatID, caption)
go b.forwardMaxToTg(ctx, msgUpd, newChatID, caption, isCrosspost)
}
return
}
@ -1161,7 +1187,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
strings.Contains(errStr, "topics are disabled")) {
slog.Info("TG forum topics disabled, resetting thread_id", "tgChat", tgChatID, "oldThread", threadID)
b.repo.SetTgThreadID(tgChatID, 0)
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, isCrosspost)
return
}
@ -1183,6 +1209,6 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
} else {
b.cbSuccess(tgChatID)
slog.Info("MAX→TG sent", "msgID", sentMsgID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid)
b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid, threadID)
}
}

View file

@ -209,7 +209,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
}
b.cbSuccess(maxChatID)
slog.Info("TG→MAX media group sent", "mid", result.Body.Mid, "photos", photosSent)
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, result.Body.Mid)
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, result.Body.Mid, items[0].msg.MessageThreadID)
}
// Видео отправляем отдельно через direct API (SDK не поддерживает AddVideo)
@ -224,7 +224,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
continue
}
if i == 0 && photosSent == 0 {
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, mid)
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, mid, items[0].msg.MessageThreadID)
}
}
}

View file

@ -0,0 +1 @@
ALTER TABLE messages DROP COLUMN tg_thread_id;

View file

@ -0,0 +1 @@
ALTER TABLE messages ADD COLUMN tg_thread_id BIGINT NOT NULL DEFAULT 0;

View file

@ -0,0 +1 @@
ALTER TABLE messages DROP COLUMN tg_thread_id;

View file

@ -0,0 +1 @@
ALTER TABLE messages ADD COLUMN tg_thread_id INTEGER NOT NULL DEFAULT 0;

View file

@ -89,13 +89,13 @@ func (r *pgRepo) GetTgChat(maxChatID int64) (int64, bool) {
return id, err == nil
}
func (r *pgRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) {
func (r *pgRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string, tgThreadID int) {
r.db.Exec(
`INSERT INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, created_at)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, tg_thread_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tg_chat_id, tg_msg_id) DO UPDATE
SET max_chat_id = EXCLUDED.max_chat_id, max_msg_id = EXCLUDED.max_msg_id, created_at = EXCLUDED.created_at`,
tgChatID, tgMsgID, maxChatID, maxMsgID, time.Now().Unix())
SET max_chat_id = EXCLUDED.max_chat_id, max_msg_id = EXCLUDED.max_msg_id, tg_thread_id = EXCLUDED.tg_thread_id, created_at = EXCLUDED.created_at`,
tgChatID, tgMsgID, maxChatID, maxMsgID, tgThreadID, time.Now().Unix())
}
func (r *pgRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) {
@ -104,11 +104,11 @@ func (r *pgRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) {
return id, err == nil
}
func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, int, bool) {
var chatID int64
var msgID int
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id FROM messages WHERE max_msg_id = $1", maxMsgID).Scan(&chatID, &msgID)
return chatID, msgID, err == nil
var msgID, threadID int
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id, COALESCE(tg_thread_id, 0) FROM messages WHERE max_msg_id = $1", maxMsgID).Scan(&chatID, &msgID, &threadID)
return chatID, msgID, threadID, err == nil
}
func (r *pgRepo) CleanOldMessages() {

View file

@ -127,7 +127,9 @@ func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now tim
slog.Info("queue retry ok", "id", item.ID, "dir", "tg2max", "mid", mid)
tgMsgID, _ := strconv.Atoi(item.SrcMsgID)
if tgMsgID > 0 {
b.repo.SaveMsg(item.SrcChatID, tgMsgID, item.DstChatID, mid)
// Тред исходного TG-сообщения в очереди не сохраняется — реплаи
// на такие сообщения из MAX будут уходить в тред по умолчанию.
b.repo.SaveMsg(item.SrcChatID, tgMsgID, item.DstChatID, mid, 0)
}
b.repo.DeleteFromQueue(item.ID)
}
@ -177,6 +179,6 @@ func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now tim
return
}
slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sentMsgID)
b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID)
b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID, threadID)
b.repo.DeleteFromQueue(item.ID)
}

View file

@ -33,9 +33,9 @@ type Repository interface {
GetTgChat(maxChatID int64) (int64, bool)
MigrateTgChat(oldID, newID int64) error
SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string)
SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string, tgThreadID int)
LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool)
LookupTgMsgID(maxMsgID string) (int64, int, bool)
LookupTgMsgID(maxMsgID string) (tgChatID int64, tgMsgID int, tgThreadID int, ok bool)
CleanOldMessages()
HasPrefix(platform string, chatID int64) bool

View file

@ -86,9 +86,9 @@ func (r *sqliteRepo) GetTgChat(maxChatID int64) (int64, bool) {
return id, err == nil
}
func (r *sqliteRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) {
r.db.Exec("INSERT OR REPLACE INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, created_at) VALUES (?, ?, ?, ?, ?)",
tgChatID, tgMsgID, maxChatID, maxMsgID, time.Now().Unix())
func (r *sqliteRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string, tgThreadID int) {
r.db.Exec("INSERT OR REPLACE INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, tg_thread_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
tgChatID, tgMsgID, maxChatID, maxMsgID, tgThreadID, time.Now().Unix())
}
func (r *sqliteRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) {
@ -97,11 +97,11 @@ func (r *sqliteRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool)
return id, err == nil
}
func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, int, bool) {
var chatID int64
var msgID int
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id FROM messages WHERE max_msg_id = ?", maxMsgID).Scan(&chatID, &msgID)
return chatID, msgID, err == nil
var msgID, threadID int
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id, COALESCE(tg_thread_id, 0) FROM messages WHERE max_msg_id = ?", maxMsgID).Scan(&chatID, &msgID, &threadID)
return chatID, msgID, threadID, err == nil
}
func (r *sqliteRepo) CleanOldMessages() {

View file

@ -373,6 +373,26 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
continue
}
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
// /bridge без ключа — если чат уже связан, не создавать новый ключ
if key == "" {
if maxID, linked := b.repo.GetMaxChat(msg.Chat.ID); linked {
var txt string
if isGroup {
txt = fmt.Sprintf("Эта группа уже связана с MAX (ID <code>%d</code>).\n\n/unbridge — удалить связку.", maxID)
} else {
txt = fmt.Sprintf("Этот личный чат уже связан с MAX (ID <code>%d</code>).\n\nЧтобы связать <b>группу</b> — добавьте бота в неё и отправьте <code>/bridge</code> <b>внутри группы</b>, не здесь.\n\n/unbridge — удалить связку этого личного чата.", maxID)
}
b.tg.SendMessage(ctx, msg.Chat.ID, txt, &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
if !isGroup {
b.tg.SendMessage(ctx, msg.Chat.ID,
"Чтобы связать группу — добавьте бота в неё и отправьте <code>/bridge</code> <b>внутри группы</b>, не здесь.\n\nЕсли хотите связать этот личный чат с MAX-пользователем — введите ключ от него: <code>/bridge &lt;ключ&gt;</code>.",
&SendOpts{ParseMode: "HTML"})
continue
}
}
paired, generatedKey, err := b.repo.Register(key, "tg", msg.Chat.ID)
if err != nil {
slog.Error("register failed", "err", err)
@ -550,7 +570,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
} else {
b.cbSuccess(maxChatID)
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
}
return
} else if msg.Animation != nil {
@ -602,7 +622,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil)
} else {
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
}
return
} else {
@ -827,7 +847,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
} else {
b.cbSuccess(maxChatID)
slog.Info("TG→MAX sent", "mid", mid, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid)
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid, msg.MessageThreadID)
}
}