Add /thread_bridge: link a single TG forum thread to a separate MAX chat

Issue #38 part 2. Allows using multiple MAX group chats as per-thread
mirrors of a TG forum group, since MAX has no native thread concept.

- New table thread_pairs (tg_chat_id, tg_thread_id, max_chat_id) with
  unique(max_chat_id) — one MAX chat = at most one TG thread.
- pending gains thread_id column for thread-bridge key exchange.
- Repo methods: StartThreadBridge (TG issues key), CompleteThreadBridge
  (MAX consumes key), GetThreadMaxChat, GetThreadTgPair, UnpairThread,
  UnpairThreadByMax.
- Commands:
  * /thread_bridge in a TG forum thread (admin, non-General) -> key
  * /thread_bridge <key> in a MAX chat -> binds
  * /thread_unbridge on either side
  TG's BOT_COMMAND_INVALID rule forbids hyphens, so the commands use
  underscore.
- Routing priority: thread-bridge > regular pair.
  TG->MAX: if msg.MessageThreadID has a thread_pair, route there.
  MAX->TG: if MAX chat is in thread_pairs, route to (tg_chat, thread).
- For thread-paired MAX chats, the Part 1 reply-to-source-thread
  override is disabled: thread-paired chats have a fixed target thread,
  replies stay there.
- Safeguard: a MAX chat cannot be in both pairs and thread_pairs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-23 17:39:00 +04:00
parent 46d2f4f748
commit 1290a23ad7
10 changed files with 416 additions and 13 deletions

View file

