From f57105d2b6e9ab39ba326ae4ed3e2264bd2b2055 Mon Sep 17 00:00:00 2001 From: Andrey Lugovskoy Date: Fri, 3 Jul 2026 13:00:46 +0400 Subject: [PATCH] crosspost: fix long album caption + strip custom emoji and whitespace padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAX→TG album: caption over Telegram's 1024 media-group limit is no longer lost — the album is sent without an inline caption and the full text follows as a separate message (sendMaxAlbumToTg, covers both direct and queued delivery). - Forwarded text: strip Telegram custom_emoji (MAX can't render them; the fallback glyph only littered the copy) in tgEntitiesToHTML, and collapse whitespace padding (runs of spaces/nbsp, trailing spaces, 3+ blank lines) in crosspost captions — cuts bloated length (MAX ~4000 char limit) and fixes ugly gaps. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ajcn7YhgeexPwbfrUNMj8u --- README.md | 3 +- addon.go | 91 +++- addon_errs_test.go | 35 ++ breaker_pause_test.go | 151 +++++ bridge.go | 216 +++++++- docs/pikabu-post.md | 62 +++ dualbot.go | 163 ++++++ format.go | 54 +- helpmenu.go | 163 ++++++ main.go | 37 +- markup.go | 109 ++-- max.go | 514 +++++++++++++----- mediagroup.go | 21 +- .../postgres/000020_pause_links.down.sql | 2 + migrations/postgres/000020_pause_links.up.sql | 2 + .../postgres/000021_crosspost_seen.down.sql | 1 + .../postgres/000021_crosspost_seen.up.sql | 7 + migrations/sqlite/000020_pause_links.down.sql | 2 + migrations/sqlite/000020_pause_links.up.sql | 2 + .../sqlite/000021_crosspost_seen.down.sql | 1 + .../sqlite/000021_crosspost_seen.up.sql | 7 + postgres.go | 65 ++- queue.go | 49 +- repository.go | 13 + sqlite.go | 66 ++- telegram.go | 281 +++++++--- tgsender.go | 22 + tgsender_impl.go | 154 +++++- upload.go | 64 ++- 29 files changed, 2043 insertions(+), 314 deletions(-) create mode 100644 addon_errs_test.go create mode 100644 breaker_pause_test.go create mode 100644 docs/pikabu-post.md create mode 100644 dualbot.go create mode 100644 helpmenu.go create mode 100644 migrations/postgres/000020_pause_links.down.sql create mode 100644 migrations/postgres/000020_pause_links.up.sql create mode 100644 migrations/postgres/000021_crosspost_seen.down.sql create mode 100644 migrations/postgres/000021_crosspost_seen.up.sql create mode 100644 migrations/sqlite/000020_pause_links.down.sql create mode 100644 migrations/sqlite/000020_pause_links.up.sql create mode 100644 migrations/sqlite/000021_crosspost_seen.down.sql create mode 100644 migrations/sqlite/000021_crosspost_seen.up.sql diff --git a/README.md b/README.md index 9f8af57..92735c2 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,8 @@ export MAX_TOKEN=your_token | `DB_PATH` | Путь к SQLite базе | `bridge.db` | | `DATABASE_URL` | DSN для PostgreSQL (если задана — SQLite игнорируется) | — | | `TG_BOT_URL` | Ссылка на TG-бота (показывается в `/help`) | `https://t.me/MaxTelegramBridgeBot` | -| `MAX_BOT_URL` | Ссылка на MAX-бота (показывается в `/help`) | `https://max.ru/id710708943262_bot` | +| `MAX_BOT_URL` | Ссылка на основного MAX-бота (показывается в `/help`) | `https://max.ru/id710708943262_bot` | +| `MAX_BOT_URL_RESERVE` | Ссылка на запасного MAX-бота (failover; пусто — скрыть подсказку) | `https://max.ru/id710708943262_4_bot` | | `MAX_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинт: `/max-webhook` | — | | `TG_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинты: `/tg-webhook` | — | | `MAX_WEBHOOK_PORT` | Порт для webhook сервера MAX. Если порт совпадает с Telegram, значит будет общий сервер | `8443` | diff --git a/addon.go b/addon.go index 3f5550b..ef150fd 100644 --- a/addon.go +++ b/addon.go @@ -29,7 +29,19 @@ type Addon interface { // сигнатура совпадала между main.Addon и приватным пакетом аддона (Go требует // идентичности типов в методах интерфейса). Возвращает true, если сообщение // обработано как нежелательное (удалено) — ядру для остановки дальнейшей пересылки. - Moderate(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid, text string, hasLink, isAdmin bool) (handled bool) + Moderate(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid, text string, hasLink, isAdmin, extReply bool) (handled bool) + // WantImageCheck — нужно ли ядру вообще скачивать картинку для модерации в этом + // чате (антиспам включён). Дешёвая проверка ДО скачивания, чтобы не качать фото + // в чатах без антиспама. + WantImageCheck(platform string, chatID int64) bool + // DeleteServiceMsgs — удалять ли служебные сообщения (вошёл/вышел/смена названия/ + // закреп) в этом чате (тоггл владельца). Ядро удаляет, аддон лишь хранит флаг. + DeleteServiceMsgs(platform string, chatID int64) bool + // ModerateImage вызывается на входящее групповое ФОТО (TG/MAX): ядро скачивает + // байты картинки и отдаёт аддону. Аддон сам решает (декод QR, мультимодальный + // LLM по конфигу и доверию) и исполняет действие через Deps. true ⇒ удалено. + // Generic: ядро не знает семантику, просто «проверь картинку». + ModerateImage(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid string, img []byte, mime string, isAdmin bool) (handled bool) // CrosspostAllowed вызывается ПЕРЕД созданием новой связки кросспоста. ok=false ⇒ // ядро не создаёт связку и показывает reason. Generic: ядро не знает семантику решения. CrosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (ok bool, reason string) @@ -41,6 +53,21 @@ type Addon interface { // HandleChatShared — пользователь выбрал чат нативной кнопкой (chat_shared в личке). // requestID эхо запроса, sharedChatID — выбранный чат. Generic: ядро не знает семантику. HandleChatShared(ctx context.Context, userID int64, requestID int, sharedChatID int64, title string) (handled bool) + // HandleJoin вызывается на вступление участника в группу (капча / анти-рейд). + HandleJoin(ctx context.Context, platform string, chatID, userID int64, name, username string, isBot bool) + // ScreenContent — синхронный relay-гейт: можно ли пересылать этот текст ОТ ЛИЦА + // бота (в обе стороны TG↔MAX). block=true ⇒ ядро НЕ пересылает сообщение + // (запрещённый/токсичный контент — наркосбыт, скам-вакансии, гемблинг, игровой/ + // финансовый скам, оружие, CSAM — ИЛИ обфусцированный «точно спам»). Это НЕ + // модерация групп: что юзеры пишут в своих группах — их дело; гейт лишь защищает + // бота от рассылки запрещёнки/спама от его имени (иначе платформа банит бота). + // Generic: ядро не знает семантику. reason — для лога/уведомления. platform/chatID/ + // userID — метаданные для лога сообщений (накопление корпуса под тест/обучение). + ScreenContent(ctx context.Context, platform string, chatID, userID int64, text string) (block bool, reason string) + // HoldForModeration — на блок relay-гейта кладёт сообщение в очередь модерации и + // постит карточку с кнопками в чат. true ⇒ задержано (ядро не пересылает). payload — + // JSON исходного сообщения для ре-релея на «Пропустить». + HoldForModeration(ctx context.Context, platform string, srcChat int64, srcMsg string, target, authorID int64, authorName, text, caption, reason, payload string) bool } // GroupMessage — удобная обёртка для вызова Moderate из ядра (call-site читаемость). @@ -53,6 +80,15 @@ type GroupMessage struct { Text string HasLink bool IsAdmin bool + ExtReply bool // сообщение цитирует пост из другого чата/канала (external_reply) +} + +// isTgServiceSender — служебные/каналовые отправители Telegram: это КОНТЕНТ КАНАЛА или +// системного бота, а НЕ пользовательский спам. Антиспам их трогать нельзя (удаление +// авто-форварда поста канала в обсуждении сносит корень треда → пропадают комментарии). +// 777000=Telegram, 1087968824=GroupAnonymousBot, 136817688=Channel_Bot. +func isTgServiceSender(userID int64) bool { + return userID == 777000 || userID == 1087968824 || userID == 136817688 } // moderateGroupMessage прогоняет сообщение через аддон-модерацию (если подключён). @@ -60,7 +96,51 @@ func (b *Bridge) moderateGroupMessage(ctx context.Context, m GroupMessage) bool if b.addon == nil { return false } - return b.addon.Moderate(ctx, m.Platform, m.ChatID, m.UserID, m.TgMsgID, m.MaxMid, m.Text, m.HasLink, m.IsAdmin) + // Служебные/каналовые отправители (пост канала, анонимный админ, авто-форвард) — не спам. + if m.Platform == "tg" && isTgServiceSender(m.UserID) { + return false + } + return b.addon.Moderate(ctx, m.Platform, m.ChatID, m.UserID, m.TgMsgID, m.MaxMid, m.Text, m.HasLink, m.IsAdmin, m.ExtReply) +} + +// screenRelay — relay-гейт: блокирует пересылку запрещённого/обфусцированного контента +// ОТ ЛИЦА бота (в обе стороны TG↔MAX). Без аддона (публичная сборка) — всегда пропускаем. +func (b *Bridge) screenRelay(ctx context.Context, platform string, chatID, userID int64, text string) (block bool, reason string) { + if b.addon == nil { + return false, "" + } + // Служебные/каналовые отправители Telegram — контент канала, не режем. + if platform == "tg" && isTgServiceSender(userID) { + return false, "" + } + return b.addon.ScreenContent(ctx, platform, chatID, userID, text) +} + +// holdForModeration — на блок relay-гейта: отдать сообщение аддону в очередь модерации +// (он запостит карточку с кнопками). Без аддона — false (ядро дропает как обычно). +func (b *Bridge) holdForModeration(ctx context.Context, platform string, srcChat int64, srcMsg string, target, authorID int64, authorName, text, caption, reason, payload string) bool { + if b.addon == nil { + return false + } + return b.addon.HoldForModeration(ctx, platform, srcChat, srcMsg, target, authorID, authorName, text, caption, reason, payload) +} + +// wantImageCheck — включён ли антиспам в чате (решает аддон), стоит ли качать картинку. +func (b *Bridge) wantImageCheck(platform string, chatID int64) bool { + return b.addon != nil && b.addon.WantImageCheck(platform, chatID) +} + +// delServiceMsgs — удалять ли служебные сообщения в чате (решает аддон по конфигу). +func (b *Bridge) delServiceMsgs(platform string, chatID int64) bool { + return b.addon != nil && b.addon.DeleteServiceMsgs(platform, chatID) +} + +// moderateImage прогоняет картинку через аддон-модерацию (если подключён). +func (b *Bridge) moderateImage(ctx context.Context, platform string, chatID, userID int64, tgMsgID int, maxMid string, img []byte, mime string, isAdmin bool) bool { + if b.addon == nil || len(img) == 0 { + return false + } + return b.addon.ModerateImage(ctx, platform, chatID, userID, tgMsgID, maxMid, img, mime, isAdmin) } // crosspostAllowed — можно ли создать новую связку (решает аддон). Без аддона — да. @@ -81,6 +161,13 @@ func (b *Bridge) chatShared(ctx context.Context, userID int64, requestID int, sh return b.addon != nil && b.addon.HandleChatShared(ctx, userID, requestID, sharedChatID, title) } +// memberJoined — уведомить аддон о вступлении участника (капча/анти-рейд). +func (b *Bridge) memberJoined(ctx context.Context, platform string, chatID, userID int64, name, username string, isBot bool) { + if b.addon != nil { + b.addon.HandleJoin(ctx, platform, chatID, userID, name, username, isBot) + } +} + // crosspostDeliverable — доставлять ли пост связки (решает аддон). Без аддона — да. func (b *Bridge) crosspostDeliverable(ctx context.Context, maxChatID int64) bool { if b.addon == nil { diff --git a/addon_errs_test.go b/addon_errs_test.go new file mode 100644 index 0000000..a80dce1 --- /dev/null +++ b/addon_errs_test.go @@ -0,0 +1,35 @@ +//go:build addon + +package main + +import "testing" + +func TestIsUnforwardable_RecipientRestrictions(t *testing.T) { + // Пост СУЩЕСТВУЕТ, но не форвардится в скретч-ЛС (приватность получателя) — + // должен трактоваться как unforwardable (exists=true), а не как фатал/«поста нет». + unforwardable := []string{ + "telegram: bad request, Bad Request: VOICE_MESSAGES_FORBIDDEN (400)", + "Bad Request: VIDEO_MESSAGES_FORBIDDEN (400)", + "Bad Request: CHAT_FORWARDS_RESTRICTED", + "message can't be copied", + "This message can't be forwarded", + } + for _, s := range unforwardable { + if !isUnforwardable(s) { + t.Errorf("isUnforwardable(%q) = false, want true", s) + } + if isAddonMsgGone(s) { + t.Errorf("isAddonMsgGone(%q) = true, want false (пост есть)", s) + } + } + // Реально отсутствующий пост — НЕ unforwardable, это «поста нет». + gone := []string{"Bad Request: message to forward not found", "MESSAGE_ID_INVALID"} + for _, s := range gone { + if isUnforwardable(s) { + t.Errorf("isUnforwardable(%q) = true, want false", s) + } + if !isAddonMsgGone(s) { + t.Errorf("isAddonMsgGone(%q) = false, want true", s) + } + } +} diff --git a/breaker_pause_test.go b/breaker_pause_test.go new file mode 100644 index 0000000..747dd31 --- /dev/null +++ b/breaker_pause_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// testRepo поднимает мигрированную in-memory(file) sqlite и возвращает sqliteRepo. +func testRepo(t *testing.T) *sqliteRepo { + t.Helper() + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "t.db")+"?_journal_mode=WAL") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := runMigrations(db, "sqlite3"); err != nil { + t.Fatalf("migrate: %v", err) + } + return &sqliteRepo{db: db} +} + +func TestIsChatUnavailable(t *testing.T) { + unavail := []string{ + "chat.denied", "chat.not.found", "not enough rights", + "Forbidden: bot was kicked from the group chat", + "Bad Request: not enough rights to send", "user is deactivated", + } + for _, s := range unavail { + if !isChatUnavailable(s) { + t.Errorf("isChatUnavailable(%q) = false, want true", s) + } + } + // Глобальный бан аккаунта MAX (account.blocked / 403) и транзиенты — НЕ повод + // паузить связку (иначе встанет всё). + ok := []string{"account.blocked", "403", "timeout", "context deadline exceeded", ""} + for _, s := range ok { + if isChatUnavailable(s) { + t.Errorf("isChatUnavailable(%q) = true, want false", s) + } + } +} + +func TestCbPauseForChat_PairTransition(t *testing.T) { + repo := testRepo(t) + const tgChat, maxChat, owner = int64(111), int64(-999), int64(555) + if _, err := repo.db.Exec( + "INSERT INTO pairs (tg_chat_id, max_chat_id, prefix, created_at, tg_owner_id, max_owner_id) VALUES (?,?,?,?,?,?)", + tgChat, maxChat, 0, 0, owner, 0); err != nil { + t.Fatalf("insert pair: %v", err) + } + b := &Bridge{repo: repo} + + // owner резолвится и по MAX-, и по TG-стороне. + if got := repo.TgOwnerForChat(maxChat); got != owner { + t.Fatalf("TgOwnerForChat(max) = %d, want %d", got, owner) + } + if got := repo.TgOwnerForChat(tgChat); got != owner { + t.Fatalf("TgOwnerForChat(tg) = %d, want %d", got, owner) + } + + // Пауза по MAX-стороне (направление TG→MAX): первый раз — переход, второй — нет. + if !b.cbPauseForChat(maxChat) { + t.Fatal("first cbPauseForChat = false, want transition true") + } + if !repo.PairPaused(tgChat, maxChat) { + t.Fatal("pair not paused after cbPauseForChat") + } + if b.cbPauseForChat(maxChat) { + t.Fatal("second cbPauseForChat = true, want false (already paused, no re-notify)") + } + + // После /unpause — пауза по TG-стороне (направление MAX→TG) снова даёт переход. + if err := repo.SetPairPaused(tgChat, maxChat, false); err != nil { + t.Fatalf("unpause: %v", err) + } + if !b.cbPauseForChat(tgChat) { + t.Fatal("cbPauseForChat(tg) = false, want transition true") + } + if !repo.PairPaused(tgChat, maxChat) { + t.Fatal("pair not paused via tg side") + } +} + +func TestCbPauseForChat_NoPairing(t *testing.T) { + repo := testRepo(t) + b := &Bridge{repo: repo} + if b.cbPauseForChat(424242) { + t.Fatal("cbPauseForChat for unknown chat = true, want false") + } +} + +func TestClaimCrosspost(t *testing.T) { + repo := testRepo(t) + // Первый claim того же сообщения — проходит (кросспостим); повтор — дубль (пропустить). + if !repo.ClaimCrosspost("tg", -100500, "42") { + t.Fatal("первый claim должен пройти") + } + if repo.ClaimCrosspost("tg", -100500, "42") { + t.Fatal("повторный claim того же (platform,chat,msg) должен быть дублем") + } + // Другой msg_id / чат / платформа — независимы, проходят. + if !repo.ClaimCrosspost("tg", -100500, "43") { + t.Fatal("другой msg_id должен пройти") + } + if !repo.ClaimCrosspost("tg", -999, "42") { + t.Fatal("другой чат должен пройти") + } + if !repo.ClaimCrosspost("max", -100500, "42") { + t.Fatal("другая платформа должна пройти") + } +} + +func TestIsTgServiceSender(t *testing.T) { + for _, id := range []int64{777000, 1087968824, 136817688} { + if !isTgServiceSender(id) { + t.Errorf("id %d должен быть служебным (не модерируем)", id) + } + } + for _, id := range []int64{0, 123, 336903139, -1} { + if isTgServiceSender(id) { + t.Errorf("id %d НЕ служебный", id) + } + } +} + +func TestMaxTextHasLink(t *testing.T) { + b := &Bridge{cfg: Config{ + MaxBotURL: "https://max.ru/id710708943262_bot", + MaxBotURLReserve: "https://max.ru/id710708943262_4_bot", + }} + cases := []struct { + text string + want bool + }{ + {"@id710708943262_4_bot", false}, // упоминание самого (запасного) бота — НЕ ссылка + {"@id710708943262_bot", false}, // основной бот — НЕ ссылка + {"@id710708943262_bot привет", false}, // бот + текст + {"@scamchannel подпишись", true}, // чужой канал — ссылка + {"@id710708943262_bot и @scam", true}, // есть чужое упоминание + {"http://evil.example", true}, // http + {"загляни на t.me/foo", true}, // t.me + {"обычный текст без ссылок", false}, // нет ничего + {"", false}, + } + for _, c := range cases { + if got := b.maxTextHasLink(c.text); got != c.want { + t.Errorf("maxTextHasLink(%q) = %v, want %v", c.text, got, c.want) + } + } +} diff --git a/bridge.go b/bridge.go index ef406f3..3bd03d4 100644 --- a/bridge.go +++ b/bridge.go @@ -9,6 +9,7 @@ import ( "os" "strings" "sync" + "sync/atomic" "time" maxbot "github.com/max-messenger/max-bot-api-client-go" @@ -16,13 +17,15 @@ import ( // Config — настройки bridge, читаемые из env. type Config struct { - MaxToken string // токен MAX API (нужен для direct-send/upload) - TgBotURL string // ссылка на TG-бота для /help - MaxBotURL string // ссылка на MAX-бота для /help - MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling) - MaxWebhookPort string // порт HTTP-сервера для MAX webhook (по умолчанию 8443) - TgWebhookURL string // базовый URL для webhook TG (если пусто — long polling) - TgWebhookPort string // порт HTTP-сервера для TG webhook (по умолчанию 8444) + MaxToken string // токен MAX API (основной бот; нужен для direct-send/upload) + MaxTokenOld string // токен старого MAX-бота (резерв/failover; "" — выключено) + TgBotURL string // ссылка на TG-бота для /help + MaxBotURL string // ссылка на основного MAX-бота для /help (старый бот) + MaxBotURLReserve string // ссылка на запасного MAX-бота (для failover; "" — скрыть) + MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling) + MaxWebhookPort string // порт HTTP-сервера для MAX webhook (по умолчанию 8443) + TgWebhookURL string // базовый URL для webhook TG (если пусто — long polling) + TgWebhookPort string // порт HTTP-сервера для TG webhook (по умолчанию 8444) TgAPIURL string // custom TG Bot API URL (если пусто — api.telegram.org) AllowedUsers []int64 // whitelist TG user IDs (empty = allow all) TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited) @@ -40,25 +43,36 @@ type Config struct { // chatBreaker хранит состояние circuit breaker для одного чата. type chatBreaker struct { - fails int + fails int blockedAt time.Time } const ( - cbMaxFails = 3 // после N фейлов — блокируем + cbMaxFails = 3 // после N фейлов — блокируем cbCooldown = 5 * time.Minute // на сколько блокируем ) // Bridge — основная структура, объединяющая зависимости. type Bridge struct { - cfg Config - repo Repository - tg TGSender - maxApi *maxbot.Api - maxBotUID int64 // MAX bot user ID (для фильтрации своих сообщений) + cfg Config + repo Repository + tg TGSender + maxApi *maxbot.Api + maxApiOld *maxbot.Api // второй (старый) MAX-бот: резерв + обслуживание чатов без нового + maxBotUID int64 // MAX bot user ID (для фильтрации своих сообщений) + maxOldUID int64 // user ID старого MAX-бота (для фильтрации своих сообщений) + // maxBotCache — кэш «какой бот в чате»: chatID → токен для отправки. Заполняется + // по членству (GetChatMembership) и по тому, через чей вебхук пришёл апдейт. + maxBotMu sync.Mutex + maxBotCache map[int64]string // chatID → token (новый по умолчанию, старый если нового нет) + // maxSeenMid — дедуп входящих: один и тот же mid приходит от ОБОИХ ботов (если оба + // в чате). Обрабатываем один раз. + maxSeenMu sync.Mutex + maxSeenMid map[string]int64 // mid → unix-время (TTL-очистка) + httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут) apiClient *http.Client // для коротких API-запросов (малый таймаут) - whSecret string // random path segment for webhook URLs + whSecret string // random path segment for webhook URLs cpWaitMu sync.Mutex cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки) @@ -66,8 +80,14 @@ type Bridge struct { cpTgOwnerMu sync.Mutex cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост) - cbMu sync.Mutex - breakers map[int64]*chatBreaker // destination chatID → breaker + cbMu sync.Mutex + breakers map[int64]*chatBreaker // destination chatID → breaker + + // maxBlockedUntil — unix-секунды, до которых считаем аккаунт MAX заблокированным + // (MAX вернул 403 account.blocked). Пока активно — глушим пользовательские + // уведомления о недоставке: это глобальный бан, слать каждому бессмысленно. + // Сбрасывается на первой успешной отправке в MAX. + maxBlockedUntil atomic.Int64 // Буферизация TG media groups (альбомы) mgMu sync.Mutex @@ -86,28 +106,32 @@ type Bridge struct { } // NewBridge создаёт экземпляр Bridge. -func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api, maxBotUID int64) *Bridge { +func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api, maxBotUID int64, maxApiOld *maxbot.Api, maxOldUID int64) *Bridge { // Derive webhook secret from tokens (stable across restarts) h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken())) secret := hex.EncodeToString(h[:8]) b := &Bridge{ - cfg: cfg, - repo: repo, + cfg: cfg, + repo: repo, tg: tg, maxApi: maxApi, + maxApiOld: maxApiOld, maxBotUID: maxBotUID, + maxOldUID: maxOldUID, httpClient: &http.Client{ Timeout: 5 * time.Minute, // для download/upload больших файлов }, apiClient: &http.Client{ Timeout: 15 * time.Second, // для коротких API-запросов }, - whSecret: secret, - cpWait: make(map[int64]int64), - cpTgOwner: make(map[int64]int64), - breakers: make(map[int64]*chatBreaker), - mgBuffers: make(map[string]*mediaGroupBuffer), + whSecret: secret, + cpWait: make(map[int64]int64), + cpTgOwner: make(map[int64]int64), + breakers: make(map[int64]*chatBreaker), + mgBuffers: make(map[string]*mediaGroupBuffer), + maxBotCache: make(map[int64]string), + maxSeenMid: make(map[string]int64), } b.addon = loadAddon(b) return b @@ -149,11 +173,129 @@ func (b *Bridge) cbFail(chatID int64) bool { return false } -// cbSuccess сбрасывает счётчик ошибок для чата. +// cbSuccess сбрасывает счётчик ошибок для чата. Любая успешная отправка в MAX также +// снимает глобальный флаг «аккаунт заблокирован» (бан, видимо, сняли). func (b *Bridge) cbSuccess(chatID int64) { b.cbMu.Lock() - defer b.cbMu.Unlock() delete(b.breakers, chatID) + b.cbMu.Unlock() + b.maxBlockedUntil.Store(0) +} + +// chatUnavailableMarkers — маркеры ПЕРМАНЕНТНОЙ недоступности чата: бот удалён, +// заблокирован или лишён прав в этом конкретном чате. Намеренно БЕЗ голых "403"/"404" +// и account.blocked — это глобальный бан аккаунта MAX (транзиент), его ловит +// maxAccountBlocked(), и связки на паузу по нему ставить нельзя. +var chatUnavailableMarkers = []string{ + "chat.not.found", "chat.denied", "not enough rights", + "bot was kicked", "bot was blocked", "Forbidden", + "chat not found", "CHAT_WRITE_FORBIDDEN", "user is deactivated", + "need administrator rights", "PEER_ID_INVALID", +} + +// isChatUnavailable — постоянная ошибка «бот недоступен в этом чате». +func isChatUnavailable(errStr string) bool { + for _, m := range chatUnavailableMarkers { + if strings.Contains(errStr, m) { + return true + } + } + return false +} + +// cbPauseForChat ставит на паузу связку(и), в которые входит недоступный chatID +// (пробуем все интерпретации: chatID как MAX- или TG-сторона пары/кросспоста). +// Возвращает true ТОЛЬКО при переходе unpaused→paused — чтобы уведомить владельца +// один раз, а не на каждом упавшем сообщении. +func (b *Bridge) cbPauseForChat(chatID int64) bool { + newly := false + if tg, ok := b.repo.GetTgChat(chatID); ok && !b.repo.PairPaused(tg, chatID) { + b.repo.SetPairPaused(tg, chatID, true) + newly = true + } + if mx, ok := b.repo.GetMaxChat(chatID); ok && !b.repo.PairPaused(chatID, mx) { + b.repo.SetPairPaused(chatID, mx, true) + newly = true + } + if _, _, ok := b.repo.GetCrosspostTgChat(chatID); ok && !b.repo.CrosspostPaused(chatID) { + b.repo.SetCrosspostPaused(chatID, true) + newly = true + } + if mx, _, ok := b.repo.GetCrosspostMaxChat(chatID); ok && !b.repo.CrosspostPaused(mx) { + b.repo.SetCrosspostPaused(mx, true) + newly = true + } + return newly +} + +// cbPermanentFail вызывается на ПЕРМАНЕНТНОЙ ошибке доставки (бот заблокирован/ +// удалён/лишён прав в чате chatID): ставит связку на паузу и DM-ит владельцу. +// Глобальный бан аккаунта MAX игнорируем — это транзиент, не повод паузить всё. +func (b *Bridge) cbPermanentFail(ctx context.Context, chatID int64) { + if b.maxAccountBlocked() { + return + } + if !b.cbPauseForChat(chatID) { + return // уже на паузе или связки нет — не дублируем + } + slog.Warn("связка на паузе: чат недоступен (бот заблокирован/удалён/лишён прав)", "chatID", chatID) + b.notifyChatPaused(ctx, chatID) +} + +// notifyChatPaused DM-ит владельцу TG-стороны: чат недоступен, связка на паузе. +func (b *Bridge) notifyChatPaused(ctx context.Context, chatID int64) { + msg := "🚫 Чат недоступен — бот заблокирован, удалён или лишён прав.\n\n" + + "Связку поставил на паузу, чтобы не терять сообщения на бесконечных ретраях. " + + "Когда вернёте бота в чат (и сделаете администратором) — отправьте /unpause, " + + "чтобы возобновить пересылку." + owner := b.repo.TgOwnerForChat(chatID) + if owner == 0 { + slog.Warn("paused-notify: владелец TG не найден", "chatID", chatID) + return + } + if _, err := b.tg.SendMessage(ctx, owner, msg, nil); err != nil { + slog.Warn("paused-notify: DM не отправилось", "err", err, "owner", owner, "chatID", chatID) + } +} + +// usernameFromMaxURL извлекает юзернейм бота из ссылки вида https://max.ru/id..._bot. +func usernameFromMaxURL(u string) string { + if i := strings.LastIndexByte(u, '/'); i >= 0 { + return u[i+1:] + } + return u +} + +// maxTextHasLink — есть ли в MAX-тексте ссылка/внешнее упоминание (для правила link-new). +// http/t.me — всегда. Голый «@» считаем ссылкой ТОЛЬКО если упоминают НЕ нашего бота: +// иначе новичка ложно банило за @id..._bot (упоминание самого бота — не реклама). +func (b *Bridge) maxTextHasLink(text string) bool { + if strings.Contains(text, "http") || strings.Contains(text, "t.me") { + return true + } + if !strings.Contains(text, "@") { + return false + } + cleaned := text + for _, u := range []string{usernameFromMaxURL(b.cfg.MaxBotURL), usernameFromMaxURL(b.cfg.MaxBotURLReserve)} { + if u != "" { + cleaned = strings.ReplaceAll(cleaned, "@"+u, "") + } + } + return strings.Contains(cleaned, "@") +} + +// noteMaxSendErr выставляет глобальный флаг блокировки аккаунта MAX, если ошибка — +// account.blocked (403). Окно 10 мин, продлевается на каждой такой ошибке. +func (b *Bridge) noteMaxSendErr(errStr string) { + if strings.Contains(errStr, "account.blocked") { + b.maxBlockedUntil.Store(time.Now().Unix() + 600) + } +} + +// maxAccountBlocked — активен ли глобальный бан аккаунта MAX прямо сейчас. +func (b *Bridge) maxAccountBlocked() bool { + return time.Now().Unix() < b.maxBlockedUntil.Load() } // maxMaxFileBytes returns the MAX-to-TG file size limit in bytes (0 = unlimited). @@ -237,6 +379,12 @@ func (b *Bridge) hasPrefix(platform string, chatID int64) bool { // Для crosspost — в ЛС владельцу связки (tg_owner_id), чтобы не мусорить в канал. // Если владелец не задан (legacy) — уведомление дропается с warn-логом. func (b *Bridge) notifyTgUser(ctx context.Context, srcChat *TGMessage, maxChatID int64, text string, isCrosspost bool) { + // Глобальный бан аккаунта MAX — не заваливаем юзеров уведомлениями о недоставке + // (бан общий для всех чатов; сообщения и так копятся в очереди). + if b.maxAccountBlocked() { + slog.Debug("notify suppressed: MAX account blocked", "text", text) + return + } if isCrosspost { _, tgOwner := b.repo.GetCrosspostOwner(maxChatID) if tgOwner == 0 { @@ -296,6 +444,8 @@ func (b *Bridge) registerCommands(ctx context.Context) { cmds := []BotCommand{ {Command: "bridge", Description: "Связать чат с MAX-чатом"}, {Command: "bridge_update", Description: "Обновить связку группы (показать в кабинете)"}, + {Command: "pause", Description: "Поставить связку на паузу"}, + {Command: "unpause", Description: "Возобновить пересылку"}, {Command: "unbridge", Description: "Удалить связку чатов"}, {Command: "thread", Description: "Установить топик для сообщений из MAX"}, {Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"}, @@ -315,7 +465,8 @@ func (b *Bridge) registerCommands(ctx context.Context) { // Описание бота (экран "Что умеет этот бот"). Доп. строка от расширения (impl // в loadAddon) — без аддона пусто, в публичной сборке про импорт ни слова. - desc := "Мост между Telegram и MAX: зеркалит сообщения в связанных группах и кросспостит каналы." + b.extraDescription + desc := "Мост между Telegram и MAX: зеркалит сообщения в связанных группах и кросспостит каналы." + b.extraDescription + + "\n\n💬 Поддержка и новости: https://t.me/+0ucbOj4wBwQzMWNi" if err := b.tg.SetMyDescription(ctx, desc); err != nil { slog.Error("TG setMyDescription failed", "err", err) } @@ -329,6 +480,15 @@ func (b *Bridge) registerCommands(ctx context.Context) { } } +// reserveBotHint — подсказка добавить запасного MAX-бота админом (для failover). +// Пусто, если запасной не настроен (MaxBotURLReserve == ""). +func (b *Bridge) reserveBotHint() string { + if b.cfg.MaxBotURLReserve == "" { + return "" + } + return "\n\n🔁 Для надёжности добавьте админом и запасного бота (подменит основной, если тот недоступен): " + b.cfg.MaxBotURLReserve +} + // Run запускает TG и MAX listener'ы + периодическую очистку. func (b *Bridge) Run(ctx context.Context) { b.registerCommands(ctx) diff --git a/docs/pikabu-post.md b/docs/pikabu-post.md new file mode 100644 index 0000000..aa29daa --- /dev/null +++ b/docs/pikabu-post.md @@ -0,0 +1,62 @@ +# Как мы делали мост между Telegram и MAX: боль, баги и 385 рестартов + +Привет, Пикабу! Хочу рассказать про наш опыт создания бота-моста между Telegram и MAX (бывший VK Teams). Может кому-то пригодится — или хотя бы посмеётесь. + +## Зачем это вообще нужно + +MAX — это новый российский мессенджер от VK. Многие компании и сообщества начали туда переезжать, но часть людей осталась в Telegram. Нужно было связать чаты так, чтобы сообщения из одного мессенджера автоматически пересылались в другой. Написали бота на Go, задеплоили — и понеслось. + +## Проблема 1: Видео приходит как «document» без расширения + +Когда кто-то отправляет видео в Telegram без сжатия (как файл), оно приходит не как `Video`, а как `Document` с MIME-типом `video/mp4`. Наш бот честно пересылал его в MAX как файл с именем «document». Без расширения. MAX показывал это как непонятный файл, который нельзя посмотреть. + +**Решение:** проверяем MIME-тип документа. Если `video/*` — загружаем как видео, а не как файл. Плюс генерируем нормальное имя из MIME-типа, если оригинальное отсутствует. + +## Проблема 2: MAX API ложится, бот встаёт колом + +MAX API периодически тайматутится. Причём не на всё, а на конкретные запросы — отправка сообщений зависает на минуту и возвращает timeout. Наш бот обрабатывал сообщения синхронно — один зависший запрос блокировал ВСЮ очередь. Пока один чат тайматутился, остальные 60+ чатов стояли. Даже /start не отвечал. + +**Решение:** перенесли пересылку в горутины. Добавили circuit breaker — после 3 фейлов подряд чат блокируется на 5 минут. И retry-очередь в SQLite — если сообщение не отправилось, оно сохраняется в базу и отправляется позже. + +## Проблема 3: 385 рестартов за ночь + +Добавили подробное логирование для отладки. В логах писали `msg.From.ID` — ID отправителя. Всё работало... пока не пришёл пост из канала. У канальных постов в Telegram нет поля `From` — оно `nil`. `nil.ID` = panic. Бот упал, systemd перезапустил, пришёл следующий пост из канала — опять panic. И так 385 раз за ночь. + +**Решение:** банальная проверка на nil. Теперь используем safe getter: `if msg.From != nil { return msg.From.ID }`. Стыдно, но факт — одна строка уронила продакшн. + +## Проблема 4: Любой мог удалить чужие связки каналов + +Команда `/crosspost` показывала ВСЕ связки каналов ВСЕМ пользователям. С кнопками «Удалить». Любой, кто написал боту в личку, мог увидеть и удалить чужие связки. Мы обнаружили это когда у пользователя «пропали» 5 каналов. Сначала думали — баг. Оказалось — кто-то просто нажал кнопку. + +**Решение:** добавили `owner_id` — кто создал связку, тот ей и управляет. Причём пришлось хранить два ID (TG и MAX), потому что у одного человека разные ID в разных мессенджерах. Плюс soft delete — теперь удалённые связки не стираются, а помечаются. Всегда можно посмотреть кто и когда удалил. + +## Проблема 5: Замедление Telegram API + +Telegram API из России работает с задержками. Файлы скачиваются медленно, запросы тайматутятся. Подняли свой Telegram Bot API сервер (https://github.com/tdlib/telegram-bot-api) на зарубежном сервере. Но тут начались приключения: + +- Без флага `--local` файлы не отдаёт (404) +- С флагом `--local` файлы сохраняются на диск, но API возвращает локальный путь вместо URL +- Docker создаёт файлы с ограниченными правами — nginx не может их прочитать +- Webhook зарегистрировался одновременно и на api.telegram.org, и на наш сервер — каждое сообщение приходило дважды + +**Решение:** nginx на том же сервере раздаёт файлы с диска, Bot API на соседнем порту. Для MAX→TG пришлось скачивать файлы на наш сервер и загружать в TG как bytes — потому что наш TG Bot API сервер не видит MAX CDN. + +## Проблема 6: MAX webhook не присылает сообщения + +Переключились с polling на webhook для мгновенной доставки. Всё настроили, webhook зарегистрировался, nginx проксирует — а сообщения не приходят. Приходят только удаления и события входа/выхода. Оказалось, MAX API при подписке без явного списка типов обновлений НЕ включает `message_created` по умолчанию. Нужно явно указывать. + +**Решение:** передаём полный список типов при подписке: `message_created`, `message_edited`, `message_removed`, `message_callback` и т.д. + +## Что в итоге + +Бот работает, пересылает текст, фото, видео, GIF, стикеры, голосовые, файлы, кружки. Поддерживает редактирование, удаление, ответы, форматирование. Кросспостинг каналов в обе стороны. 70+ связанных чатов. + +Стек: Go, SQLite, MAX Bot API SDK, Telegram Bot API, systemd, nginx. + +Исходники открыты: https://github.com/BEARlogin/max-telegram-bridge-bot (CC BY-NC 4.0) + +Если кому нужно связать свои TG и MAX чаты — боты: +- Telegram: @MaxTelegramBridgeBot +- MAX: https://max.ru/id710708943262_bot + +Главный вывод: мессенджер-мосты — это не «просто пересылка текста». Это зоопарк edge-кейсов, кривых API, таймаутов и nil pointer'ов. Но работает! diff --git a/dualbot.go b/dualbot.go new file mode 100644 index 0000000..f62331f --- /dev/null +++ b/dualbot.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "time" + + maxbot "github.com/max-messenger/max-bot-api-client-go" +) + +// --- Дуал-бот MAX: основной (новый) + резервный (старый) --- +// Цель: обслуживать чаты, где есть только старый бот (новый туда не добавлен), и не +// дублировать в чатах, где сидят оба. Маршрутизация отправки — по членству бота в чате +// (кэш), дедуп входящих — по mid (оба бота присылают одно и то же сообщение). + +// dualEnabled — поднят ли второй (старый) бот. +func (b *Bridge) dualEnabled() bool { + return b.maxApiOld != nil && b.cfg.MaxTokenOld != "" +} + +// maxTokenFor возвращает токен бота, которым нужно слать в этот MAX-чат: по умолчанию +// основной (новый); если нового бота в чате НЕТ, а старый есть — токен старого. +// Результат кэшируется (членство дёргаем один раз на чат). +func (b *Bridge) maxTokenFor(ctx context.Context, chatID int64) string { + if !b.dualEnabled() { + return b.cfg.MaxToken + } + b.maxBotMu.Lock() + if t, ok := b.maxBotCache[chatID]; ok { + b.maxBotMu.Unlock() + return t + } + b.maxBotMu.Unlock() + + // Приоритет СТАРОМУ боту: он сидит почти везде (все каналы + большинство групп), + // так что где есть оба — работаем старым (по запросу). Новый — только там, где + // старого нет (чаты, откуда старого удалили) + горячий резерв. + tok := b.cfg.MaxToken + // На старого — только если он АДМИН (член-без-прав слать/модерировать не может). + if m, err := b.maxApiOld.Chats.GetChatMembership(ctx, chatID); err == nil && m.IsAdmin { + tok = b.cfg.MaxTokenOld // старый админ в чате — шлём старым + } + // Иначе остаётся новый (дефолт): чаты new-only (старого снесли) + где старый не админ. + b.maxBotMu.Lock() + b.maxBotCache[chatID] = tok + b.maxBotMu.Unlock() + return tok +} + +// maxClientFor — SDK-клиент бота, которым нужно слать в этот чат (для команд/ответов/ +// карточек). Та же маршрутизация, что и maxTokenFor (приоритет старому где он есть). +func (b *Bridge) maxClientFor(ctx context.Context, chatID int64) *maxbot.Api { + if !b.dualEnabled() { + return b.maxApi + } + // Токен из ctx (бот-источник апдейта в MAX-цикле / выбранный бот релея) приоритетнее — + // гарантирует ответ ТЕМ ЖЕ ботом, что получил сообщение (критично для ЛС, где членство + // не определить). Если в ctx нет — по членству. + tok := b.maxTokenCtx(ctx) + if tok == "" { + tok = b.maxTokenFor(ctx, chatID) + } + if tok == b.cfg.MaxTokenOld { + return b.maxApiOld + } + return b.maxApi +} + +// altMaxToken — «другой» бот относительно данного токена (для fallback при chat.not.found). +// "" — если дуал выключен или токен неизвестен. +func (b *Bridge) altMaxToken(tok string) string { + if !b.dualEnabled() { + return "" + } + if tok == b.cfg.MaxTokenOld { + return b.cfg.MaxToken + } + return b.cfg.MaxTokenOld +} + +// markChatBot прайм кэша: запомнить, что в чате есть бот с данным токеном (например, +// когда апдейт пришёл через его вебхук, или отправка прошла успешно). +func (b *Bridge) markChatBot(chatID int64, token string) { + if !b.dualEnabled() || token == "" { + return + } + b.maxBotMu.Lock() + b.maxBotCache[chatID] = token + b.maxBotMu.Unlock() +} + +// invalidateChatBot сбрасывает кэш бота для чата (например, send упал — пересчитаем). +func (b *Bridge) invalidateChatBot(chatID int64) { + b.maxBotMu.Lock() + delete(b.maxBotCache, chatID) + b.maxBotMu.Unlock() +} + +// maxDupMid — дедуп входящих MAX-сообщений по mid: при дуал-режиме один и тот же mid +// приходит от ОБОИХ ботов (если оба в чате). true ⇒ уже видели, пропускаем. +func (b *Bridge) maxDupMid(mid string) bool { + if mid == "" || !b.dualEnabled() { + return false + } + now := time.Now().Unix() + b.maxSeenMu.Lock() + defer b.maxSeenMu.Unlock() + if _, ok := b.maxSeenMid[mid]; ok { + return true + } + b.maxSeenMid[mid] = now + if len(b.maxSeenMid) > 5000 { // редкая TTL-очистка (старше 10 мин) + for k, t := range b.maxSeenMid { + if now-t > 600 { + delete(b.maxSeenMid, k) + } + } + } + return false +} + +// maxTokenCtxKey — ключ контекста для проброса выбранного MAX-токена в аплоады без +// изменения десятков сигнатур. forwardTgToMax кладёт сюда токен бота, который в чате; +// customUpload*/sendMaxChunk его читают, чтобы заливать/слать тем же ботом. +type maxTokenCtxKeyT struct{} + +func (b *Bridge) withMaxToken(ctx context.Context, token string) context.Context { + if token == "" { + return ctx + } + return context.WithValue(ctx, maxTokenCtxKeyT{}, token) +} + +// maxTokenCtx достаёт MAX-токен из контекста ("" если не задан). +func (b *Bridge) maxTokenCtx(ctx context.Context) string { + if v, ok := ctx.Value(maxTokenCtxKeyT{}).(string); ok { + return v + } + return "" +} + +// maxTokenForSend — токен для отправки/аплоада: из контекста (если релей его задал), +// иначе по членству (maxTokenFor). Обеспечивает один бот на весь релей. +func (b *Bridge) maxTokenForSend(ctx context.Context, chatID int64) string { + if t := b.maxTokenCtx(ctx); t != "" { + return t + } + return b.maxTokenFor(ctx, chatID) +} + +// maxTokenCtxOr — токен из контекста (если релей задал), иначе основной. Для аплоадов +// (у них нет chatID — берут токен релея из ctx, чтобы заливать тем же ботом, что и шлёт). +func (b *Bridge) maxTokenCtxOr(ctx context.Context) string { + if t := b.maxTokenCtx(ctx); t != "" { + return t + } + return b.cfg.MaxToken +} + +// isSelfMaxBot — сообщение от любого из НАШИХ MAX-ботов (основного или старого). +// Нужен дедуп/фильтр: старый бот видит сообщения нового (и наоборот) в общих чатах. +func (b *Bridge) isSelfMaxBot(senderUID int64) bool { + return senderUID != 0 && (senderUID == b.maxBotUID || (b.maxOldUID != 0 && senderUID == b.maxOldUID)) +} diff --git a/format.go b/format.go index ef0fa2d..7e6daf3 100644 --- a/format.go +++ b/format.go @@ -2,6 +2,7 @@ package main import ( "html" + "regexp" "strings" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" @@ -48,6 +49,28 @@ func formatAttributionHTML(name, text string, newline bool) string { return bold + ": " + text } +// tgForwardLine — метка «↪️ Переслано из X\n» (HTML, italic) для зеркалируемых форвардов. +// "" если сообщение не форвард. Подмешивается в начало тела (после атрибуции автора). +func tgForwardLine(msg *TGMessage) string { + if msg.ForwardFrom == "" { + return "" + } + return "↪️ Переслано из " + html.EscapeString(msg.ForwardFrom) + "\n" +} + +// tgContactText — текстовое представление контакта (sharing телефона), чтобы зеркало +// не было пустым. "" если контакта нет. +func tgContactText(msg *TGMessage) string { + if msg.Contact == nil { + return "" + } + name := strings.TrimSpace(msg.Contact.FirstName + " " + msg.Contact.LastName) + if name == "" { + name = "Контакт" + } + return "📇 " + name + "\n📞 " + msg.Contact.PhoneNumber +} + // formatTgCaption — для пересылки (текст или caption) func formatTgCaption(msg *TGMessage, prefix, newline bool) string { name := tgName(msg) @@ -55,6 +78,9 @@ func formatTgCaption(msg *TGMessage, prefix, newline bool) string { if text == "" { text = msg.Caption } + if text == "" { + text = tgContactText(msg) + } if prefix { return formatAttribution("[TG] "+name, text, newline) } @@ -68,6 +94,9 @@ func formatTgMessage(msg *TGMessage, prefix, newline bool) string { if text == "" { text = msg.Caption } + if text == "" { + text = tgContactText(msg) + } if text == "" { return "" } @@ -106,12 +135,33 @@ func formatTgCrosspostCaption(msg *TGMessage) string { text = msg.Caption entities = msg.CaptionEntities } - return tgEntitiesToHTML(text, entities) + return collapseWhitespace(tgEntitiesToHTML(text, entities)) } // formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов) func formatMaxCrosspostCaption(upd *maxschemes.MessageCreatedUpdate) string { - return upd.Message.Body.Text + return collapseWhitespace(upd.Message.Body.Text) +} + +// collapseWhitespace убирает «паддинг» из пересылаемого текста: длинные пробельные +// прогоны и лишние пустые строки (часто в постах-простынях), которые раздувают длину +// (лимит MAX ~4000) и уродуют вид. Схлопывает 2+ пробелов/табов/nbsp → 1, обрезает +// хвостовые пробелы строк, 3+ переносов → максимум одна пустая строка. +var ( + reTrailSpace = regexp.MustCompile(`[ \t\x{00A0}]+\n`) + reManyBlank = regexp.MustCompile(`\n{3,}`) + reManySpace = regexp.MustCompile(`[ \t\x{00A0}]{2,}`) +) + +func collapseWhitespace(s string) string { + if s == "" { + return s + } + s = strings.ReplaceAll(s, "\r\n", "\n") + s = reTrailSpace.ReplaceAllString(s, "\n") + s = reManyBlank.ReplaceAllString(s, "\n\n") + s = reManySpace.ReplaceAllString(s, " ") + return strings.TrimSpace(s) } // mimeToFilename генерирует имя файла из MIME-типа, если оригинальное имя отсутствует. diff --git a/helpmenu.go b/helpmenu.go new file mode 100644 index 0000000..ab3ef3b --- /dev/null +++ b/helpmenu.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "strings" +) + +// --- /start и /help как меню --- +// Вместо «портянки» по /start показываем короткий интро + inline-кнопки. Детали +// (как связать группы/каналы, команды, FAQ) — за кнопками (callback help:*), +// редактируем то же сообщение. Кнопки «добавить в группу/канал» — deep-link Telegram. + +// tgStartMenu — короткий интро + главное меню. +func (b *Bridge) tgStartMenu() (string, *InlineKeyboardMarkup) { + text := "🔗 Мост между Telegram и MAX\n\n" + + "Пишете в одном мессенджере — сообщение автоматически появляется в другом. " + + "Группы и каналы: текст, фото, видео, голосовые, файлы.\n\n" + + "🔒 Бот не хранит тексты сообщений — только ID для синхронизации правок, " + + "и те удаляются через 30 дней.\n\n" + + "Выберите действие 👇" + + kb := NewInlineKeyboard( + NewInlineRow( + InlineKeyboardButton{Text: "➕ В группу Telegram", URL: b.cfg.TgBotURL + "?startgroup=true"}, + InlineKeyboardButton{Text: "➕ В канал Telegram", URL: b.cfg.TgBotURL + "?startchannel=true"}, + ), + NewInlineRow(InlineKeyboardButton{Text: "🤖 Добавить MAX-бота", URL: b.cfg.MaxBotURL}), + NewInlineRow(NewInlineButton("📋 Как связать группы", "help:groups")), + NewInlineRow(NewInlineButton("🧵 Темы форума", "help:threads")), + NewInlineRow(NewInlineButton("📡 Кросспостинг каналов", "help:channels")), + NewInlineRow( + NewInlineButton("⌨️ Команды", "help:cmds"), + NewInlineButton("❓ Вопросы", "help:faq"), + ), + ) + // Расширенные функции (импорт/комменты/антиспам/PRO) приходят от расширения через + // extraHelp — в публичном коде их не хардкодим. Кнопка только если расширение есть. + if strings.TrimSpace(b.extraHelp) != "" { + kb.Rows = append(kb.Rows, NewInlineRow(NewInlineButton("✨ Ещё: импорт, PRO, антиспам", "help:more"))) + } + // Если задана подробная инструкция (HELP_FILE/help.html) — кнопка на полный текст. + if customHelp() != "" { + kb.Rows = append(kb.Rows, NewInlineRow(NewInlineButton("📖 Полная инструкция", "help:full"))) + } + return text, kb +} + +// helpBackKb — клавиатура с одной кнопкой «назад в меню». +func helpBackKb() *InlineKeyboardMarkup { + return NewInlineKeyboard(NewInlineRow(NewInlineButton("⬅️ Назад", "help:home"))) +} + +func (b *Bridge) helpGroupsText() string { + return "📋 Как связать группы\n\n" + + "1. Добавьте бота в обе группы:\n" + + " • Telegram — кнопка «➕ В группу Telegram»\n" + + " • MAX — " + b.cfg.MaxBotURL + "\n" + + " (если поиск в MAX не находит по полному имени — введите имя без суффикса _bot)\n" + + "2. В MAX сделайте бота администратором группы с правом «Доступ к сообщениям» " + + "(читать все сообщения). Без этого права бот не видит команды и сообщения — связка не сработает.\n" + + "3. В Telegram — администратором, только если группа с темами (форум). " + + "В обычных группах не требуется.\n" + + "4. В одной из групп отправьте /bridge — бот выдаст ключ.\n" + + "5. Отправьте /bridge ключ во второй группе.\n" + + "6. Готово — сообщения зеркалятся в обе стороны." + b.reserveBotHint() +} + +func (b *Bridge) helpThreadsText() string { + return "🧵 Темы форума (threads)\n\n" + + "Если TG-группа — форум с темами (topics):\n\n" + + "/thread — выполните внутри нужной темы: сообщения из связанной " + + "MAX-группы будут приходить именно в эту тему.\n\n" + + "/thread_bridge — связать отдельную тему с отдельной MAX-группой: " + + "выполните в теме → бот выдаст ключ → в MAX-группе отправьте /thread_bridge ключ.\n\n" + + "/thread_unbridge — разорвать связку темы.\n\n" + + "⚠️ При обычном /bridge на всю группу в MAX уходят сообщения из ВСЕХ тем. " + + "Чтобы зеркалить только одну тему: /unbridge на группе → /thread_bridge в нужной теме." +} + +func (b *Bridge) helpChannelsText() string { + return "📡 Кросспостинг каналов\n\n" + + "1. Добавьте бота администратором (с правом постинга) в оба канала:\n" + + " • Telegram — " + b.cfg.TgBotURL + "\n" + + " • MAX — " + b.cfg.MaxBotURL + "\n" + + "2. Перешлите пост из TG-канала в личку TG-боту.\n" + + "3. Бот покажет ID канала — скопируйте.\n" + + "4. В личке MAX-бота (" + b.cfg.MaxBotURL + ") отправьте: /crosspost ID\n" + + "5. Перешлите пост из MAX-канала туда же — готово!\n\n" + + "Управление: /crosspost — список связок с кнопками, либо перешлите " + + "пост из связанного канала." + b.reserveBotHint() +} + +func (b *Bridge) helpCmdsText() string { + return "⌨️ Команды\n\n" + + "/bridge — создать ключ для связки групп\n" + + "/bridge ключ — связать эту группу по ключу\n" + + "/bridge prefix on|off — префикс [TG]/[MAX]\n" + + "/pause — поставить связку на паузу (не зеркалить, не удаляя)\n" + + "/unpause — возобновить пересылку\n" + + "/unbridge — удалить связку\n" + + "/thread — направить MAX → текущий топик (форум)\n" + + "/thread_bridge — связать тред с отдельной MAX-группой\n" + + "/thread_unbridge — разорвать связку треда\n" + + "/crosspost — связки каналов и управление" +} + +func (b *Bridge) helpFaqText() string { + return "❓ Частые вопросы\n\n" + + "Бот хранит мои сообщения?\n" + + "Нет. Только ID для синхронизации правок, удаляются через 30 дней.\n\n" + + "Что пересылается?\n" + + "Текст, фото, видео, голосовые, документы, альбомы, правки.\n\n" + + "Нужно ли делать бота админом?\n" + + "В MAX — да. В Telegram — только в форум-группах и в каналах (для постинга).\n\n" + + "Сообщения не доходят?\n" + + "Проверьте, что бот добавлен в нужный чат и является администратором.\n\n" + + "💬 Поддержка: https://t.me/+0ucbOj4wBwQzMWNi" +} + +// handleHelpMenuCallback — навигация по меню /start (help:*). Редактирует сообщение. +// Возвращает true, если callback обработан здесь. +func (b *Bridge) handleHelpMenuCallback(ctx context.Context, query *TGCallback, data string) bool { + if !strings.HasPrefix(data, "help:") { + return false + } + chatID := query.Message.Chat.ID + msgID := query.Message.MessageID + + var text string + var kb *InlineKeyboardMarkup + switch data { + case "help:home": + text, kb = b.tgStartMenu() + case "help:groups": + text, kb = b.helpGroupsText(), helpBackKb() + case "help:threads": + text, kb = b.helpThreadsText(), helpBackKb() + case "help:channels": + text, kb = b.helpChannelsText(), helpBackKb() + case "help:cmds": + text, kb = b.helpCmdsText(), helpBackKb() + case "help:faq": + text, kb = b.helpFaqText(), helpBackKb() + case "help:more": + if h := strings.TrimSpace(b.extraHelp); h != "" { + text, kb = h, helpBackKb() + } else { + text, kb = b.tgStartMenu() + } + case "help:full": + if ch := customHelp(); ch != "" { + text, kb = ch, helpBackKb() + } else { + text, kb = b.tgStartMenu() + } + default: + b.tg.AnswerCallback(ctx, query.ID, "") + return true + } + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ParseMode: "HTML", ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") + return true +} diff --git a/main.go b/main.go index 28072d6..2c82010 100644 --- a/main.go +++ b/main.go @@ -55,8 +55,10 @@ func main() { cfg := Config{ MaxToken: mustEnv("MAX_TOKEN"), + MaxTokenOld: os.Getenv("MAX_TOKEN_OLD"), TgBotURL: envOr("TG_BOT_URL", "https://t.me/MaxTelegramBridgeBot"), - MaxBotURL: envOr("MAX_BOT_URL", "https://max.ru/id710708943262_bot"), + MaxBotURL: envOr("MAX_BOT_URL", "https://max.ru/id710708943262_bot"), // основной (старый разбанен) + MaxBotURLReserve: envOr("MAX_BOT_URL_RESERVE", "https://max.ru/id710708943262_4_bot"), // запасной (failover) MaxWebhookURL: os.Getenv("MAX_WEBHOOK_URL"), MaxWebhookPort: envOr("MAX_WEBHOOK_PORT", "8443"), TgWebhookURL: os.Getenv("TG_WEBHOOK_URL"), @@ -163,12 +165,37 @@ func main() { slog.Error("MAX bot error", "err", err) os.Exit(1) } + var maxUID int64 maxInfo, err := maxApi.Bots.GetBot(context.Background()) if err != nil { - slog.Error("MAX bot info error", "err", err) - os.Exit(1) + // НЕ валим старт, если MAX недоступен или аккаунт бота заблокирован + // (403 account.blocked) — поднимаемся в деградированном режиме: Telegram, + // polling и антиспам работают; MAX-отправки фейлятся и уходят в очередь. + // Иначе при бане/простое MAX любой рестарт убивал бы бота целиком (был + // прод-инцидент: GetBot → account.blocked → os.Exit → крэш-луп). + slog.Error("MAX bot info error — продолжаю в деградированном режиме (MAX недоступен/заблокирован)", "err", err) + } else { + maxUID = maxInfo.UserId + slog.Info("MAX bot started", "name", maxInfo.Name) + } + + // Второй (старый) MAX-бот — резерв + обслуживание чатов, где нового бота нет. + // Best-effort: если токен не задан/невалиден — работаем одним ботом. + var maxApiOld *maxbot.Api + var maxOldUID int64 + if cfg.MaxTokenOld != "" { + if old, err := maxbot.New(cfg.MaxTokenOld, maxbot.WithBaseURL("https://platform-api.max.ru/")); err != nil { + slog.Error("MAX old bot init error — работаю без резервного бота", "err", err) + } else { + maxApiOld = old + if info, err := old.Bots.GetBot(context.Background()); err != nil { + slog.Error("MAX old bot info error", "err", err) + } else { + maxOldUID = info.UserId + slog.Info("MAX old bot started", "name", info.Name, "uid", maxOldUID) + } + } } - slog.Info("MAX bot started", "name", maxInfo.Name) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -178,7 +205,7 @@ func main() { cancel() }() - bridge := NewBridge(cfg, repo, tg, maxApi, maxInfo.UserId) + bridge := NewBridge(cfg, repo, tg, maxApi, maxUID, maxApiOld, maxOldUID) bridge.Run(ctx) slog.Info("Bridge stopped") } diff --git a/markup.go b/markup.go index 0631219..0722047 100644 --- a/markup.go +++ b/markup.go @@ -137,6 +137,11 @@ func tgEntitiesToHTML(text string, entities []Entity) string { runes := []rune(text) utf16units := utf16.Encode(runes) + // skip — код-юниты, которые НЕ выводим (кастомные эмодзи Telegram: MAX их не + // рендерит, а fallback-символ засоряет пересылку). Режем весь диапазон энтити. + skip := make([]bool, len(utf16units)) + hasSkip := false + type tag struct { pos int open bool @@ -146,6 +151,19 @@ func tgEntitiesToHTML(text string, entities []Entity) string { var tags []tag for i, e := range entities { + if e.Type == "custom_emoji" { + end := e.Offset + e.Length + if end > len(utf16units) { + end = len(utf16units) + } + for p := e.Offset; p < end; p++ { + if p >= 0 { + skip[p] = true + hasSkip = true + } + } + continue + } var openTag, closeTag string switch e.Type { case "bold": @@ -179,7 +197,7 @@ func tgEntitiesToHTML(text string, entities []Entity) string { tags = append(tags, tag{pos: end, open: false, idx: i, text: closeTag}) } - if len(tags) == 0 { + if len(tags) == 0 && !hasSkip { return html.EscapeString(text) } @@ -205,10 +223,12 @@ func tgEntitiesToHTML(text string, entities []Entity) string { } if i < len(utf16units) { if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) { - r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1])) - sb.WriteString(html.EscapeString(string(r))) - i++ - } else { + if !skip[i] { + r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1])) + sb.WriteString(html.EscapeString(string(r))) + } + i++ // суррогатная пара — пропускаем и низкий код-юнит + } else if !skip[i] { sb.WriteString(html.EscapeString(string(rune(utf16units[i])))) } } @@ -286,50 +306,69 @@ func maxMarkupsToHTML(text string, markups []maxschemes.MarkUp) string { runes := []rune(text) utf16units := utf16.Encode(runes) - type tag struct { - pos int - open bool - order int - tag string - } - - var tags []tag - for _, m := range markups { - var openTag, closeTag string + // open/close HTML для типа маркапа (для ссылки URL входит в идентичность тега). + type tagPair struct{ open, close string } + tagFor := func(m maxschemes.MarkUp) (tagPair, bool) { switch m.Type { case maxschemes.MarkupStrong: - openTag, closeTag = "", "" + return tagPair{"", ""}, true case maxschemes.MarkupEmphasized: - openTag, closeTag = "", "" + return tagPair{"", ""}, true case maxschemes.MarkupMonospaced: - openTag, closeTag = "", "" + return tagPair{"", ""}, true case maxschemes.MarkupStrikethrough: - openTag, closeTag = "", "" + return tagPair{"", ""}, true case maxschemes.MarkupUnderline: - openTag, closeTag = "", "" + return tagPair{"", ""}, true case maxschemes.MarkupLink: - openTag = `` - closeTag = "" - default: - continue + return tagPair{``, ""}, true } - tags = append(tags, tag{pos: m.From, open: true, order: 0, tag: openTag}) - tags = append(tags, tag{pos: m.From + m.Length, open: false, order: 1, tag: closeTag}) + return tagPair{}, false } - sort.Slice(tags, func(i, j int) bool { - if tags[i].pos != tags[j].pos { - return tags[i].pos < tags[j].pos + // activeAt — детерминированно упорядоченный набор тегов, активных на позиции i. + // MAX допускает ПЕРЕКРЫВАЮЩИЕСЯ маркапы (ссылка+жирный внахлёст), а HTML требует + // строгой вложенности. Поэтому на каждой границе закрываем все открытые теги и + // переоткрываем актуальные — так HTML всегда валиден (Telegram не ругается). + activeAt := func(i int) []tagPair { + var out []tagPair + for _, m := range markups { + if m.From <= i && i < m.From+m.Length { + if tp, ok := tagFor(m); ok { + out = append(out, tp) + } + } } - return tags[i].order > tags[j].order - }) + sort.Slice(out, func(a, b int) bool { return out[a].open < out[b].open }) + return out + } + sameTags := func(a, b []tagPair) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true + } var sb strings.Builder - tagIdx := 0 + var open []tagPair for i := 0; i <= len(utf16units); i++ { - for tagIdx < len(tags) && tags[tagIdx].pos == i { - sb.WriteString(tags[tagIdx].tag) - tagIdx++ + var cur []tagPair + if i < len(utf16units) { + cur = activeAt(i) + } + if !sameTags(cur, open) { + for j := len(open) - 1; j >= 0; j-- { + sb.WriteString(open[j].close) + } + open = cur + for _, tp := range open { + sb.WriteString(tp.open) + } } if i < len(utf16units) { if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) { diff --git a/max.go b/max.go index cf59729..045beac 100644 --- a/max.go +++ b/max.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" maxbot "github.com/max-messenger/max-bot-api-client-go" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" @@ -96,6 +97,7 @@ func (b *Bridge) noteBotChatMax(ctx context.Context, chatID int64, chatType stri func (b *Bridge) listenMax(ctx context.Context) { var updates <-chan maxschemes.UpdateInterface + var oldUpdates <-chan maxschemes.UpdateInterface // вебхук старого бота (дуал); nil иначе if b.cfg.MaxWebhookURL != "" { whPath := b.maxWebhookPath() @@ -106,11 +108,29 @@ func (b *Bridge) listenMax(ctx context.Context) { "message_created", "message_edited", "message_removed", "message_callback", "bot_added", "bot_removed", "user_added", "user_removed", "chat_title_changed", + "bot_started", // кнопка «Начать» в MAX — иначе бот молчит на старте } if _, err := b.maxApi.Subscriptions.Subscribe(ctx, whURL, updateTypes, ""); err != nil { slog.Error("MAX webhook subscribe failed", "err", err) return } + // Второй (старый) бот — отдельный путь вебхука, ТОТ ЖЕ канал ch (единый диспетч). + // Дубли (оба бота в чате) отсекаются дедупом по mid в обработчике. + if b.dualEnabled() { + oldPath := whPath + "-old" + oldURL := strings.TrimRight(b.cfg.MaxWebhookURL, "/") + oldPath + // Отдельный канал для старого бота — чтобы в диспетчере знать, КТО доставил + // апдейт (нужно для ответов в ЛС/группах тем же ботом, что получил сообщение). + chOld := make(chan maxschemes.UpdateInterface, 100) + http.HandleFunc(oldPath, b.maxApiOld.GetHandler(chOld)) + if _, err := b.maxApiOld.Subscriptions.Subscribe(ctx, oldURL, updateTypes, ""); err != nil { + slog.Error("MAX old-bot webhook subscribe failed", "err", err) + } else { + oldUpdates = chOld + slog.Info("MAX old-bot webhook subscribed", "path", oldPath) + } + } + updates = ch slog.Info("MAX webhook mode") @@ -131,14 +151,37 @@ func (b *Bridge) listenMax(ctx context.Context) { slog.Info("MAX polling mode") } + // Слияние апдейтов обоих ботов с пометкой бота-источника (чтобы отвечать тем же ботом). + type maxSrc struct { + upd maxschemes.UpdateInterface + tok string + } + merged := make(chan maxSrc, 200) + go func() { + for u := range updates { + merged <- maxSrc{u, b.cfg.MaxToken} + } + }() + if oldUpdates != nil { + go func() { + for u := range oldUpdates { + merged <- maxSrc{u, b.cfg.MaxTokenOld} + } + }() + } + for { select { case <-ctx.Done(): return - case upd, ok := <-updates: + case m, ok := <-merged: if !ok { return } + upd := m.upd + // Бот-источник апдейта → его токен в ctx: ответы (ЛС/группы/callback) уйдут + // тем же ботом (он точно в этом чате). maxClientFor читает токен из ctx. + ctx := b.withMaxToken(ctx, m.tok) slog.Debug("MAX update", "type", fmt.Sprintf("%T", upd)) @@ -164,6 +207,14 @@ func (b *Bridge) listenMax(ctx context.Context) { continue } + // Кнопка «Начать» в MAX (bot_started) — отвечаем приветствием, как на /start. + // Без этого новый бот молчит на старте (текст «/start» MAX не шлёт). + if bs, isStart := upd.(*maxschemes.BotStartedUpdate); isStart { + slog.Info("MAX bot started", "maxChat", bs.GetChatID(), "user", bs.GetUserID()) + b.sendMaxStart(ctx, bs.GetChatID()) + continue + } + // Обработка удаления (только bridge, не crosspost) if delUpd, isDel := upd.(*maxschemes.MessageRemovedUpdate); isDel { tgChatID, tgMsgID, _, ok := b.repo.LookupTgMsgID(delUpd.MessageId) @@ -186,9 +237,36 @@ func (b *Bridge) listenMax(ctx context.Context) { // Обработка edit if editUpd, isEdit := upd.(*maxschemes.MessageEditedUpdate); isEdit { - if editUpd.Message.Sender.UserId == b.maxBotUID { + if b.isSelfMaxBot(editUpd.Message.Sender.UserId) { continue } + // Дуал-бот: правка (message_edited) приходит от ОБОИХ ботов и НЕ дедупится + // как message_created. Без этого оба бота обрабатывают edit, и при фолбэке + // edit-media (re-send) пост ДУБЛИРУЕТСЯ дважды в TG. Дедуп по mid+содержимому: + // повтор той же правки режется, реальная следующая правка (другой текст/медиа) — нет. + if b.maxDupMid("edit:" + editUpd.Message.Body.Mid + ":" + editUpd.Message.Body.Text + + ":" + strconv.Itoa(len(editUpd.Message.Body.Attachments))) { + continue + } + // Модерация правок: спамер может опубликовать чистый текст (пройдёт фильтр), + // затем отредактировать его в спам. Прогоняем edit через антиспам ДО + // зеркалирования и независимо от наличия маппинга (иначе ниже `if !ok continue` + // пропустил бы правку в standalone-группе). + if isMaxGroup(editUpd.Message.Recipient.ChatType) && editUpd.Message.Sender.UserId != 0 { + eChat := editUpd.Message.Recipient.ChatId + eText := editUpd.Message.Body.Text + isAdmin := false + if admins, err := b.maxApi.Chats.GetChatAdmins(ctx, eChat); err == nil { + isAdmin = isMaxUserAdmin(admins.Members, editUpd.Message.Sender.UserId) + } + hasLink := b.maxTextHasLink(eText) + if b.moderateGroupMessage(ctx, GroupMessage{ + Platform: "max", ChatID: eChat, UserID: editUpd.Message.Sender.UserId, + MaxMid: editUpd.Message.Body.Mid, Text: eText, HasLink: hasLink, IsAdmin: isAdmin, + }) { + continue + } + } mid := editUpd.Message.Body.Mid tgChatID, tgMsgID, _, ok := b.repo.LookupTgMsgID(mid) if !ok { @@ -305,13 +383,19 @@ func (b *Bridge) listenMax(ctx context.Context) { if !isMsg { continue } + // Дуал-бот: одно и то же сообщение приходит от ОБОИХ ботов (если оба в чате) — + // обрабатываем один раз (mid уникален per-сообщение). Заодно метим бот чата. + if b.maxDupMid(msgUpd.Message.Body.Mid) { + continue + } body := msgUpd.Message.Body chatID := msgUpd.Message.Recipient.ChatId text := strings.TrimSpace(body.Text) isDialog := msgUpd.Message.Recipient.ChatType == "dialog" - slog.Debug("MAX msg received", "uid", msgUpd.Message.Sender.UserId, "chat", chatID, "type", msgUpd.Message.Recipient.ChatType) + slog.Debug("MAX msg received", "uid", msgUpd.Message.Sender.UserId, "chat", chatID, "type", msgUpd.Message.Recipient.ChatType, + "textLen", len(body.Text), "att", len(body.Attachments), "rawAtt", len(body.RawAttachments), "markups", len(body.Markups)) // Запоминаем юзера при личном сообщении if isDialog && msgUpd.Message.Sender.UserId != 0 { @@ -333,53 +417,12 @@ func (b *Bridge) listenMax(ctx context.Context) { "Автор: Andrey Lugovskoy (@BEARlogin)\n" + "Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n" + "Лицензия: CC BY-NC 4.0") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } if text == "/start" || text == "/help" { - if ch := customHelp(); ch != "" { - m := maxbot.NewMessage().SetChat(chatID).SetText(helpPlain(ch)) - b.maxApi.Messages.Send(ctx, m) - continue - } - m := maxbot.NewMessage().SetChat(chatID).SetText( - "Бот-мост между MAX и Telegram.\n\n" + - "Команды (группы):\n" + - "/bridge — создать ключ для связки чатов\n" + - "/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" + - "/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" + - "/unbridge — удалить связку\n" + - "/thread_bridge <ключ> — связать этот MAX-чат с отдельным TG-тредом (форум)\n" + - "/thread_unbridge — разорвать связку треда\n\n" + - "Кросспостинг каналов (в личке бота):\n" + - "/crosspost — связать MAX-канал с TG-каналом\n" + - " (TG ID получить: перешлите пост из TG-канала TG-боту)\n\n" + - "Как связать каналы:\n" + - "1. Добавьте бота админом в оба канала (с правом постинга)\n" + - " TG: " + b.cfg.TgBotURL + "\n" + - "2. Перешлите пост из TG-канала в личку TG-бота\n" + - "3. Бот покажет ID канала — скопируйте\n" + - "4. Здесь в личке напишите: /crosspost \n" + - "5. Перешлите пост из MAX-канала сюда → готово!\n\n" + - "/crosspost — список всех связок с кнопками управления\n" + - "Управление: перешлите пост из связанного канала → кнопки\n\n" + - "Автозамены в кросспостинге:\n" + - "В настройках связки (кнопка 🔄) можно добавить замены текста.\n" + - "Формат: текст | замена или /regex/ | замена\n" + - "Можно заменять только в ссылках или во всём тексте.\n\n" + - "Как связать группы:\n" + - "1. Добавьте бота в оба чата\n" + - " MAX: " + b.cfg.MaxBotURL + "\n" + - " TG: " + b.cfg.TgBotURL + "\n" + - "2. В MAX сделайте бота админом группы\n" + - "3. В TG сделайте бота админом, если группа — супергруппа с темами (форум). " + - "В обычных группах админство не требуется.\n" + - "4. В одном из чатов отправьте /bridge\n" + - "5. Бот выдаст ключ — отправьте его в другом чате (в группе, не в ЛС бота)\n" + - "6. Готово!\n\n" + - "Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues") - b.maxApi.Messages.Send(ctx, m) + b.sendMaxStart(ctx, chatID) continue } @@ -387,7 +430,9 @@ func (b *Bridge) listenMax(ctx context.Context) { isGroup := isMaxGroup(msgUpd.Message.Recipient.ChatType) isAdmin := false if isGroup && msgUpd.Message.Sender.UserId != 0 { - admins, err := b.maxApi.Chats.GetChatAdmins(ctx, chatID) + // Через бота, который в чате (dual-bot): иначе новый бот в old-only чате + // не прочитает админов → владелец/админ ошибочно считается не-админом. + admins, err := b.maxClientFor(ctx, chatID).Chats.GetChatAdmins(ctx, chatID) if err == nil { isAdmin = isMaxUserAdmin(admins.Members, msgUpd.Message.Sender.UserId) } @@ -399,7 +444,7 @@ func (b *Bridge) listenMax(ctx context.Context) { // Антиспам-модерация (аддон) для MAX-групп. Аддон сам решает по конфигу; // если удалил как спам — дальше не зеркалим. if isGroup && msgUpd.Message.Sender.UserId != 0 { - hasLink := strings.Contains(text, "http") || strings.Contains(text, "t.me") || strings.Contains(text, "@") + hasLink := b.maxTextHasLink(text) if b.moderateGroupMessage(ctx, GroupMessage{ Platform: "max", ChatID: chatID, @@ -417,7 +462,7 @@ func (b *Bridge) listenMax(ctx context.Context) { if text == "/bridge prefix on" || text == "/bridge prefix off" { if isGroup && !isAdmin { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } on := text == "/bridge prefix on" @@ -427,10 +472,10 @@ func (b *Bridge) listenMax(ctx context.Context) { reply = "Префикс [TG]/[MAX] выключен." } m := maxbot.NewMessage().SetChat(chatID).SetText(reply) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } else { m := maxbot.NewMessage().SetChat(chatID).SetText("Чат не связан. Сначала выполните /bridge.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } continue } @@ -439,12 +484,12 @@ func (b *Bridge) listenMax(ctx context.Context) { if text == "/bridge_update" { if isGroup && !isAdmin { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } if _, linked := b.repo.GetTgChat(chatID); !linked { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта группа не связана с Telegram. Сначала свяжите её через /bridge.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } var reply string @@ -455,7 +500,7 @@ func (b *Bridge) listenMax(ctx context.Context) { reply = "Не удалось обновить связку." } m := maxbot.NewMessage().SetChat(chatID).SetText(reply) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } @@ -463,7 +508,7 @@ func (b *Bridge) listenMax(ctx context.Context) { if text == "/bridge" || strings.HasPrefix(text, "/bridge ") { if isGroup && !isAdmin { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge")) @@ -478,13 +523,13 @@ func (b *Bridge) listenMax(ctx context.Context) { 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) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } if !isGroup { m := maxbot.NewMessage().SetChat(chatID).SetText( "Чтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\nЕсли хотите связать этот личный чат с Telegram-пользователем — введите ключ от него: /bridge <ключ>.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } } @@ -496,16 +541,16 @@ func (b *Bridge) listenMax(ctx context.Context) { if paired { m := maxbot.NewMessage().SetChat(chatID).SetText("Связано! Сообщения теперь пересылаются.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) slog.Info("paired", "platform", "max", "chat", chatID, "key", key) } else if generatedKey != "" { m := maxbot.NewMessage().SetChat(chatID). SetText(fmt.Sprintf("Ключ для связки: %s\n\nДобавьте TG-бота в нужную Telegram-группу и отправьте в ней (не в ЛС бота):\n/bridge %s\n\nСсылка на TG-бота (для добавления в группу): %s", generatedKey, generatedKey, b.cfg.TgBotURL)) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) slog.Info("pending", "platform", "max", "chat", chatID, "key", generatedKey) } else { m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или чат той же платформы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } continue } @@ -513,24 +558,51 @@ func (b *Bridge) listenMax(ctx context.Context) { if text == "/unbridge" { if isGroup && !isAdmin { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } if b.repo.Unpair("max", chatID) { m := maxbot.NewMessage().SetChat(chatID).SetText("Связка удалена.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } else { m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } continue } + // Пауза/возобновление связки групп (не удаляя её). + if text == "/pause" || text == "/unpause" { + if isGroup && !isAdmin { + m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) + continue + } + tgChatID, linked := b.repo.GetTgChat(chatID) + if !linked { + m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан (пауза — для связки групп).") + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) + continue + } + pause := text == "/pause" + if err := b.repo.SetPairPaused(tgChatID, chatID, pause); err != nil { + m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось изменить паузу.") + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) + continue + } + txt := "▶️ Связка возобновлена — пересылка снова работает." + if pause { + txt = "⏸ Связка на паузе — сообщения не зеркалятся (в обе стороны). Возобновить: /unpause" + } + b.maxClientFor(ctx, chatID).Messages.Send(ctx, maxbot.NewMessage().SetChat(chatID).SetText(txt)) + continue + } + // /thread_bridge — принять ключ, связать MAX-чат с конкретным TG-тредом if strings.HasPrefix(text, "/thread_bridge") { if isGroup && !isAdmin { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } key := strings.TrimSpace(strings.TrimPrefix(text, "/thread_bridge")) @@ -538,35 +610,35 @@ func (b *Bridge) listenMax(ctx context.Context) { 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) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } m := maxbot.NewMessage().SetChat(chatID).SetText( "Нужен ключ: /thread_bridge <ключ>\n\nСначала в Telegram-форум-группе выполните /thread_bridge внутри нужного треда — там выдадут ключ.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).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) + b.maxClientFor(ctx, chatID).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) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } if !ok { m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или истёк.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).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) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) slog.Info("thread-bridge paired", "tgChat", tgChatID, "thread", threadID, "maxChat", chatID) continue } @@ -574,15 +646,15 @@ func (b *Bridge) listenMax(ctx context.Context) { if text == "/thread_unbridge" { if isGroup && !isAdmin { m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } if b.repo.UnpairThreadByMax(chatID) { m := maxbot.NewMessage().SetChat(chatID).SetText("Связка треда удалена.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } else { m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан с тредом.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } continue } @@ -594,7 +666,7 @@ func (b *Bridge) listenMax(ctx context.Context) { rule, valid := parseReplacementInput(text) if !valid { m := maxbot.NewMessage().SetChat(chatID).SetText("Не получилось разобрать. Нужна одна строка с вертикальной чертой «|»:\nчто заменить | на что\n\nНапример: наш Телеграм | наш канал в MAX\n\nПопробуйте ещё раз через кнопку «🔄 Замены».") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } rule.Target = w.target @@ -607,7 +679,7 @@ func (b *Bridge) listenMax(ctx context.Context) { if err := b.repo.SetCrosspostReplacements(w.maxChatID, repl); err != nil { slog.Error("save replacements failed", "err", err) m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка сохранения.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } ruleType := "строка" @@ -620,7 +692,7 @@ func (b *Bridge) listenMax(ctx context.Context) { } m := maxbot.NewMessage().SetChat(chatID).SetText( fmt.Sprintf("Замена добавлена (%s, %s):\n%s → %s", dirLabel, ruleType, rule.From, rule.To)) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } } @@ -641,7 +713,7 @@ func (b *Bridge) listenMax(ctx context.Context) { "2. Бот покажет ID канала\n" + "3. Здесь напишите: /crosspost \n" + "4. Перешлите пост из MAX-канала сюда") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } else { for _, l := range links { kb := maxCrosspostKeyboard(b.maxApi, l.Direction, l.MaxChatID, b.repo.GetCrosspostSyncEdits(l.MaxChatID)) @@ -653,7 +725,7 @@ func (b *Bridge) listenMax(ctx context.Context) { m := maxbot.NewMessage().SetChat(chatID). SetText(statusText). AddKeyboard(kb) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } } continue @@ -661,10 +733,18 @@ func (b *Bridge) listenMax(ctx context.Context) { tgChannelID, err := strconv.ParseInt(arg, 10, 64) if err != nil { m := maxbot.NewMessage().SetChat(chatID).SetText("Неверный ID. Пример: /crosspost -1001234567890") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } + // Валидация: ID TG-канала ВСЕГДА отрицательный (-100…). Положительный = + // уронили минус или «сырой» ID из ссылки t.me/c/… — связка молча не заработает. + if tgChannelID >= 0 { + m := maxbot.NewMessage().SetChat(chatID).SetText( + "⚠️ Похоже, ID введён неверно.\n\nID TG-канала — ОТРИЦАТЕЛЬНЫЙ, начинается с -100, например: -1001234567890.\n\nЧто делать:\n1. Перешлите пост из TG-канала в личку TG-бота " + b.cfg.TgBotURL + "\n2. Он покажет ID — скопируйте ЦЕЛИКОМ, со знаком «-»\n3. Пришлите сюда: /crosspost ") + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) + continue + } // Сохраняем ожидание: userId → tgChannelID b.cpWaitMu.Lock() b.cpWait[msgUpd.Message.Sender.UserId] = tgChannelID @@ -672,7 +752,7 @@ func (b *Bridge) listenMax(ctx context.Context) { m := maxbot.NewMessage().SetChat(chatID).SetText( fmt.Sprintf("TG канал ID: %d\n\nТеперь перешлите любой пост из MAX-канала, который хотите связать.", tgChannelID)) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) slog.Info("crosspost waiting for forward", "user", msgUpd.Message.Sender.UserId, "tgChannel", tgChannelID) continue } @@ -693,26 +773,26 @@ func (b *Bridge) listenMax(ctx context.Context) { // Проверяем, не связан ли уже if _, _, ok := b.repo.GetCrosspostTgChat(maxChannelID); ok { m := maxbot.NewMessage().SetChat(chatID).SetText("Этот MAX-канал уже связан.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } // Достаём TG owner ID (кто переслал пост из TG-канала в TG-бот) - b.cpTgOwnerMu.Lock() - tgOwnerID := b.cpTgOwner[tgChannelID] - b.cpTgOwnerMu.Unlock() + b.cpTgOwnerMu.Lock() + tgOwnerID := b.cpTgOwner[tgChannelID] + b.cpTgOwnerMu.Unlock() - // Новую связку создаём, только если аддон разрешил (иначе показываем его reason). - if ok, reason := b.crosspostAllowed(ctx, msgUpd.Message.Sender.UserId, tgOwnerID); !ok { - m := maxbot.NewMessage().SetChat(chatID).SetText(reason) - b.maxApi.Messages.Send(ctx, m) - continue - } + // Новую связку создаём, только если аддон разрешил (иначе показываем его reason). + if ok, reason := b.crosspostAllowed(ctx, msgUpd.Message.Sender.UserId, tgOwnerID); !ok { + m := maxbot.NewMessage().SetChat(chatID).SetText(reason) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) + continue + } - if err := b.repo.PairCrosspost(tgChannelID, maxChannelID, msgUpd.Message.Sender.UserId, tgOwnerID); err != nil { + if err := b.repo.PairCrosspost(tgChannelID, maxChannelID, msgUpd.Message.Sender.UserId, tgOwnerID); err != nil { slog.Error("crosspost pair failed", "err", err) m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка при создании связки.") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } @@ -721,7 +801,7 @@ func (b *Bridge) listenMax(ctx context.Context) { m := maxbot.NewMessage().SetChat(chatID). SetText(fmt.Sprintf("Кросспостинг настроен!\nTG: %d ↔ MAX: %d\nНаправление: ⟷ оба", tgChannelID, maxChannelID)). AddKeyboard(kb) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) slog.Info("crosspost paired", "tg", tgChannelID, "max", maxChannelID, "maxOwner", msgUpd.Message.Sender.UserId, "tgOwner", tgOwnerID) continue } @@ -733,7 +813,7 @@ func (b *Bridge) listenMax(ctx context.Context) { m := maxbot.NewMessage().SetChat(chatID). SetText(maxCrosspostStatusText(tgID, direction)). AddKeyboard(kb) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) continue } } @@ -741,7 +821,7 @@ func (b *Bridge) listenMax(ctx context.Context) { // Канал не связан, cpWait нет — сообщить if maxChannelID != 0 { m := maxbot.NewMessage().SetChat(chatID).SetText("Этот канал не связан с кросспостингом.\n\nДля настройки:\n/crosspost ") - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, chatID).Messages.Send(ctx, m) } continue } @@ -755,7 +835,7 @@ func (b *Bridge) listenMax(ctx context.Context) { linked = true } } - if linked && msgUpd.Message.Sender.UserId != b.maxBotUID { + if linked && !b.isSelfMaxBot(msgUpd.Message.Sender.UserId) { // Anti-loop if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") { prefix := b.hasPrefix("max", chatID) @@ -766,7 +846,7 @@ func (b *Bridge) listenMax(ctx context.Context) { } // Пересылка (crosspost fallback) - if msgUpd.Message.Sender.UserId == b.maxBotUID { + if b.isSelfMaxBot(msgUpd.Message.Sender.UserId) { continue } tgChatID, direction, cpLinked := b.repo.GetCrosspostTgChat(chatID) @@ -790,12 +870,63 @@ func (b *Bridge) listenMax(ctx context.Context) { caption = applyReplacements(caption, repl.MaxToTg) } + // Идемпотентность: этот пост MAX-канала уже кросспостился (повторная доставка / + // дубль-строка связки / перекрытие) — атомарный claim по (MAX-канал, mid) в БД. + if !b.repo.ClaimCrosspost("max", chatID, body.Mid) { + slog.Info("skip duplicate crosspost MAX→TG (already claimed)", "maxChannel", chatID, "mid", body.Mid, "tgChat", tgChatID) + continue + } go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, true) } } } // handleMaxCallback обрабатывает нажатия inline-кнопок (crosspost management). +// sendMaxStart шлёт приветствие в MAX-чат (на /start и на кнопку «Начать»/bot_started). +func (b *Bridge) sendMaxStart(ctx context.Context, chatID int64) { + if ch := customHelp(); ch != "" { + b.maxClientFor(ctx, chatID).Messages.Send(ctx, maxbot.NewMessage().SetChat(chatID).SetText(helpPlain(ch))) + return + } + text := "Бот-мост между MAX и Telegram.\n\n" + + "Команды (группы):\n" + + "/bridge — создать ключ для связки чатов\n" + + "/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" + + "/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" + + "/unbridge — удалить связку\n" + + "/thread_bridge <ключ> — связать этот MAX-чат с отдельным TG-тредом (форум)\n" + + "/thread_unbridge — разорвать связку треда\n\n" + + "Кросспостинг каналов (в личке бота):\n" + + "/crosspost — связать MAX-канал с TG-каналом\n" + + " (TG ID получить: перешлите пост из TG-канала TG-боту)\n\n" + + "Как связать каналы:\n" + + "1. Добавьте бота админом в оба канала (с правом постинга)\n" + + " TG: " + b.cfg.TgBotURL + "\n" + + "2. Перешлите пост из TG-канала в личку TG-бота\n" + + "3. Бот покажет ID канала — скопируйте\n" + + "4. Здесь в личке напишите: /crosspost \n" + + "5. Перешлите пост из MAX-канала сюда → готово!\n\n" + + "/crosspost — список всех связок с кнопками управления\n" + + "Управление: перешлите пост из связанного канала → кнопки\n\n" + + "Автозамены в кросспостинге:\n" + + "В настройках связки (кнопка 🔄) можно добавить замены текста.\n" + + "Формат: текст | замена или /regex/ | замена\n" + + "Можно заменять только в ссылках или во всём тексте.\n\n" + + "Как связать группы:\n" + + "1. Добавьте бота в оба чата\n" + + " MAX: " + b.cfg.MaxBotURL + "\n" + + " TG: " + b.cfg.TgBotURL + "\n" + + "2. В MAX сделайте бота админом группы С ПРАВОМ «Доступ к сообщениям» (читать все\n" + + " сообщения) — без него бот не видит команды и сообщения, связка не сработает\n" + + "3. В TG сделайте бота админом, если группа — супергруппа с темами (форум). " + + "В обычных группах админство не требуется.\n" + + "4. В одном из чатов отправьте /bridge\n" + + "5. Бот выдаст ключ — отправьте его в другом чате (в группе, не в ЛС бота)\n" + + "6. Готово!" + b.reserveBotHint() + "\n\n" + + "💬 Поддержка и новости: https://t.me/+0ucbOj4wBwQzMWNi" + b.maxClientFor(ctx, chatID).Messages.Send(ctx, maxbot.NewMessage().SetChat(chatID).SetText(text)) +} + func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.MessageCallbackUpdate) { data := cbUpd.Callback.Payload callbackID := cbUpd.Callback.CallbackID @@ -818,7 +949,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag return } if !b.isCrosspostOwner(maxChatID, userID) { - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Notification: "Только владелец связки может изменять настройки.", }) return @@ -826,8 +957,8 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag b.repo.SetCrosspostDirection(maxChatID, dir) tgID, _, _ := b.repo.GetCrosspostTgChat(maxChatID) - body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, dir), dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + body := maxCrosspostMessageBody(b.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, dir), dir, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, Notification: "Готово", }) @@ -841,7 +972,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag return } if !b.isCrosspostOwner(maxChatID, userID) { - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Notification: "Только владелец связки может изменять настройки.", }) return @@ -849,12 +980,12 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag cur := b.repo.GetCrosspostSyncEdits(maxChatID) b.repo.SetCrosspostSyncEdits(maxChatID, !cur) tgID, direction, _ := b.repo.GetCrosspostTgChat(maxChatID) - body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, !cur) + body := maxCrosspostMessageBody(b.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, direction), direction, maxChatID, !cur) note := "Синхронизация правок выключена" if !cur { note = "Синхронизация правок включена" } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, Notification: note, }) @@ -868,12 +999,12 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag return } if !b.isCrosspostOwner(maxChatID, userID) { - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Notification: "Только владелец связки может удалять.", }) return } - kb := b.maxApi.Messages.NewKeyboardBuilder() + kb := b.maxClientFor(ctx, 0).Messages.NewKeyboardBuilder() kb.AddRow(). AddCallback("Да, удалить", maxschemes.NEGATIVE, fmt.Sprintf("cpuc:%d", maxChatID)). AddCallback("Отмена", maxschemes.DEFAULT, fmt.Sprintf("cpux:%d", maxChatID)) @@ -881,7 +1012,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag Text: "Удалить кросспостинг?", Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, }) return @@ -894,7 +1025,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag return } if !b.isCrosspostOwner(maxChatID, userID) { - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Notification: "Только владелец связки может удалять.", }) return @@ -902,7 +1033,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag slog.Info("MAX crosspost unlink", "maxChatID", maxChatID, "by", userID) b.repo.UnpairCrosspost(maxChatID, userID) body := &maxschemes.NewMessageBody{Text: "Кросспостинг удалён."} - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, Notification: "Удалено", }) @@ -918,26 +1049,26 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag repl := b.repo.GetCrosspostReplacements(maxChatID) id := strconv.FormatInt(maxChatID, 10) // Заголовок с кнопками добавления - kb := maxReplacementsKeyboard(b.maxApi, maxChatID) + kb := maxReplacementsKeyboard(b.maxClientFor(ctx, 0), maxChatID) body := &maxschemes.NewMessageBody{ Text: formatReplacementsHeader(repl), Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) // Каждая замена — отдельное сообщение с кнопками for i, r := range repl.TgToMax { - dkb := maxReplItemKeyboard(b.maxApi, "tg>max", i, id, r.Target) + dkb := maxReplItemKeyboard(b.maxClientFor(ctx, 0), "tg>max", i, id, r.Target) m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId). SetText(formatReplacementItem(r, "tg>max")). AddKeyboard(dkb) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, 0).Messages.Send(ctx, m) } for i, r := range repl.MaxToTg { - dkb := maxReplItemKeyboard(b.maxApi, "max>tg", i, id, r.Target) + dkb := maxReplItemKeyboard(b.maxClientFor(ctx, 0), "max>tg", i, id, r.Target) m := maxbot.NewMessage().SetChat(cbUpd.Callback.User.UserId). SetText(formatReplacementItem(r, "max>tg")). AddKeyboard(dkb) - b.maxApi.Messages.Send(ctx, m) + b.maxClientFor(ctx, 0).Messages.Send(ctx, m) } return } @@ -972,7 +1103,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag r.Target = newTarget b.repo.SetCrosspostReplacements(maxChatID, repl) newText := formatReplacementItem(*r, dir) - dkb := maxReplItemKeyboard(b.maxApi, dir, idx, id, r.Target) + dkb := maxReplItemKeyboard(b.maxClientFor(ctx, 0), dir, idx, id, r.Target) body := &maxschemes.NewMessageBody{ Text: newText, Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(dkb.Build())}, @@ -981,7 +1112,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag if newTarget == "links" { label = "только ссылки" } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, Notification: "Тип: " + label, }) @@ -1011,7 +1142,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag } b.repo.SetCrosspostReplacements(maxChatID, repl) body := &maxschemes.NewMessageBody{Text: "Замена удалена."} - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, Notification: "Удалено", }) @@ -1030,7 +1161,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag if dir == "max>tg" { dirLabel = "MAX → TG" } - kb := b.maxApi.Messages.NewKeyboardBuilder() + kb := b.maxClientFor(ctx, 0).Messages.NewKeyboardBuilder() kb.AddRow(). AddCallback("📝 Весь текст", maxschemes.DEFAULT, "cprat:"+dir+":all:"+id). AddCallback("🔗 Только ссылки", maxschemes.DEFAULT, "cprat:"+dir+":links:"+id) @@ -1038,7 +1169,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag Text: fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel), Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) return } @@ -1066,7 +1197,7 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag "Просто отправьте такую строку сообщением.\n\n" + "Для продвинутых (регулярное выражение): /utm_source=\\w+/ | utm_source=max", } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) return } @@ -1078,12 +1209,12 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag } b.repo.SetCrosspostReplacements(maxChatID, CrosspostReplacements{}) repl := b.repo.GetCrosspostReplacements(maxChatID) - kb := maxReplacementsKeyboard(b.maxApi, maxChatID) + kb := maxReplacementsKeyboard(b.maxClientFor(ctx, 0), maxChatID) body := &maxschemes.NewMessageBody{ Text: formatReplacementsHeader(repl), Attachments: []interface{}{maxschemes.NewInlineKeyboardAttachmentRequest(kb.Build())}, } - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, Notification: "Очищено", }) @@ -1100,8 +1231,8 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag if !ok { return } - body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) + body := maxCrosspostMessageBody(b.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{Message: body}) return } @@ -1114,13 +1245,13 @@ func (b *Bridge) handleMaxCallback(ctx context.Context, cbUpd *maxschemes.Messag tgID, direction, ok := b.repo.GetCrosspostTgChat(maxChatID) if !ok { body := &maxschemes.NewMessageBody{Text: "Кросспостинг не найден."} - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, }) return } - body := maxCrosspostMessageBody(b.maxApi, maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) - b.maxApi.Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ + body := maxCrosspostMessageBody(b.maxClientFor(ctx, 0), maxCrosspostStatusText(tgID, direction), direction, maxChatID, b.repo.GetCrosspostSyncEdits(maxChatID)) + b.maxClientFor(ctx, 0).Messages.AnswerOnCallback(ctx, callbackID, &maxschemes.CallbackAnswer{ Message: body, }) return @@ -1183,6 +1314,61 @@ func maxCrosspostStatusText(tgChatID int64, direction string) string { return fmt.Sprintf("Кросспостинг настроен\nTG: %d ↔ MAX\nНаправление: %s", tgChatID, dirLabel) } +// maxAlbumItem — элемент MAX-альбома для пересылки в TG (тип + URL на MAX-CDN). +// Сериализуется в JSON и кладётся в очередь (AttType="album"), чтобы альбом не терял фото. +type maxAlbumItem struct { + Type string `json:"t"` // "photo" | "video" + URL string `json:"u"` +} + +// sendMaxAlbumToTg качает байты каждого элемента альбома и шлёт media group в TG. +// Байты (а не URL) — Telegram не фетчит MAX-CDN надёжно; уникальные attach-имена ставит +// SendMediaGroup (иначе все фото схлопывались в первое). caption/parseMode — на [0]. +// Единый путь и для прямой пересылки, и для доставки из очереди. +func (b *Bridge) sendMaxAlbumToTg(ctx context.Context, tgChatID int64, items []maxAlbumItem, caption, parseMode string, threadID, replyToID int) ([]int, error) { + // Лимит подписи media-group в Telegram — 1024 символа. Длинную подпись (частый кейс — + // пост-инструкция с альбомом) НЕ вешаем на альбом (иначе «caption is too long» → фото + // уходят без текста), а шлём отдельным сообщением следом. Порог по рунам с запасом: для + // HTML-подписи теги завышают счёт — тогда просто отправим текст отдельно, это безопасно. + const tgMediaCaptionLimit = 1024 + albumCaption := caption + overflowCaption := "" + if utf8.RuneCountInString(caption) > tgMediaCaptionLimit { + albumCaption = "" + overflowCaption = caption + } + + media := make([]TGInputMedia, 0, len(items)) + for i, it := range items { + typ := it.Type + if typ == "" { + typ = "photo" + } + im := TGInputMedia{Type: typ, File: FileArg{URL: it.URL}} + if data, name, dlErr := b.downloadURLWithLimit(it.URL, b.cfg.maxMaxFileBytes()); dlErr == nil { + im.File = FileArg{Name: name, Bytes: data} + } else { + slog.Warn("MAX→TG album item download failed, keep URL", "err", dlErr, "idx", i) + } + if i == 0 && albumCaption != "" { + im.Caption = albumCaption + im.ParseMode = parseMode + } + media = append(media, im) + } + msgs, err := b.tg.SendMediaGroup(ctx, tgChatID, media, &SendOpts{ThreadID: threadID, ReplyToID: replyToID}) + if err != nil { + return msgs, err + } + // Длинная подпись — отдельным сообщением (после альбома, в том же треде). + if overflowCaption != "" { + if _, serr := b.tg.SendMessage(ctx, tgChatID, overflowCaption, &SendOpts{ParseMode: parseMode, ThreadID: threadID}); serr != nil { + slog.Warn("MAX→TG album long caption send failed", "tgChat", tgChatID, "err", serr) + } + } + return msgs, nil +} + // forwardMaxToTg пересылает MAX-сообщение (текст/медиа) в TG-чат. // Если isCrosspost=true, caption используется как финальный текст (с заменами, без атрибуции). func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string, isCrosspost bool) { @@ -1194,6 +1380,24 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC chatID := msgUpd.Message.Recipient.ChatId text := strings.TrimSpace(body.Text) + // Пауза связки — временно не пересылаем (связку не удаляем). Возобновить: /unpause. + if isCrosspost { + if b.repo.CrosspostPaused(chatID) { + return + } + } else if b.repo.PairPaused(tgChatID, chatID) { + return + } + + // Дедуп: это MAX-сообщение уже переслано в TG (повторная доставка вебхука после + // рестарта / двойная доставка). Второй раз не шлём. + if body.Mid != "" { + if _, _, _, ok := b.repo.LookupTgMsgID(body.Mid); ok { + slog.Info("MAX→TG skip: already delivered (dedup)", "maxMid", body.Mid, "tgChat", tgChatID) + return + } + } + // Forward (репост) из MAX: оригинал лежит в Link.Message, а body.Text часто пустой // (если юзер не дописал свой комментарий). Подмешиваем содержимое Link.Message.Text, // иначе в TG прилетит пустое "Name:" сообщение. @@ -1229,6 +1433,23 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC } } + // Relay-гейт ТОЛЬКО для бридж-ГРУПП (бот постит от своего имени → страйк боту). + // Каналы (даже через /bridge) и кросспосты не трогаем — бот там не отправитель. + if !isCrosspost && isMaxGroup(msgUpd.Message.Recipient.ChatType) { + // Скриним только тело (text), без caption — он содержит имя автора (вычурные ники + // травили mixed-script/emoji-junk и блокировали легит). + if block, reason := b.screenRelay(ctx, "max", chatID, msgUpd.Message.Sender.UserId, text); block { + slog.Warn("relay blocked MAX→TG (group): prohibited/spam", + "maxChat", chatID, "tgChat", tgChatID, "reason", reason) + // Soft-блок → карточка в MAX-чате (админ «Пропустить» → автор soft-approved). + // Жёсткие категории holdForModeration дропнет без карточки. + payload, _ := json.Marshal(msgUpd) + b.holdForModeration(ctx, "max", chatID, msgUpd.Message.Body.Mid, tgChatID, + msgUpd.Message.Sender.UserId, msgUpd.Message.Sender.Name, text, caption, reason, string(payload)) + return + } + } + // Определяем тред-назначение: // — thread-pair: MAX-чат жёстко привязан к одному TG-треду, все сообщения идут туда, // reply-override не меняет тред. @@ -1300,6 +1521,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC // Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia var albumMedia []TGInputMedia + var albumItems []maxAlbumItem // тот же альбом как список {type,url} — для очереди (JSON) var soloMedia []struct { url string attType string @@ -1319,6 +1541,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC } p := TGInputMedia{Type: "photo", File: FileArg{URL: a.Payload.Url}} albumMedia = append(albumMedia, p) + albumItems = append(albumItems, maxAlbumItem{Type: "photo", URL: a.Payload.Url}) } case *maxschemes.VideoAttachment: if a.Payload.Url != "" { @@ -1327,6 +1550,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC } v := TGInputMedia{Type: "video", File: FileArg{URL: a.Payload.Url}} albumMedia = append(albumMedia, v) + albumItems = append(albumItems, maxAlbumItem{Type: "video", URL: a.Payload.Url}) } case *maxschemes.AudioAttachment: if a.Payload.Url != "" { @@ -1368,7 +1592,11 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC // чтобы не нарушить порядок доставки. Сразу ставим в очередь. if b.hasPendingForChat("max2tg", tgChatID) { slog.Info("MAX→TG queued (pending exists)", "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) - b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, pm) + if len(albumItems) > 1 { + b.enqueueMax2TgAlbum(chatID, tgChatID, body.Mid, htmlCaption, albumItems, pm) + } else { + b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, pm) + } return } @@ -1395,13 +1623,12 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC b.maxApi.Messages.Send(ctx, m) } } else { - // Несколько — отправляем как media group (альбом) - msgIDs, err := b.tg.SendMediaGroup(ctx, tgChatID, albumMedia, &SendOpts{ThreadID: threadID, ReplyToID: replyToID}) + // Несколько — media group. Единый helper (качает байты + уникальные attach-имена). + // На фейле НЕ спамим чат — sendErr → outer уведёт весь альбом в очередь (album-aware). + msgIDs, err := b.sendMaxAlbumToTg(ctx, tgChatID, albumItems, htmlCaption, pm, threadID, replyToID) if err != nil { slog.Error("MAX→TG album send failed", "err", err) sendErr = err - m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось отправить медиаальбом.") - b.maxApi.Messages.Send(ctx, m) } else if len(msgIDs) > 0 { sentMsgID = msgIDs[0] } @@ -1502,17 +1729,22 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC if useHTML { parseMode = "HTML" } - var eTooLarge *ErrFileTooLarge - if !errors.As(sendErr, &eTooLarge) { - notifyText := "Не удалось переслать сообщение. Попробуем ещё раз автоматически." - if b.cbBlocked(tgChatID) { - notifyText = "TG API недоступен. Сообщения в очереди, будут доставлены автоматически." + if isChatUnavailable(errStr) { + // Бот удалён/заблокирован/лишён прав в TG-чате → связку на паузу + DM + // владельцу. Не ретраим (очередь всё равно дропнет перманент). + b.cbPermanentFail(ctx, tgChatID) + } else { + // Транзиент (429 rate-limit / таймаут / API): НЕ пишем в MAX-чат «не удалось / + // в очереди» на каждый пост — при массовом постинге это флудило чат десятками + // сообщений. Молча в очередь: доставится автоматически (+ ретрай 429 в tgsender). + // Альбом кладём ЦЕЛИКОМ (album-aware), иначе доставилось бы 1 фото. + if len(albumItems) > 1 { + b.enqueueMax2TgAlbum(chatID, tgChatID, body.Mid, htmlCaption, albumItems, parseMode) + } else { + b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, parseMode) } - m := maxbot.NewMessage().SetChat(chatID).SetText(notifyText) - b.maxApi.Messages.Send(ctx, m) + b.cbFail(tgChatID) } - b.enqueueMax2Tg(chatID, tgChatID, body.Mid, htmlCaption, qAttType, qAttURL, parseMode) - b.cbFail(tgChatID) } else { b.cbSuccess(tgChatID) slog.Info("MAX→TG sent", "msgID", sentMsgID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) diff --git a/mediagroup.go b/mediagroup.go index 09c33b6..4392ce9 100644 --- a/mediagroup.go +++ b/mediagroup.go @@ -99,6 +99,17 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { return } } + // Пауза связки — альбом тоже не пересылаем. + if isCrosspost { + if b.repo.CrosspostPaused(maxChatID) { + return + } + } else if b.repo.PairPaused(items[0].msg.Chat.ID, maxChatID) { + return + } + // Дуал-бот: токен бота этого чата в ctx (аплоады + sendMaxDirect альбома идут им). + ctx = b.withMaxToken(ctx, b.maxTokenFor(ctx, maxChatID)) + mc := b.maxClientFor(ctx, maxChatID) // SDK-клиент бота этого чата (дуал) uid := tgUserID(items[0].msg) prefix := !isCrosspost && b.hasPrefix("tg", items[0].msg.Chat.ID) @@ -173,7 +184,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { } m.AddPhoto(uploaded) } else { - uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL) + uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL) if err != nil { slog.Error("media group: photo upload failed", "err", err) photoFailErr = err @@ -214,10 +225,12 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { // Если есть фото — отправляем через SDK (поддерживает AddPhoto) if photosSent > 0 { - result, err := b.maxApi.Messages.SendWithResult(ctx, m) + result, err := mc.Messages.SendWithResult(ctx, m) if err != nil { slog.Error("TG→MAX media group send failed", "err", err) - if b.cbFail(maxChatID) { + if isChatUnavailable(err.Error()) { + b.cbPermanentFail(ctx, maxChatID) // бот недоступен в чате → связка на паузу + DM + } else if b.cbFail(maxChatID) { b.notifyTgUser(ctx, items[0].msg, maxChatID, fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost) } @@ -234,7 +247,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { } else { cap = formatTgCaption(it.msg, prefix, b.cfg.MessageNewline) } - go b.forwardTgToMax(ctx, it.msg, maxChatID, cap, isCrosspost) + go b.forwardTgToMax(ctx, it.msg, maxChatID, cap, isCrosspost, false) } return } diff --git a/migrations/postgres/000020_pause_links.down.sql b/migrations/postgres/000020_pause_links.down.sql new file mode 100644 index 0000000..5a62b12 --- /dev/null +++ b/migrations/postgres/000020_pause_links.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE crossposts DROP COLUMN paused; +ALTER TABLE pairs DROP COLUMN paused; diff --git a/migrations/postgres/000020_pause_links.up.sql b/migrations/postgres/000020_pause_links.up.sql new file mode 100644 index 0000000..d1b4dc6 --- /dev/null +++ b/migrations/postgres/000020_pause_links.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE pairs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0; +ALTER TABLE crossposts ADD COLUMN paused INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/postgres/000021_crosspost_seen.down.sql b/migrations/postgres/000021_crosspost_seen.down.sql new file mode 100644 index 0000000..96cb1f4 --- /dev/null +++ b/migrations/postgres/000021_crosspost_seen.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS crosspost_seen; diff --git a/migrations/postgres/000021_crosspost_seen.up.sql b/migrations/postgres/000021_crosspost_seen.up.sql new file mode 100644 index 0000000..e2e9f80 --- /dev/null +++ b/migrations/postgres/000021_crosspost_seen.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS crosspost_seen ( + platform TEXT NOT NULL, + chat_id BIGINT NOT NULL, + msg_id TEXT NOT NULL, + at BIGINT NOT NULL, + PRIMARY KEY (platform, chat_id, msg_id) +); diff --git a/migrations/sqlite/000020_pause_links.down.sql b/migrations/sqlite/000020_pause_links.down.sql new file mode 100644 index 0000000..5a62b12 --- /dev/null +++ b/migrations/sqlite/000020_pause_links.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE crossposts DROP COLUMN paused; +ALTER TABLE pairs DROP COLUMN paused; diff --git a/migrations/sqlite/000020_pause_links.up.sql b/migrations/sqlite/000020_pause_links.up.sql new file mode 100644 index 0000000..d1b4dc6 --- /dev/null +++ b/migrations/sqlite/000020_pause_links.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE pairs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0; +ALTER TABLE crossposts ADD COLUMN paused INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/sqlite/000021_crosspost_seen.down.sql b/migrations/sqlite/000021_crosspost_seen.down.sql new file mode 100644 index 0000000..96cb1f4 --- /dev/null +++ b/migrations/sqlite/000021_crosspost_seen.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS crosspost_seen; diff --git a/migrations/sqlite/000021_crosspost_seen.up.sql b/migrations/sqlite/000021_crosspost_seen.up.sql new file mode 100644 index 0000000..ac4fd38 --- /dev/null +++ b/migrations/sqlite/000021_crosspost_seen.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS crosspost_seen ( + platform TEXT NOT NULL, + chat_id INTEGER NOT NULL, + msg_id TEXT NOT NULL, + at INTEGER NOT NULL, + PRIMARY KEY (platform, chat_id, msg_id) +); diff --git a/postgres.go b/postgres.go index 7e9b4e7..dfedb69 100644 --- a/postgres.go +++ b/postgres.go @@ -254,9 +254,7 @@ func (r *pgRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int, } func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) { - if threadID == 0 { - return 0, false - } + // threadID==0 — General-топик форума (валидная связка), НЕ отбиваем (см. sqlite). var id int64 err := r.db.QueryRow( "SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2", @@ -441,6 +439,67 @@ func (r *pgRepo) SetCrosspostSyncEdits(maxChatID int64, on bool) error { return err } +// --- Пауза связок (временно не пересылаем, связку не удаляем) --- + +func (r *pgRepo) PairPaused(tgChatID, maxChatID int64) bool { + var v int + r.db.QueryRow("SELECT COALESCE(paused, 0) FROM pairs WHERE tg_chat_id = $1 AND max_chat_id = $2", tgChatID, maxChatID).Scan(&v) + return v != 0 +} + +func (r *pgRepo) SetPairPaused(tgChatID, maxChatID int64, paused bool) error { + v := 0 + if paused { + v = 1 + } + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET paused = $1 WHERE tg_chat_id = $2 AND max_chat_id = $3", v, tgChatID, maxChatID) + return err +} + +func (r *pgRepo) CrosspostPaused(maxChatID int64) bool { + var v int + r.db.QueryRow("SELECT COALESCE(paused, 0) FROM crossposts WHERE max_chat_id = $1 AND deleted_at = 0", maxChatID).Scan(&v) + return v != 0 +} + +func (r *pgRepo) SetCrosspostPaused(maxChatID int64, paused bool) error { + v := 0 + if paused { + v = 1 + } + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE crossposts SET paused = $1 WHERE max_chat_id = $2 AND deleted_at = 0", v, maxChatID) + return err +} + +func (r *pgRepo) ClaimCrosspost(platform string, chatID int64, msgID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now().Unix() + r.db.Exec("DELETE FROM crosspost_seen WHERE at < $1", now-3600) // TTL: 1ч + res, err := r.db.Exec( + "INSERT INTO crosspost_seen (platform, chat_id, msg_id, at) VALUES ($1, $2, $3, $4) ON CONFLICT (platform, chat_id, msg_id) DO NOTHING", + platform, chatID, msgID, now) + if err != nil { + return true // на ошибке БД не блокируем доставку + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *pgRepo) TgOwnerForChat(chatID int64) int64 { + var owner int64 + r.db.QueryRow("SELECT tg_owner_id FROM pairs WHERE tg_chat_id = $1 OR max_chat_id = $2", chatID, chatID).Scan(&owner) + if owner != 0 { + return owner + } + r.db.QueryRow("SELECT tg_owner_id FROM crossposts WHERE (tg_chat_id = $1 OR max_chat_id = $2) AND deleted_at = 0", chatID, chatID).Scan(&owner) + return owner +} + func (r *pgRepo) TouchUser(userID int64, platform, username, firstName string) { now := time.Now().Unix() r.db.Exec(`INSERT INTO users (user_id, platform, username, first_name, first_seen, last_seen) VALUES ($1, $2, $3, $4, $5, $5) diff --git a/queue.go b/queue.go index 0ce915d..5db36b8 100644 --- a/queue.go +++ b/queue.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "log/slog" "strconv" @@ -42,6 +43,11 @@ func humanQueueError(errStr string) string { // notifyTg2MaxFailure сообщает о невозможности доставить пост в MAX: владельцу // связки (для crosspost) с номером поста и причиной, иначе — в исходный чат. func (b *Bridge) notifyTg2MaxFailure(ctx context.Context, item QueueItem, reason string) { + // Глобальный бан аккаунта MAX — молчим (бан общий, уведомлять каждого бессмысленно). + if b.maxAccountBlocked() { + slog.Debug("queue fail notify suppressed: MAX account blocked", "srcChat", item.SrcChatID) + return + } post := "" if item.SrcMsgID != "" && item.SrcMsgID != "0" { post = fmt.Sprintf(" (пост #%s)", item.SrcMsgID) @@ -124,6 +130,17 @@ func (b *Bridge) enqueueMax2Tg(maxChatID, tgChatID int64, maxMid, text, attType, } } +// enqueueMax2TgAlbum ставит в очередь ВЕСЬ альбом MAX→TG: список {type,url} в JSON, +// AttType="album". Иначе при доставке из очереди уходило только первое фото. +func (b *Bridge) enqueueMax2TgAlbum(maxChatID, tgChatID int64, maxMid, caption string, items []maxAlbumItem, parseMode string) { + data, err := json.Marshal(items) + if err != nil { + slog.Error("album enqueue marshal failed", "err", err) + return + } + b.enqueueMax2Tg(maxChatID, tgChatID, maxMid, caption, "album", string(data), parseMode) +} + // processQueue обрабатывает очередь — вызывается периодически. func (b *Bridge) processQueue(ctx context.Context) { items, err := b.repo.PeekQueue(queueBatchSize) @@ -155,6 +172,10 @@ func (b *Bridge) processQueue(ctx context.Context) { } func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now time.Time) { + // Связка на паузе — НЕ доставляем из очереди (оставляем item, заберём после /unpause). + if b.repo.PairPaused(item.SrcChatID, item.DstChatID) || b.repo.CrosspostPaused(item.DstChatID) { + return + } // Дедуп: если это сообщение уже доставлено (например, прямая отправка прошла, // но бот упал до удаления из очереди) — не отправляем повторно. if tgMsgID, _ := strconv.Atoi(item.SrcMsgID); b.alreadyDeliveredToMax(item.SrcChatID, tgMsgID) { @@ -194,12 +215,38 @@ func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now tim } func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now time.Time) { + // Связка на паузе — НЕ доставляем из очереди (src=MAX-чат, dst=TG-чат). + if b.repo.PairPaused(item.DstChatID, item.SrcChatID) || b.repo.CrosspostPaused(item.SrcChatID) { + return + } + // Дедуп: сообщение уже доставлено (напр. рестарт до удаления из очереди) — не шлём повторно. + if item.SrcMsgID != "" { + if _, _, _, ok := b.repo.LookupTgMsgID(item.SrcMsgID); ok { + slog.Info("queue: skip already-delivered max2tg", "id", item.ID, "maxMid", item.SrcMsgID) + b.repo.DeleteFromQueue(item.ID) + return + } + } var sentMsgID int var err error threadID := b.repo.GetTgThreadID(item.DstChatID) - if item.AttType != "" && item.AttURL != "" { + if item.AttType == "album" { + // Альбом: восстанавливаем список {type,url} и шлём media group целиком. + var items []maxAlbumItem + if json.Unmarshal([]byte(item.AttURL), &items) == nil && len(items) > 0 { + ids, e := b.sendMaxAlbumToTg(ctx, item.DstChatID, items, item.Text, item.ParseMode, threadID, 0) + err = e + if e == nil && len(ids) > 0 { + sentMsgID = ids[0] + } + } else { + slog.Warn("queue: bad album payload, dropping", "id", item.ID) + b.repo.DeleteFromQueue(item.ID) + return + } + } else if item.AttType != "" && item.AttURL != "" { opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID} switch item.AttType { case "photo": diff --git a/repository.go b/repository.go index b3f2dd9..b3f06a8 100644 --- a/repository.go +++ b/repository.go @@ -101,6 +101,19 @@ type Repository interface { GetCrosspostSyncEdits(maxChatID int64) bool SetCrosspostSyncEdits(maxChatID int64, on bool) error + // Пауза связок (временно не пересылаем, не удаляя) + PairPaused(tgChatID, maxChatID int64) bool + SetPairPaused(tgChatID, maxChatID int64, paused bool) error + CrosspostPaused(maxChatID int64) bool + SetCrosspostPaused(maxChatID int64, paused bool) error + // TgOwnerForChat — TG-владелец связки (пары или кросспоста), куда входит chatID + // (как TG- или MAX-сторона). 0 если не найден. Для DM-уведомлений о паузе. + TgOwnerForChat(chatID int64) int64 + // ClaimCrosspost атомарно застолбляет (platform, chatID, msgID) как «уже кросспостим». + // true — застолбили ВПЕРВЫЕ (кросспостить можно); false — уже было (дубль, пропустить). + // Идемпотентность против повторной доставки/дубль-строк/петель. Старьё чистится по TTL. + ClaimCrosspost(platform string, chatID int64, msgID string) bool + // Users TouchUser(userID int64, platform, username, firstName string) ListUsers(platform string) ([]int64, error) diff --git a/sqlite.go b/sqlite.go index b27c9db..55df088 100644 --- a/sqlite.go +++ b/sqlite.go @@ -249,9 +249,8 @@ func (r *sqliteRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, i } func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) { - if threadID == 0 { - return 0, false - } + // threadID==0 — это General-топик форума (валидная тред-связка), НЕ отбиваем: + // если для (чат, 0) связки нет — вернётся пусто, и вызывающий уйдёт на групповую. var id int64 err := r.db.QueryRow( "SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?", @@ -438,6 +437,67 @@ func (r *sqliteRepo) SetCrosspostSyncEdits(maxChatID int64, on bool) error { return err } +// --- Пауза связок (временно не пересылаем, связку не удаляем) --- + +func (r *sqliteRepo) PairPaused(tgChatID, maxChatID int64) bool { + var v int + r.db.QueryRow("SELECT COALESCE(paused, 0) FROM pairs WHERE tg_chat_id = ? AND max_chat_id = ?", tgChatID, maxChatID).Scan(&v) + return v != 0 +} + +func (r *sqliteRepo) SetPairPaused(tgChatID, maxChatID int64, paused bool) error { + v := 0 + if paused { + v = 1 + } + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET paused = ? WHERE tg_chat_id = ? AND max_chat_id = ?", v, tgChatID, maxChatID) + return err +} + +func (r *sqliteRepo) CrosspostPaused(maxChatID int64) bool { + var v int + r.db.QueryRow("SELECT COALESCE(paused, 0) FROM crossposts WHERE max_chat_id = ? AND deleted_at = 0", maxChatID).Scan(&v) + return v != 0 +} + +func (r *sqliteRepo) SetCrosspostPaused(maxChatID int64, paused bool) error { + v := 0 + if paused { + v = 1 + } + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE crossposts SET paused = ? WHERE max_chat_id = ? AND deleted_at = 0", v, maxChatID) + return err +} + +func (r *sqliteRepo) ClaimCrosspost(platform string, chatID int64, msgID string) bool { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now().Unix() + r.db.Exec("DELETE FROM crosspost_seen WHERE at < ?", now-3600) // TTL: 1ч + res, err := r.db.Exec( + "INSERT OR IGNORE INTO crosspost_seen (platform, chat_id, msg_id, at) VALUES (?, ?, ?, ?)", + platform, chatID, msgID, now) + if err != nil { + return true // на ошибке БД не блокируем доставку (лучше риск дубля, чем потеря) + } + n, _ := res.RowsAffected() + return n > 0 +} + +func (r *sqliteRepo) TgOwnerForChat(chatID int64) int64 { + var owner int64 + r.db.QueryRow("SELECT tg_owner_id FROM pairs WHERE tg_chat_id = ? OR max_chat_id = ?", chatID, chatID).Scan(&owner) + if owner != 0 { + return owner + } + r.db.QueryRow("SELECT tg_owner_id FROM crossposts WHERE (tg_chat_id = ? OR max_chat_id = ?) AND deleted_at = 0", chatID, chatID).Scan(&owner) + return owner +} + func (r *sqliteRepo) TouchUser(userID int64, platform, username, firstName string) { now := time.Now().Unix() r.mu.Lock() diff --git a/telegram.go b/telegram.go index 1e8623b..9e8c491 100644 --- a/telegram.go +++ b/telegram.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -58,11 +59,35 @@ func (b *Bridge) listenTelegram(ctx context.Context) { if b.isSelfTgBot(edited.From) { continue } + // Модерация правок: спамер может опубликовать чистый текст (пройдёт фильтр), + // затем отредактировать его в спам. Прогоняем edit через антиспам так же, как + // новое сообщение — ДО bridge-зеркалирования и независимо от наличия связки + // (иначе в standalone-группе ниже стоит `if !linked { continue }`). + if isTgGroup(edited.Chat.Type) && edited.From != nil && !edited.IsService { + isAdmin := false + if isTgAnonymousAdmin(edited) { + isAdmin = true + } else if status, err := b.tg.GetChatMember(ctx, edited.Chat.ID, edited.From.ID); err == nil { + isAdmin = isTgAdmin(status) + } + mtext := edited.Text + if mtext == "" { + mtext = edited.Caption + } + if edited.HasExternalReply { + mtext = strings.TrimSpace(mtext + " " + edited.ExternalReplyText) + } + if b.moderateGroupMessage(ctx, GroupMessage{ + Platform: "tg", ChatID: edited.Chat.ID, UserID: edited.From.ID, + TgMsgID: edited.MessageID, Text: mtext, HasLink: tgMsgHasLink(edited), IsAdmin: isAdmin, + ExtReply: edited.HasExternalReply, + }) { + continue + } + } var maxChatID int64 var linked bool - if edited.MessageThreadID != 0 { - maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID) - } + maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID) if !linked { maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID) } @@ -79,7 +104,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { if hasMedia && !hasMapping { prefix := b.hasPrefix("tg", edited.Chat.ID) caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline) - go b.forwardTgToMax(ctx, edited, maxChatID, caption, false) + go b.forwardTgToMax(ctx, edited, maxChatID, caption, false, false) continue } @@ -114,7 +139,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { fwd := formatAttributionHTML(name, mdText, b.cfg.MessageNewline) m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd) m.SetFormat("html") - if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil { + if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil { slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID) } else { slog.Info("TG→MAX edited", "mid", maxMsgID, "uid", tgUserID(edited), "tgChat", edited.Chat.ID) @@ -195,43 +220,12 @@ func (b *Bridge) listenTelegram(ctx context.Context) { if text == "/start" || text == "/help" { if ch := customHelp(); ch != "" { - b.tg.SendMessage(ctx, msg.Chat.ID, ch, &SendOpts{ParseMode: "HTML"}) + b.tg.SendMessage(ctx, msg.Chat.ID, ch, &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) continue } - b.tg.SendMessage(ctx, msg.Chat.ID, - "Бот-мост между Telegram и MAX.\n\n"+ - "Команды (группы):\n"+ - "/bridge — создать ключ для связки чатов\n"+ - "/bridge <ключ> — связать этот чат с MAX-чатом по ключу\n"+ - "/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n"+ - "/unbridge — удалить связку\n"+ - "/thread — направить сообщения из MAX в текущий топик (форум)\n"+ - "/thread_bridge — связать текущий тред с отдельной MAX-группой (форум)\n"+ - "/thread_unbridge — разорвать связку треда\n\n"+ - "Кросспостинг каналов:\n"+ - "1. Добавьте бота админом в оба канала (с правом постинга)\n"+ - "2. Перешлите пост из TG-канала в личку TG-бота\n"+ - "3. Бот покажет ID — скопируйте\n"+ - "4. В личке MAX-бота: /crosspost \n"+ - "5. Перешлите пост из MAX-канала → готово!\n\n"+ - "/crosspost — список всех связок с кнопками управления\n"+ - "Управление: перешлите пост из связанного канала → кнопки\n\n"+ - "Автозамены в кросспостинге:\n"+ - "В настройках связки (кнопка 🔄) можно добавить замены текста.\n"+ - "Формат: текст | замена или /regex/ | замена\n"+ - "Можно заменять только в ссылках или во всём тексте.\n\n"+ - "Как связать группы:\n"+ - "1. Добавьте бота в оба чата\n"+ - " TG: "+b.cfg.TgBotURL+"\n"+ - " MAX: "+b.cfg.MaxBotURL+"\n"+ - "2. В MAX сделайте бота админом группы\n"+ - "3. В TG сделайте бота админом, если группа — супергруппа с темами (форум). "+ - "В обычных группах админство не требуется.\n"+ - "4. В одном из чатов отправьте /bridge\n"+ - "5. Бот выдаст ключ — отправьте /bridge <ключ> в другом чате\n"+ - "6. Готово!\n\n"+ - b.extraHelp+ - "Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues", &SendOpts{ThreadID: msg.MessageThreadID}) + intro, kb := b.tgStartMenu() + b.tg.SendMessage(ctx, msg.Chat.ID, intro, + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID, ReplyMarkup: kb}) continue } @@ -332,7 +326,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { } b.tg.SendMessage(ctx, msg.Chat.ID, - fmt.Sprintf("TG-канал «%s»\nID: %d\n\nВ личке MAX-бота напишите:\n/crosspost %d\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.", channelTitle, channelID, channelID, b.cfg.MaxBotURL), + fmt.Sprintf("TG-канал «%s»\nID: %d\n\nВ личке MAX-бота напишите:\n/crosspost %d\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.%s", channelTitle, channelID, channelID, b.cfg.MaxBotURL, b.reserveBotHint()), &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) continue } @@ -481,7 +475,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key) } else if generatedKey != "" { b.tg.SendMessage(ctx, msg.Chat.ID, - fmt.Sprintf("Ключ для связки: %s\n\nДобавьте MAX-бота в нужную MAX-группу и отправьте в ней (не в ЛС бота):\n/bridge %s\n\nСсылка на MAX-бота (для добавления в группу): %s", generatedKey, generatedKey, b.cfg.MaxBotURL), + fmt.Sprintf("Ключ для связки: %s\n\nДобавьте MAX-бота в нужную MAX-группу, сделайте его администратором с правом «Доступ к сообщениям» (читать все сообщения — иначе бот не увидит команду), и отправьте в ней (не в ЛС бота):\n/bridge %s\n\nСсылка на MAX-бота (для добавления в группу): %s%s", generatedKey, generatedKey, b.cfg.MaxBotURL, b.reserveBotHint()), &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey) } else { @@ -506,6 +500,33 @@ func (b *Bridge) listenTelegram(ctx context.Context) { continue } + // Пауза/возобновление связки групп (не удаляя её). + if text == "/pause" || text == "/unpause" { + if isGroup && !isAdmin { + b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { + continue + } + maxChatID, linked := b.repo.GetMaxChat(msg.Chat.ID) + if !linked { + b.tg.SendMessage(ctx, msg.Chat.ID, "Этот чат не связан (пауза — для связки групп).", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + pause := text == "/pause" + if err := b.repo.SetPairPaused(msg.Chat.ID, maxChatID, pause); err != nil { + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось изменить паузу.", &SendOpts{ThreadID: msg.MessageThreadID}) + continue + } + if pause { + b.tg.SendMessage(ctx, msg.Chat.ID, "⏸ Связка на паузе — сообщения не зеркалятся (в обе стороны). Возобновить: /unpause", &SendOpts{ThreadID: msg.MessageThreadID}) + } else { + b.tg.SendMessage(ctx, msg.Chat.ID, "▶️ Связка возобновлена — пересылка снова работает.", &SendOpts{ThreadID: msg.MessageThreadID}) + } + continue + } + // /thread_bridge — связать конкретный тред с отдельным MAX-чатом if text == "/thread_bridge" { if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { @@ -519,12 +540,8 @@ func (b *Bridge) listenTelegram(ctx context.Context) { b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID}) continue } - if msg.MessageThreadID == 0 { - b.tg.SendMessage(ctx, msg.Chat.ID, - "Отправьте команду внутри конкретного треда (не в General). Тред будет связан с отдельным MAX-чатом.", - &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) - continue - } + // General-топик форума имеет thread_id=0 — это валидный тред, связываем как + // и любой другой (id=0). Маршрутизация ниже проверяет thread-bridge для всех id. if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok { b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID %d).\n\n/thread_unbridge — разорвать связку.", maxID), @@ -538,7 +555,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { continue } _, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID, - fmt.Sprintf("Ключ для связки этого треда: %s\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте в ней:\n/thread_bridge %s\n\nСсылка на MAX-бота: %s", key, key, b.cfg.MaxBotURL), + fmt.Sprintf("Ключ для связки этого треда: %s\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте в ней:\n/thread_bridge %s\n\nСсылка на MAX-бота: %s%s", key, key, b.cfg.MaxBotURL, b.reserveBotHint()), &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) @@ -556,10 +573,6 @@ func (b *Bridge) listenTelegram(ctx context.Context) { 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 { @@ -571,25 +584,73 @@ func (b *Bridge) listenTelegram(ctx context.Context) { // Группа обсуждения канала: ловим авто-форвард поста (строим маппинг // пост↔тред) и ингестим комменты реальных юзеров в мини-апп (TG→app). if isGroup { - if msg.IsAutomaticForward && msg.ForwardOriginChat != nil && msg.ForwardOriginMsgID != 0 { - if err := b.repo.SaveDiscussionMessage(msg.ForwardOriginChat.ID, msg.ForwardOriginMsgID, msg.Chat.ID, msg.MessageID); err != nil { - slog.Warn("save discussion map failed", "err", err) + // Вступления — капча/анти-рейд (аддон сам решает по конфигу). + if len(msg.NewChatMembers) > 0 { + for _, nm := range msg.NewChatMembers { + if !b.isSelfTgBot(&nm) { + b.memberJoined(ctx, "tg", msg.Chat.ID, nm.ID, strings.TrimSpace(nm.FirstName+" "+nm.LastName), nm.UserName, nm.IsBot) + } } - continue // авто-форвард не зеркалим и не модерируем + // Удаление служебного «вошёл», если включено владельцем (после капчи/рейда). + if b.delServiceMsgs("tg", msg.Chat.ID) { + b.tg.DeleteMessage(ctx, msg.Chat.ID, msg.MessageID) + } + continue // служебное сообщение о вступлении — дальше не обрабатываем + } + // Прочие служебные сообщения (вышел/смена названия/фото/закреп). Удаляем, + // если включено — здесь, внутри isGroup, чтобы работало и в standalone-группах + // (ниже стоит `if !linked continue`, который их бы не пропустил к общему хендлеру). + if msg.IsService { + if b.delServiceMsgs("tg", msg.Chat.ID) { + b.tg.DeleteMessage(ctx, msg.Chat.ID, msg.MessageID) + } + continue + } + if msg.IsAutomaticForward { + // Авто-форвард поста канала в группу обсуждения (корень треда) — НИКОГДА + // не модерируем и не зеркалим. Маппинг пост↔тред сохраняем, только если + // есть данные origin (иначе просто пропускаем — это контент канала, не спам). + if msg.ForwardOriginChat != nil && msg.ForwardOriginMsgID != 0 { + if err := b.repo.SaveDiscussionMessage(msg.ForwardOriginChat.ID, msg.ForwardOriginMsgID, msg.Chat.ID, msg.MessageID); err != nil { + slog.Warn("save discussion map failed", "err", err) + } + } + continue } // Антиспам/команда — ДО ингеста коммента. Иначе в группе обсуждения канала // /antispam и модерация комментов не срабатывают (сообщение уходит в continue ниже). - if msg.From != nil && !msg.IsService { + // SenderChat != nil — сообщение «от имени канала» (пост связанного канала, + // репост канала): это контент канала, а не юзер-спам — не модерируем. + if msg.From != nil && !msg.IsService && msg.SenderChat == nil { mtext := msg.Text if mtext == "" { mtext = msg.Caption } + hasLink := tgMsgHasLink(msg) + // Спам через цитату чужого канала (external_reply): чистый текст — + // наживка, пейлоад в цитате. Подмешиваем текст цитаты в анализ; сам + // факт external_reply — отдельный сильный сигнал (ExtReply). + if msg.HasExternalReply { + mtext = strings.TrimSpace(mtext + " " + msg.ExternalReplyText) + } if b.moderateGroupMessage(ctx, GroupMessage{ Platform: "tg", ChatID: msg.Chat.ID, UserID: msg.From.ID, - TgMsgID: msg.MessageID, Text: mtext, HasLink: tgMsgHasLink(msg), IsAdmin: isAdmin, + TgMsgID: msg.MessageID, Text: mtext, HasLink: hasLink, IsAdmin: isAdmin, + ExtReply: msg.HasExternalReply, }) { continue } + // Модерация картинки (QR + vision). Качаем байты только если антиспам + // в чате включён (иначе не трогаем фото впустую). + if len(msg.Photo) > 0 && b.wantImageCheck("tg", msg.Chat.ID) { + if data, mime, err := b.tgPhotoBytes(ctx, msg.Photo); err == nil && len(data) > 0 { + if b.moderateImage(ctx, "tg", msg.Chat.ID, msg.From.ID, msg.MessageID, "", data, mime, isAdmin) { + continue + } + } else if err != nil { + slog.Warn("antispam: photo download failed", "err", err, "chat", msg.Chat.ID) + } + } } if msg.MessageThreadID != 0 && msg.From != nil && !b.isSelfTgBot(msg.From) && !msg.IsService { chCh, chMsg, ok := b.repo.LookupChannelByDiscussion(msg.Chat.ID, msg.MessageThreadID) @@ -615,12 +676,11 @@ func (b *Bridge) listenTelegram(ctx context.Context) { // (антиспам-модерация и /antispam уже выполнены выше — до ингеста комментов обсуждения) - // Пересылка. Приоритет у thread-bridge: если сообщение в связанном треде — шлём в его MAX-чат. + // Пересылка. Приоритет у thread-bridge (вкл. General, thread_id=0): если этот + // тред связан отдельно — шлём в его MAX-чат, иначе фоллбэк на групповую связку. var maxChatID int64 var linked bool - if msg.MessageThreadID != 0 { - maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID) - } + maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID) if !linked { maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID) } @@ -665,7 +725,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { continue } - go b.forwardTgToMax(ctx, msg, maxChatID, caption, false) + go b.forwardTgToMax(ctx, msg, maxChatID, caption, false, false) } } } @@ -702,7 +762,7 @@ func (b *Bridge) alreadyDeliveredToMax(tgChatID int64, tgMsgID int) bool { return ok && mid != "" } -func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string, isCrosspost bool) { +func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string, isCrosspost bool, bypassScreen bool) { // Дедуп: если это сообщение уже доставлено в MAX — не отправляем повторно. if b.alreadyDeliveredToMax(msg.Chat.ID, msg.MessageID) { slog.Info("skip duplicate TG→MAX", "tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "crosspost", isCrosspost) @@ -711,6 +771,44 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i if b.cbBlocked(maxChatID) { return } + // Пауза связки — временно не пересылаем (связку не удаляем). Возобновить: /unpause. + if isCrosspost { + if b.repo.CrosspostPaused(maxChatID) { + return + } + } else if b.repo.PairPaused(msg.Chat.ID, maxChatID) { + return + } + + // Дуал-бот: выбираем бота для этого чата один раз и пробрасываем токен в ctx — + // все аплоады/отправки этого релея пойдут одним и тем же ботом (тем, кто в чате). + ctx = b.withMaxToken(ctx, b.maxTokenFor(ctx, maxChatID)) + mc := b.maxClientFor(ctx, maxChatID) // SDK-клиент бота этого чата (дуал) + + // Relay-гейт ТОЛЬКО для бридж-ГРУПП: там бот постит сообщение от своего имени и + // получает страйк за спам/запрещёнку. Каналы (даже связанные через /bridge) и + // кросспосты НЕ трогаем — там бот не отправитель. Проверка синхронная, до доставки. + if !isCrosspost && !bypassScreen && isTgGroup(msg.Chat.Type) { + // Скриним ТОЛЬКО тело сообщения, без форматированного caption (он содержит имя + // автора). Вычурные ники (псевдо-кириллица, эмодзи) травили mixed-script/emoji-junk + // и блокировали легит-сообщения — имя автора к контенту отношения не имеет. + screenText := msg.Text + if msg.Caption != "" { + if screenText != "" { + screenText += "\n" + } + screenText += msg.Caption + } + if block, reason := b.screenRelay(ctx, "tg", msg.Chat.ID, tgUserID(msg), screenText); block { + slog.Warn("relay blocked TG→MAX (group): prohibited/spam", + "tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "maxChat", maxChatID, "reason", reason) + // Не дропаем тихо: кладём в очередь модерации (карточка с кнопками в чате). + // payload — сериализованное сообщение для ре-релея на «Пропустить» (file_id внутри). + payload, _ := json.Marshal(msg) + b.holdForModeration(ctx, "tg", msg.Chat.ID, strconv.Itoa(msg.MessageID), maxChatID, tgUserID(msg), tgName(msg), screenText, caption, reason, string(payload)) + return + } + } uid := tgUserID(msg) @@ -758,6 +856,9 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i rawText = msg.Text } mdText := tgEntitiesToHTML(rawText, msg.CaptionEntities) + if fl := tgForwardLine(msg); fl != "" { + mdText = fl + mdText + } name := tgName(msg) if b.hasPrefix("tg", msg.Chat.ID) { name = "[TG] " + name @@ -784,7 +885,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i return } } else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil { - if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { + if uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { m.AddPhoto(uploaded) } else { slog.Error("TG→MAX photo upload failed", "err", err) @@ -802,10 +903,12 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i } } slog.Info("TG→MAX sending photo", "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) - result, err := b.maxApi.Messages.SendWithResult(ctx, m) + result, err := mc.Messages.SendWithResult(ctx, m) if err != nil { slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) - if b.cbFail(maxChatID) { + if isChatUnavailable(err.Error()) { + b.cbPermanentFail(ctx, maxChatID) // бот недоступен в чате → связка на паузу + DM + } else if b.cbFail(maxChatID) { b.notifyTgUser(ctx, msg, maxChatID, fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost) } @@ -849,7 +952,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i } else { // Обычный стикер WebP → отправляем как фото if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil { - if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { + if uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption) m.AddPhoto(uploaded) if msg.ReplyToMessage != nil { @@ -861,7 +964,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i m.AddKeyboard(kb) } slog.Info("TG→MAX sending sticker as photo", "uid", uid, "tgChat", msg.Chat.ID) - result, err := b.maxApi.Messages.SendWithResult(ctx, m) + result, err := mc.Messages.SendWithResult(ctx, m) if err != nil { slog.Error("TG→MAX sticker send failed", "err", err) b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost) @@ -1012,6 +1115,15 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i mdText = tgEntitiesToHTML(rawText, entities) } + // Контакт (sharing телефона) — текста нет, иначе зеркало пустое. + if mdText == "" && msg.Contact != nil { + mdText = tgContactText(msg) + } + // Метка репоста: «↪️ Переслано из X» перед телом. + if fl := tgForwardLine(msg); fl != "" { + mdText = fl + mdText + } + // Fallback: медиа было, но не загрузилось (либо не хватило места, либо Bot API // не смог его скачать). Подмешиваем маркер типа, чтобы хотя бы текст прошёл. hasMedia := msg.Video != nil || msg.VideoNote != nil || msg.Document != nil || @@ -1088,11 +1200,19 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i if sendErr != nil { errStr := sendErr.Error() slog.Error("TG→MAX send failed", "err", errStr, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) + b.noteMaxSendErr(errStr) // глобальный бан аккаунта MAX → глушим уведомления + // chat.not.found / chat.denied — перманентно: бота нет в MAX-группе или он не + // админ. НЕ ретраим и сообщаем владельцу ТОЧНО (а не «MAX недоступен/в очереди»). + notInChat := strings.Contains(errStr, "chat.not.found") || strings.Contains(errStr, "chat.denied") // 403/404 — permanent error, не ретраим if !strings.Contains(errStr, "403") && !strings.Contains(errStr, "404") && !strings.Contains(errStr, "chat.denied") { b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format) } - if b.cbFail(maxChatID) { + if notInChat { + // Бот недоступен в MAX-чате (удалён/не админ) — ставим связку на паузу + // и DM-им владельцу (один раз, по переходу unpaused→paused). + b.cbPermanentFail(ctx, maxChatID) + } else if b.cbFail(maxChatID) { b.notifyTgUser(ctx, msg, maxChatID, "MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost) } @@ -1219,7 +1339,7 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID } // Стикеры/VideoNote редактирование подписи не поддерживают со стороны TG, поэтому пропускаем. - if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil { + if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil { slog.Error("TG→MAX edit media failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxMsgID", maxMsgID) } else { slog.Info("TG→MAX edited media", "mid", maxMsgID, "uid", uid, "tgChat", msg.Chat.ID) @@ -1259,6 +1379,14 @@ func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *TGMessage) { return } + // Идемпотентность: этот пост канала уже кросспостился (повторная доставка вебхука / + // дубль-строка связки / перекрытие) — атомарный claim по (канал, msg_id) в БД. + // Уже застолблён → не кросспостим повторно. Старые записи чистятся по TTL. + if !b.repo.ClaimCrosspost("tg", msg.Chat.ID, strconv.Itoa(msg.MessageID)) { + slog.Info("skip duplicate crosspost TG→MAX (already claimed)", "tgChannel", msg.Chat.ID, "tgMsg", msg.MessageID, "maxChat", maxChatID) + return + } + go b.publishTgCrosspost(ctx, msg, maxChatID) } @@ -1295,7 +1423,7 @@ func (b *Bridge) publishTgCrosspost(ctx context.Context, msg *TGMessage, maxChat return } - b.forwardTgToMax(ctx, msg, maxChatID, caption, true) + b.forwardTgToMax(ctx, msg, maxChatID, caption, true, false) } // handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management). @@ -1316,6 +1444,11 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *TGCallback) { } } + // Навигация по меню /start (help:*) — редактируем то же сообщение. + if b.handleHelpMenuCallback(ctx, query, data) { + return + } + // cpd:dir:maxChatID — change direction if strings.HasPrefix(data, "cpd:") { parts := strings.SplitN(data, ":", 3) @@ -1694,7 +1827,7 @@ func (b *Bridge) handleTgEditedChannelPost(ctx context.Context, edited *TGMessag } m := maxbot.NewMessage().SetChat(maxChatID).SetText(text) - if err := b.maxApi.Messages.EditMessage(ctx, maxMsgID, m); err != nil { + if err := b.maxClientFor(ctx, maxChatID).Messages.EditMessage(ctx, maxMsgID, m); err != nil { slog.Error("TG→MAX crosspost edit failed", "err", err) } else { slog.Info("TG→MAX crosspost edited", "mid", maxMsgID) diff --git a/tgsender.go b/tgsender.go index e91b1e5..768812c 100644 --- a/tgsender.go +++ b/tgsender.go @@ -26,6 +26,14 @@ type PhotoSize struct { FileSize int } +// ContactInfo — контакт (sharing телефона). +type ContactInfo struct { + PhoneNumber string + FirstName string + LastName string + UserID int64 +} + type FileInfo struct { FileID string FileName string @@ -77,6 +85,7 @@ type TGMessage struct { MediaGroupID string ReplyToMessage *TGMessage ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin + ForwardFrom string // отображаемое имя источника форварда (канал/группа/юзер/скрытый), "" если не форвард ForwardOriginMsgID int // msg_id оригинала в канале-источнике (для старта импорта с поста) ForwardOriginDate int // дата оригинала (unix) — у постов одного альбома совпадает MigrateToChatID int64 @@ -88,6 +97,15 @@ type TGMessage struct { // IsAutomaticForward — авто-форвард поста канала в его группу обсуждения // (Telegram ставит is_automatic_forward). По нему строим маппинг пост↔тред. IsAutomaticForward bool + // HasExternalReply — сообщение цитирует пост из ДРУГОГО чата/канала (external_reply). + // Частый приём спама: чистая наживка + цитата скам-канала со ссылкой (текст чистый, + // пейлоад в цитате). ExternalReplyText — текст цитаты + название источника + ссылка. + HasExternalReply bool + ExternalReplyText string + // NewChatMembers — вступившие в группу (для капчи/анти-рейда). + NewChatMembers []UserInfo + // Contact — пересланный/отправленный контакт (телефон). Без текста — иначе зеркало пустое. + Contact *ContactInfo // ChatShared — пользователь выбрал чат через нативную кнопку KeyboardButtonRequestChat // (флоу выбора группы из лички). Содержит chat_id выбранной группы. ChatShared *ChatSharedInfo @@ -230,8 +248,12 @@ type TGSender interface { GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error) // RestrictChatMember мутит участника (запрет писать) до untilUnix (0 = навсегда). RestrictChatMember(ctx context.Context, chatID, userID int64, untilUnix int) error + // UnrestrictChatMember снимает мут (возвращает базовые права писать) — для капчи. + UnrestrictChatMember(ctx context.Context, chatID, userID int64) error // BanChatMember банит участника и удаляет его сообщения. BanChatMember(ctx context.Context, chatID, userID int64) error + // KickChatMember выгоняет участника с возможностью вернуться (ban+unban) — для капчи-таймаута. + KickChatMember(ctx context.Context, chatID, userID int64) error SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error SetMyDescription(ctx context.Context, description string) error SetMenuButtonWebApp(ctx context.Context, text, url string) error diff --git a/tgsender_impl.go b/tgsender_impl.go index 14eae86..96a8891 100644 --- a/tgsender_impl.go +++ b/tgsender_impl.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "strings" + "time" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" @@ -93,7 +94,12 @@ func (s *tgBotSender) SendMessage(ctx context.Context, chatID int64, text string Text: text, } applySendMessageOpts(p, opts) - msg, err := s.b.SendMessage(ctx, p) + var msg *models.Message + err := retryOn429(ctx, func() error { + var e error + msg, e = s.b.SendMessage(ctx, p) + return e + }) if err != nil { return 0, wrapErr(err) } @@ -153,14 +159,7 @@ func (s *tgBotSender) SendDocument(ctx context.Context, chatID int64, file FileA } func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) { - items := make([]models.InputMedia, 0, len(media)) - for _, m := range media { - items = append(items, toLibInputMedia(m)) - } - p := &bot.SendMediaGroupParams{ - ChatID: chatID, - Media: items, - } + p := &bot.SendMediaGroupParams{ChatID: chatID} if opts != nil { if opts.ThreadID != 0 { p.MessageThreadID = opts.ThreadID @@ -169,7 +168,24 @@ func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media [] p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} } } - msgs, err := s.b.SendMediaGroup(ctx, p) + var msgs []*models.Message + err := retryOn429(ctx, func() error { + // items пересобираем на каждой попытке: у upload-байтов io.Reader одноразовый. + items := make([]models.InputMedia, 0, len(media)) + for i, m := range media { + // Уникальное имя attach:// на элемент. Иначе (одинаковое/пустое имя у всех + // байтовых аплоадов, напр. фото с MAX-CDN) multipart-парты схлопываются и + // ВЕСЬ альбом становится первым фото. Только для upload (у URL/file_id имя не нужно). + if len(m.File.Bytes) > 0 { + m.File.Name = fmt.Sprintf("m%d_%s", i, m.File.Name) + } + items = append(items, toLibInputMedia(m)) + } + p.Media = items + var e error + msgs, e = s.b.SendMediaGroup(ctx, p) + return e + }) if err != nil { return nil, wrapErr(err) } @@ -274,6 +290,28 @@ func (s *tgBotSender) RestrictChatMember(ctx context.Context, chatID, userID int return wrapErr(err) } +// UnrestrictChatMember возвращает базовые права писать (снятие мута капчи). +func (s *tgBotSender) UnrestrictChatMember(ctx context.Context, chatID, userID int64) error { + _, err := s.b.RestrictChatMember(ctx, &bot.RestrictChatMemberParams{ + ChatID: chatID, + UserID: userID, + Permissions: &models.ChatPermissions{ + CanSendMessages: true, + CanSendAudios: true, + CanSendDocuments: true, + CanSendPhotos: true, + CanSendVideos: true, + CanSendVideoNotes: true, + CanSendVoiceNotes: true, + CanSendPolls: true, + CanSendOtherMessages: true, + CanAddWebPagePreviews: true, + CanInviteUsers: true, + }, + }) + return wrapErr(err) +} + func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) error { _, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{ ChatID: chatID, @@ -283,6 +321,19 @@ func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) e return wrapErr(err) } +// KickChatMember — выгнать с возможностью вернуться (ban + сразу unban). +func (s *tgBotSender) KickChatMember(ctx context.Context, chatID, userID int64) error { + if _, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{ + ChatID: chatID, UserID: userID, RevokeMessages: true, + }); err != nil { + return wrapErr(err) + } + _, err := s.b.UnbanChatMember(ctx, &bot.UnbanChatMemberParams{ + ChatID: chatID, UserID: userID, OnlyIfBanned: true, + }) + return wrapErr(err) +} + func (s *tgBotSender) SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error { cmds := make([]models.BotCommand, len(commands)) for i, c := range commands { @@ -533,6 +584,36 @@ func applySendDocumentOpts(p *bot.SendDocumentParams, opts *SendOpts) { // --- Error wrapping --- +// retryOn429 выполняет send, повторяя при 429 с учётом retry_after (Telegram при +// массовом постинге лимитит ~20 сообщений/мин на чат). send пересобирает параметры +// внутри себя, если в них есть одноразовые io.Reader (upload-байты media group). +func retryOn429(ctx context.Context, send func() error) error { + const maxTries = 4 + for attempt := 0; ; attempt++ { + err := send() + if err == nil { + return nil + } + var tmr *bot.TooManyRequestsError + if !errors.As(err, &tmr) || attempt >= maxTries { + return err + } + wait := time.Duration(tmr.RetryAfter)*time.Second + 300*time.Millisecond + if wait <= 0 { + wait = time.Second + } + if wait > 30*time.Second { + wait = 30 * time.Second + } + slog.Warn("TG 429 rate-limit, backing off", "retryAfter", tmr.RetryAfter, "attempt", attempt+1) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + } +} + func wrapErr(err error) error { if err == nil { return nil @@ -640,6 +721,21 @@ func convertMsg(m *models.Message) *TGMessage { msg.ForwardOriginDate = m.ForwardOrigin.MessageOriginChannel.Date } + // Имя источника форварда (для метки «Переслано из X») — все типы origin. + if m.ForwardOrigin != nil { + switch { + case m.ForwardOrigin.MessageOriginChannel != nil: + msg.ForwardFrom = m.ForwardOrigin.MessageOriginChannel.Chat.Title + case m.ForwardOrigin.MessageOriginChat != nil: + msg.ForwardFrom = m.ForwardOrigin.MessageOriginChat.SenderChat.Title + case m.ForwardOrigin.MessageOriginUser != nil: + u := m.ForwardOrigin.MessageOriginUser.SenderUser + msg.ForwardFrom = strings.TrimSpace(u.FirstName + " " + u.LastName) + case m.ForwardOrigin.MessageOriginHiddenUser != nil: + msg.ForwardFrom = m.ForwardOrigin.MessageOriginHiddenUser.SenderUserName + } + } + // Photo for _, p := range m.Photo { msg.Photo = append(msg.Photo, PhotoSize{ @@ -674,6 +770,44 @@ func convertMsg(m *models.Message) *TGMessage { msg.ReplyToMessage = convertMsg(m.ReplyToMessage) } + for _, u := range m.NewChatMembers { + msg.NewChatMembers = append(msg.NewChatMembers, UserInfo{ + ID: u.ID, IsBot: u.IsBot, UserName: u.Username, FirstName: u.FirstName, LastName: u.LastName, + }) + } + + if m.Contact != nil { + msg.Contact = &ContactInfo{ + PhoneNumber: m.Contact.PhoneNumber, + FirstName: m.Contact.FirstName, + LastName: m.Contact.LastName, + UserID: m.Contact.UserID, + } + } + + // Цитата на сообщение из другого чата/канала (external_reply) — приём спама. + // Собираем пейлоад (цитата + название источника + ссылка) для антиспама. + if m.Quote != nil { + msg.ExternalReplyText = m.Quote.Text + } + if m.ExternalReply != nil { + msg.HasExternalReply = true + parts := []string{msg.ExternalReplyText} + if m.ExternalReply.Chat != nil && m.ExternalReply.Chat.Title != "" { + parts = append(parts, m.ExternalReply.Chat.Title) + } + if ch := m.ExternalReply.Origin.MessageOriginChannel; ch != nil { + parts = append(parts, ch.Chat.Title) + if ch.AuthorSignature != nil { + parts = append(parts, *ch.AuthorSignature) + } + } + if lp := m.ExternalReply.LinkPreviewOptions; lp != nil && lp.URL != nil { + parts = append(parts, *lp.URL) + } + msg.ExternalReplyText = strings.TrimSpace(strings.Join(parts, " ")) + } + for _, e := range m.Entities { msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL}) } diff --git a/upload.go b/upload.go index 65179fe..7f28ae5 100644 --- a/upload.go +++ b/upload.go @@ -90,7 +90,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up if err != nil { return nil, fmt.Errorf("create request: %w", err) } - req.Header.Set("Authorization", b.cfg.MaxToken) + req.Header.Set("Authorization", b.maxTokenCtxOr(ctx)) // дуал-бот: токен релея (ctx) или основной resp, err := b.apiClient.Do(req) if err != nil { @@ -180,6 +180,39 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up return nil, fmt.Errorf("no token in CDN response: %s", string(cdnBody)) } +// tgPhotoBytes скачивает байты самого крупного варианта фото (для модерации картинки — +// декод QR и vision). Возвращает данные + mime. Берём последний PhotoSize (наибольший). +func (b *Bridge) tgPhotoBytes(ctx context.Context, photos []PhotoSize) ([]byte, string, error) { + if len(photos) == 0 { + return nil, "", nil + } + // Жёсткий таймаут: модерация в последовательном цикле апдейтов — нельзя залипнуть + // (обычный downloadURL живёт 5 мин). 10с хватает на фото, при зависании не стопаем бот. + dctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + fileURL, err := b.tgFileURL(dctx, photos[len(photos)-1].FileID) + if err != nil { + return nil, "", fmt.Errorf("tg getFileURL: %w", err) + } + req, err := http.NewRequestWithContext(dctx, http.MethodGet, fileURL, nil) + if err != nil { + return nil, "", err + } + resp, err := b.apiClient.Do(req) // apiClient: 15с таймаут (а не 5 мин) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, "", fmt.Errorf("tg download status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) // лимит 16MB + if err != nil { + return nil, "", err + } + return data, "image/jpeg", nil +} + // uploadTgPhotoToMax скачивает фото из TG и загружает в MAX (возвращает PhotoTokens). // Загрузка — в обход SDK (наш customUploadPhotoToMax): SDK добавляет к /uploads // устаревший параметр v=1.2.5, из-за которого MAX отдаёт 404 (как было с видео). @@ -213,7 +246,7 @@ func (b *Bridge) customUploadPhotoToMax(ctx context.Context, reader io.Reader, f if err != nil { return nil, fmt.Errorf("create request: %w", err) } - req.Header.Set("Authorization", b.cfg.MaxToken) + req.Header.Set("Authorization", b.maxTokenCtxOr(ctx)) // дуал-бот: токен релея (ctx) или основной resp, err := b.apiClient.Do(req) if err != nil { return nil, fmt.Errorf("get upload url: %w", err) @@ -448,9 +481,14 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at } } + // Токен отправки фиксируем до цикла: дуал-бот: ctx-токен релея или по членству. + sendTok := b.maxTokenForSend(ctx, chatID) + triedAlt := false // пробовали ли уже второго бота (fallback при chat.not.found) + flipped := false // только что переключили бота — повторяем без задержки + // Retry при attachment.not.ready (файл ещё обрабатывается) for attempt := 0; attempt < 20; attempt++ { - if attempt > 0 { + if attempt > 0 && !flipped { delay := time.Duration(3+attempt*2) * time.Second select { case <-ctx.Done(): @@ -459,12 +497,13 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at } slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 20) } + flipped = false req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) if err != nil { return "", err } - req.Header.Set("Authorization", b.cfg.MaxToken) + req.Header.Set("Authorization", sendTok) req.Header.Set("Content-Type", "application/json") resp, err := b.apiClient.Do(req) @@ -486,6 +525,7 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at if err := json.Unmarshal(respBody, &result); err != nil { return "", err } + b.markChatBot(chatID, sendTok) // запомним рабочего бота для будущих отправок return result.Message.Body.Mid, nil } @@ -495,7 +535,21 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at continue } - return "", fmt.Errorf("MAX API %d: %s", resp.StatusCode, string(respBody)) + // Дуал-бот fallback: бот не в чате (chat.not.found/chat.denied) — пробуем второго + // бота и чиним кэш роутинга (бота могли добавить позже / первая проверка промахнулась). + bodyStr := string(respBody) + if !triedAlt && (strings.Contains(bodyStr, "chat.not.found") || strings.Contains(bodyStr, "chat.denied")) { + if alt := b.altMaxToken(sendTok); alt != "" && alt != sendTok { + slog.Warn("MAX chat.not.found — пробую другого бота", "maxChat", chatID) + sendTok = alt + triedAlt = true + flipped = true + b.markChatBot(chatID, alt) // future-отправки сразу пойдут рабочим + continue + } + } + + return "", fmt.Errorf("MAX API %d: %s", resp.StatusCode, bodyStr) } return "", fmt.Errorf("MAX attachment not ready after 10 retries") }