@ -217,6 +217,8 @@ func (b *Bridge) registerCommands(ctx context.Context) {
{Command: "bridge", Description: "Связать чат с MAX-чатом"},
{Command: "unbridge", Description: "Удалить связку чатов"},
{Command: "thread", Description: "Установить топик для сообщений из MAX"},
{Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"},
{Command: "thread_unbridge", Description: "Удалить связку треда"},
{Command: "crosspost", Description: "Список связок кросспостинга"},
{Command: "help", Description: "Инструкция"},
}

100
max.go
View file

@ -220,7 +220,9 @@ func (b *Bridge) listenMax(ctx context.Context) {
"/bridge — создать ключ для связки чатов\n" +
"/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" +
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" +
"/unbridge — удалить связку\n\n" +
"/unbridge — удалить связку\n" +
"/thread_bridge <ключ> — связать этот MAX-чат с отдельным TG-тредом (форум)\n" +
"/thread_unbridge — разорвать связку треда\n\n" +
"Кросспостинг каналов (в личке бота):\n" +
"/crosspost <TG_ID> — связать MAX-канал с TG-каналом\n" +
" (TG ID получить: перешлите пост из TG-канала TG-боту)\n\n" +
@ -354,6 +356,67 @@ func (b *Bridge) listenMax(ctx context.Context) {
continue
}
// /thread_bridge <key> — принять ключ, связать MAX-чат с конкретным TG-тредом
if strings.HasPrefix(text, "/thread_bridge") {
if isGroup && !isAdmin {
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
b.maxApi.Messages.Send(ctx, m)
continue
}
key := strings.TrimSpace(strings.TrimPrefix(text, "/thread_bridge"))
if key == "" {
if tgID, tid, ok := b.repo.GetThreadTgPair(chatID); ok {
m := maxbot.NewMessage().SetChat(chatID).SetText(
fmt.Sprintf("Этот чат уже связан с TG-тредом (чат %d, thread %d).\n\n/thread_unbridge — разорвать.", tgID, tid))
b.maxApi.Messages.Send(ctx, m)
continue
}
m := maxbot.NewMessage().SetChat(chatID).SetText(
"Нужен ключ: /thread_bridge <ключ>\n\nСначала в Telegram-форум-группе выполните /thread_bridge внутри нужного треда — там выдадут ключ.")
b.maxApi.Messages.Send(ctx, m)
continue
}
tgChatID, threadID, ok, err := b.repo.CompleteThreadBridge(key, chatID)
if err == errThreadMaxBusy {
m := maxbot.NewMessage().SetChat(chatID).SetText(
"Этот MAX-чат уже участвует в другой связке (bridge или thread-bridge). Сначала /unbridge или /thread_unbridge.")
b.maxApi.Messages.Send(ctx, m)
continue
}
if err != nil {
slog.Error("CompleteThreadBridge failed", "err", err)
m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка сохранения связки.")
b.maxApi.Messages.Send(ctx, m)
continue
}
if !ok {
m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или истёк.")
b.maxApi.Messages.Send(ctx, m)
continue
}
m := maxbot.NewMessage().SetChat(chatID).SetText(
fmt.Sprintf("Связано с TG-тредом (чат %d, thread %d). Сообщения из этого MAX-чата будут уходить в указанный тред, и обратно.", tgChatID, threadID))
b.maxApi.Messages.Send(ctx, m)
slog.Info("thread-bridge paired", "tgChat", tgChatID, "thread", threadID, "maxChat", chatID)
continue
}
if text == "/thread_unbridge" {
if isGroup && !isAdmin {
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
b.maxApi.Messages.Send(ctx, m)
continue
}
if b.repo.UnpairThreadByMax(chatID) {
m := maxbot.NewMessage().SetChat(chatID).SetText("Связка треда удалена.")
b.maxApi.Messages.Send(ctx, m)
} else {
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан с тредом.")
b.maxApi.Messages.Send(ctx, m)
}
continue
}
// Обработка ввода замены (если юзер в режиме ожидания)
if isDialog && !strings.HasPrefix(text, "/") && msgUpd.Message.Sender.UserId != 0 {
if w, ok := b.getReplWait(msgUpd.Message.Sender.UserId); ok {
@ -506,8 +569,15 @@ func (b *Bridge) listenMax(ctx context.Context) {
continue
}
// Пересылка (bridge)
// Пересылка (bridge). Сначала проверяем thread-bridge (MAX-чат = отдельный TG-тред),
// потом обычную пару.
tgChatID, linked := b.repo.GetTgChat(chatID)
if !linked {
if tg, _, ok := b.repo.GetThreadTgPair(chatID); ok {
tgChatID = tg
linked = true
}
}
if linked && msgUpd.Message.Sender.UserId != b.maxBotUID {
// Anti-loop
if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") {
@ -930,27 +1000,39 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
return
}
threadID := b.repo.GetTgThreadID(tgChatID)
body := msgUpd.Message.Body
chatID := msgUpd.Message.Recipient.ChatId
text := strings.TrimSpace(body.Text)
// Reply ID + маршрутизация в тред исходного TG-сообщения.
// Для reply-ответов всегда используем тред исходника (в т.ч. 0 = General),
// а не пара-дефолтный тред — иначе ответ уходит не туда, куда смотрит юзер.
// Определяем тред-назначение:
// — thread-pair: MAX-чат жёстко привязан к одному TG-треду, все сообщения идут туда,
// reply-override не меняет тред.
// — обычная пара: дефолтный тред пары + для reply переопределяем на тред исходника
// (чтобы ответ попадал в ту же ветку, где лежит исходное сообщение).
var threadID int
_, threadPairThread, isThreadPair := b.repo.GetThreadTgPair(chatID)
if isThreadPair {
threadID = threadPairThread
} else {
threadID = b.repo.GetTgThreadID(tgChatID)
}
var replyToID int
if body.ReplyTo != "" {
if _, rid, tid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
replyToID = rid
threadID = tid
if !isThreadPair {
threadID = tid
}
}
} else if msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.REPLY {
mid := msgUpd.Message.Link.Message.Mid
if mid != "" {
if _, rid, tid, ok := b.repo.LookupTgMsgID(mid); ok {
replyToID = rid
threadID = tid
if !isThreadPair {
threadID = tid
}
}
}
}

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_thread_pairs_max;
DROP INDEX IF EXISTS idx_thread_pairs_tg;
DROP TABLE IF EXISTS thread_pairs;
ALTER TABLE pending DROP COLUMN thread_id;

View file

@ -0,0 +1,12 @@
ALTER TABLE pending ADD COLUMN thread_id BIGINT NOT NULL DEFAULT 0;
CREATE TABLE thread_pairs (
tg_chat_id BIGINT NOT NULL,
tg_thread_id BIGINT NOT NULL,
max_chat_id BIGINT NOT NULL,
created_at BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (tg_chat_id, tg_thread_id)
);
CREATE UNIQUE INDEX idx_thread_pairs_max ON thread_pairs(max_chat_id);
CREATE INDEX idx_thread_pairs_tg ON thread_pairs(tg_chat_id);

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_thread_pairs_max;
DROP INDEX IF EXISTS idx_thread_pairs_tg;
DROP TABLE IF EXISTS thread_pairs;
ALTER TABLE pending DROP COLUMN thread_id;

View file

@ -0,0 +1,12 @@
ALTER TABLE pending ADD COLUMN thread_id INTEGER NOT NULL DEFAULT 0;
CREATE TABLE thread_pairs (
tg_chat_id INTEGER NOT NULL,
tg_thread_id INTEGER NOT NULL,
max_chat_id INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (tg_chat_id, tg_thread_id)
);
CREATE UNIQUE INDEX idx_thread_pairs_max ON thread_pairs(max_chat_id);
CREATE INDEX idx_thread_pairs_tg ON thread_pairs(tg_chat_id);

View file

@ -177,6 +177,104 @@ func (r *pgRepo) SetTgThreadID(tgChatID int64, threadID int) error {
return err
}
func (r *pgRepo) StartThreadBridge(tgChatID int64, threadID int) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
var existing string
err := r.db.QueryRow(
"SELECT key FROM pending WHERE platform = 'tg' AND chat_id = $1 AND thread_id = $2 AND command = 'thread-bridge'",
tgChatID, threadID).Scan(&existing)
if err == nil {
return existing, nil
}
generated := genKey()
_, err = r.db.Exec(
"INSERT INTO pending (key, platform, chat_id, thread_id, created_at, command) VALUES ($1, 'tg', $2, $3, $4, 'thread-bridge')",
generated, tgChatID, threadID, time.Now().Unix())
return generated, err
}
func (r *pgRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int, bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
var tgChatID int64
var threadID int
err := r.db.QueryRow(
"SELECT chat_id, thread_id FROM pending WHERE key = $1 AND platform = 'tg' AND command = 'thread-bridge'",
key).Scan(&tgChatID, &threadID)
if err != nil {
return 0, 0, false, nil
}
var cnt int
r.db.QueryRow("SELECT COUNT(*) FROM pairs WHERE max_chat_id = $1", maxChatID).Scan(&cnt)
if cnt > 0 {
return 0, 0, false, errThreadMaxBusy
}
cnt = 0
r.db.QueryRow("SELECT COUNT(*) FROM thread_pairs WHERE max_chat_id = $1", maxChatID).Scan(&cnt)
if cnt > 0 {
return 0, 0, false, errThreadMaxBusy
}
r.db.Exec("DELETE FROM pending WHERE key = $1", key)
_, err = r.db.Exec(
`INSERT INTO thread_pairs (tg_chat_id, tg_thread_id, max_chat_id, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tg_chat_id, tg_thread_id) DO UPDATE
SET max_chat_id = EXCLUDED.max_chat_id, created_at = EXCLUDED.created_at`,
tgChatID, threadID, maxChatID, time.Now().Unix())
if err != nil {
return 0, 0, false, err
}
return tgChatID, threadID, true, nil
}
func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
if threadID == 0 {
return 0, false
}
var id int64
err := r.db.QueryRow(
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2",
tgChatID, threadID).Scan(&id)
return id, err == nil
}
func (r *pgRepo) GetThreadTgPair(maxChatID int64) (int64, int, bool) {
var tgChatID int64
var threadID int
err := r.db.QueryRow(
"SELECT tg_chat_id, tg_thread_id FROM thread_pairs WHERE max_chat_id = $1",
maxChatID).Scan(&tgChatID, &threadID)
return tgChatID, threadID, err == nil
}
func (r *pgRepo) UnpairThread(tgChatID int64, threadID int) bool {
r.mu.Lock()
defer r.mu.Unlock()
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2", tgChatID, threadID)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
func (r *pgRepo) UnpairThreadByMax(maxChatID int64) bool {
r.mu.Lock()
defer r.mu.Unlock()
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE max_chat_id = $1", maxChatID)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
func (r *pgRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error {
_, err := r.db.Exec(
"INSERT INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tg_chat_id, max_chat_id) DO NOTHING",

View file

@ -1,5 +1,10 @@
package main
import "errors"
// errThreadMaxBusy — попытка thread-bridge на MAX-чат, уже участвующий в какой-то связке.
var errThreadMaxBusy = errors.New("max chat is already linked (bridge or thread-bridge)")
// Replacement — одно правило замены текста.
// Target: "" или "all" — весь текст, "links" — только ссылки.
type Replacement struct {
@ -46,6 +51,15 @@ type Repository interface {
GetTgThreadID(tgChatID int64) int
SetTgThreadID(tgChatID int64, threadID int) error
// Thread-bridge: связка отдельного TG-треда с отдельным MAX-чатом.
// Ключ выдаётся в TG-треде (StartThreadBridge), принимается в MAX (CompleteThreadBridge).
StartThreadBridge(tgChatID int64, threadID int) (key string, err error)
CompleteThreadBridge(key string, maxChatID int64) (tgChatID int64, threadID int, ok bool, err error)
GetThreadMaxChat(tgChatID int64, threadID int) (maxChatID int64, ok bool)
GetThreadTgPair(maxChatID int64) (tgChatID int64, threadID int, ok bool)
UnpairThread(tgChatID int64, threadID int) bool
UnpairThreadByMax(maxChatID int64) bool
// Crosspost methods
PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error
GetCrosspostOwner(maxChatID int64) (maxOwner, tgOwner int64)

View file

@ -170,6 +170,103 @@ func (r *sqliteRepo) SetTgThreadID(tgChatID int64, threadID int) error {
return err
}
func (r *sqliteRepo) StartThreadBridge(tgChatID int64, threadID int) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
// Уже есть ожидающий ключ для этого треда — возвращаем его.
var existing string
err := r.db.QueryRow(
"SELECT key FROM pending WHERE platform = 'tg' AND chat_id = ? AND thread_id = ? AND command = 'thread-bridge'",
tgChatID, threadID).Scan(&existing)
if err == nil {
return existing, nil
}
generated := genKey()
_, err = r.db.Exec(
"INSERT INTO pending (key, platform, chat_id, thread_id, created_at, command) VALUES (?, 'tg', ?, ?, ?, 'thread-bridge')",
generated, tgChatID, threadID, time.Now().Unix())
return generated, err
}
func (r *sqliteRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int, bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
var tgChatID int64
var threadID int
err := r.db.QueryRow(
"SELECT chat_id, thread_id FROM pending WHERE key = ? AND platform = 'tg' AND command = 'thread-bridge'",
key).Scan(&tgChatID, &threadID)
if err != nil {
return 0, 0, false, nil
}
// MAX-чат не должен быть в обычной паре или в другом thread-bridge.
var cnt int
r.db.QueryRow("SELECT COUNT(*) FROM pairs WHERE max_chat_id = ?", maxChatID).Scan(&cnt)
if cnt > 0 {
return 0, 0, false, errThreadMaxBusy
}
cnt = 0
r.db.QueryRow("SELECT COUNT(*) FROM thread_pairs WHERE max_chat_id = ?", maxChatID).Scan(&cnt)
if cnt > 0 {
return 0, 0, false, errThreadMaxBusy
}
r.db.Exec("DELETE FROM pending WHERE key = ?", key)
_, err = r.db.Exec(
"INSERT OR REPLACE INTO thread_pairs (tg_chat_id, tg_thread_id, max_chat_id, created_at) VALUES (?, ?, ?, ?)",
tgChatID, threadID, maxChatID, time.Now().Unix())
if err != nil {
return 0, 0, false, err
}
return tgChatID, threadID, true, nil
}
func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
if threadID == 0 {
return 0, false
}
var id int64
err := r.db.QueryRow(
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?",
tgChatID, threadID).Scan(&id)
return id, err == nil
}
func (r *sqliteRepo) GetThreadTgPair(maxChatID int64) (int64, int, bool) {
var tgChatID int64
var threadID int
err := r.db.QueryRow(
"SELECT tg_chat_id, tg_thread_id FROM thread_pairs WHERE max_chat_id = ?",
maxChatID).Scan(&tgChatID, &threadID)
return tgChatID, threadID, err == nil
}
func (r *sqliteRepo) UnpairThread(tgChatID int64, threadID int) bool {
r.mu.Lock()
defer r.mu.Unlock()
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?", tgChatID, threadID)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
func (r *sqliteRepo) UnpairThreadByMax(maxChatID int64) bool {
r.mu.Lock()
defer r.mu.Unlock()
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE max_chat_id = ?", maxChatID)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n > 0
}
func (r *sqliteRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error {
_, err := r.db.Exec("INSERT OR REPLACE INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES (?, ?, ?, ?, ?)",
tgChatID, maxChatID, time.Now().Unix(), ownerID, tgOwnerID)

View file

@ -58,7 +58,14 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
if b.isSelfTgBot(edited.From) {
continue
}
maxChatID, linked := b.repo.GetMaxChat(edited.Chat.ID)
var maxChatID int64
var linked bool
if edited.MessageThreadID != 0 {
maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID)
}
if !linked {
maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID)
}
if !linked {
continue
}
@ -172,7 +179,9 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
"/bridge <ключ> — связать этот чат с MAX-чатом по ключу\n"+
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n"+
"/unbridge — удалить связку\n"+
"/thread — направить сообщения из MAX в текущий топик (форум)\n\n"+
"/thread — направить сообщения из MAX в текущий топик (форум)\n"+
"/thread_bridge — связать текущий тред с отдельной MAX-группой (форум)\n"+
"/thread_unbridge — разорвать связку треда\n\n"+
"Кросспостинг каналов:\n"+
"1. Добавьте бота админом в оба канала (с правом постинга)\n"+
"2. Перешлите пост из TG-канала в личку TG-бота\n"+
@ -430,8 +439,77 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
continue
}
// Пересылка
maxChatID, linked := b.repo.GetMaxChat(msg.Chat.ID)
// /thread_bridge — связать конкретный тред с отдельным MAX-чатом
if text == "/thread_bridge" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if !isGroup {
b.tg.SendMessage(ctx, msg.Chat.ID, "Команда работает только в форум-группах.", nil)
continue
}
if !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if msg.MessageThreadID == 0 {
b.tg.SendMessage(ctx, msg.Chat.ID,
"Отправьте команду <b>внутри конкретного треда</b> (не в General). Тред будет связан с отдельным MAX-чатом.",
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok {
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
key, err := b.repo.StartThreadBridge(msg.Chat.ID, msg.MessageThreadID)
if err != nil {
slog.Error("StartThreadBridge failed", "err", err)
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось создать ключ.", &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
_, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s", key, key, b.cfg.MaxBotURL),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
if sendErr != nil {
slog.Error("thread-bridge reply send failed", "err", sendErr, "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID)
}
slog.Info("thread-bridge pending", "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID, "key", key)
continue
}
// /thread_unbridge — удалить связку конкретного треда
if text == "/thread_unbridge" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
continue
}
if isGroup && !isAdmin {
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue
}
if msg.MessageThreadID == 0 {
b.tg.SendMessage(ctx, msg.Chat.ID, "Отправьте команду внутри треда.", nil)
continue
}
if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) {
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
} else {
b.tg.SendMessage(ctx, msg.Chat.ID, "Этот тред не связан.", &SendOpts{ThreadID: msg.MessageThreadID})
}
continue
}
// Пересылка. Приоритет у thread-bridge: если сообщение в связанном треде — шлём в его MAX-чат.
var maxChatID int64
var linked bool
if msg.MessageThreadID != 0 {
maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID)
}
if !linked {
maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID)
}
if !linked {
continue
}