crosspost: fix long album caption + strip custom emoji and whitespace padding
Some checks failed
Build / build (push) Has been cancelled

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ajcn7YhgeexPwbfrUNMj8u
This commit is contained in:
Andrey Lugovskoy 2026-07-03 13:00:46 +04:00
parent a7dab2896b
commit f57105d2b6
29 changed files with 2043 additions and 314 deletions

View file

@ -191,7 +191,8 @@ export MAX_TOKEN=your_token
| `DB_PATH` | Путь к SQLite базе | `bridge.db` | | `DB_PATH` | Путь к SQLite базе | `bridge.db` |
| `DATABASE_URL` | DSN для PostgreSQL (если задана — SQLite игнорируется) | — | | `DATABASE_URL` | DSN для PostgreSQL (если задана — SQLite игнорируется) | — |
| `TG_BOT_URL` | Ссылка на TG-бота (показывается в `/help`) | `https://t.me/MaxTelegramBridgeBot` | | `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` | — | | `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` | — | | `TG_WEBHOOK_URL` | Базовый URL для webhook, например `https://bridge.example.com` (если не задан — long polling). Эндпоинты: `/tg-webhook` | — |
| `MAX_WEBHOOK_PORT` | Порт для webhook сервера MAX. Если порт совпадает с Telegram, значит будет общий сервер | `8443` | | `MAX_WEBHOOK_PORT` | Порт для webhook сервера MAX. Если порт совпадает с Telegram, значит будет общий сервер | `8443` |

View file

@ -29,7 +29,19 @@ type Addon interface {
// сигнатура совпадала между main.Addon и приватным пакетом аддона (Go требует // сигнатура совпадала между main.Addon и приватным пакетом аддона (Go требует
// идентичности типов в методах интерфейса). Возвращает true, если сообщение // идентичности типов в методах интерфейса). Возвращает 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 ⇒ // CrosspostAllowed вызывается ПЕРЕД созданием новой связки кросспоста. ok=false ⇒
// ядро не создаёт связку и показывает reason. Generic: ядро не знает семантику решения. // ядро не создаёт связку и показывает reason. Generic: ядро не знает семантику решения.
CrosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (ok bool, reason string) CrosspostAllowed(ctx context.Context, ownerMaxID, ownerTgID int64) (ok bool, reason string)
@ -41,6 +53,21 @@ type Addon interface {
// HandleChatShared — пользователь выбрал чат нативной кнопкой (chat_shared в личке). // HandleChatShared — пользователь выбрал чат нативной кнопкой (chat_shared в личке).
// requestID эхо запроса, sharedChatID — выбранный чат. Generic: ядро не знает семантику. // requestID эхо запроса, sharedChatID — выбранный чат. Generic: ядро не знает семантику.
HandleChatShared(ctx context.Context, userID int64, requestID int, sharedChatID int64, title string) (handled bool) 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 читаемость). // GroupMessage — удобная обёртка для вызова Moderate из ядра (call-site читаемость).
@ -53,6 +80,15 @@ type GroupMessage struct {
Text string Text string
HasLink bool HasLink bool
IsAdmin 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 прогоняет сообщение через аддон-модерацию (если подключён). // moderateGroupMessage прогоняет сообщение через аддон-модерацию (если подключён).
@ -60,7 +96,51 @@ func (b *Bridge) moderateGroupMessage(ctx context.Context, m GroupMessage) bool
if b.addon == nil { if b.addon == nil {
return false 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 — можно ли создать новую связку (решает аддон). Без аддона — да. // 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) 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 — доставлять ли пост связки (решает аддон). Без аддона — да. // crosspostDeliverable — доставлять ли пост связки (решает аддон). Без аддона — да.
func (b *Bridge) crosspostDeliverable(ctx context.Context, maxChatID int64) bool { func (b *Bridge) crosspostDeliverable(ctx context.Context, maxChatID int64) bool {
if b.addon == nil { if b.addon == nil {

35
addon_errs_test.go Normal file
View file

@ -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)
}
}
}

151
breaker_pause_test.go Normal file
View file

@ -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)
}
}
}

216
bridge.go
View file

@ -9,6 +9,7 @@ import (
"os" "os"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
maxbot "github.com/max-messenger/max-bot-api-client-go" maxbot "github.com/max-messenger/max-bot-api-client-go"
@ -16,13 +17,15 @@ import (
// Config — настройки bridge, читаемые из env. // Config — настройки bridge, читаемые из env.
type Config struct { type Config struct {
MaxToken string // токен MAX API (нужен для direct-send/upload) MaxToken string // токен MAX API (основной бот; нужен для direct-send/upload)
TgBotURL string // ссылка на TG-бота для /help MaxTokenOld string // токен старого MAX-бота (резерв/failover; "" — выключено)
MaxBotURL string // ссылка на MAX-бота для /help TgBotURL string // ссылка на TG-бота для /help
MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling) MaxBotURL string // ссылка на основного MAX-бота для /help (старый бот)
MaxWebhookPort string // порт HTTP-сервера для MAX webhook (по умолчанию 8443) MaxBotURLReserve string // ссылка на запасного MAX-бота (для failover; "" — скрыть)
TgWebhookURL string // базовый URL для webhook TG (если пусто — long polling) MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling)
TgWebhookPort string // порт HTTP-сервера для TG webhook (по умолчанию 8444) 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) TgAPIURL string // custom TG Bot API URL (если пусто — api.telegram.org)
AllowedUsers []int64 // whitelist TG user IDs (empty = allow all) AllowedUsers []int64 // whitelist TG user IDs (empty = allow all)
TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited) TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited)
@ -40,25 +43,36 @@ type Config struct {
// chatBreaker хранит состояние circuit breaker для одного чата. // chatBreaker хранит состояние circuit breaker для одного чата.
type chatBreaker struct { type chatBreaker struct {
fails int fails int
blockedAt time.Time blockedAt time.Time
} }
const ( const (
cbMaxFails = 3 // после N фейлов — блокируем cbMaxFails = 3 // после N фейлов — блокируем
cbCooldown = 5 * time.Minute // на сколько блокируем cbCooldown = 5 * time.Minute // на сколько блокируем
) )
// Bridge — основная структура, объединяющая зависимости. // Bridge — основная структура, объединяющая зависимости.
type Bridge struct { type Bridge struct {
cfg Config cfg Config
repo Repository repo Repository
tg TGSender tg TGSender
maxApi *maxbot.Api maxApi *maxbot.Api
maxBotUID int64 // MAX bot user ID (для фильтрации своих сообщений) 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 // для скачивания/загрузки файлов (большой таймаут) httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут)
apiClient *http.Client // для коротких API-запросов (малый таймаут) apiClient *http.Client // для коротких API-запросов (малый таймаут)
whSecret string // random path segment for webhook URLs whSecret string // random path segment for webhook URLs
cpWaitMu sync.Mutex cpWaitMu sync.Mutex
cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки) cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки)
@ -66,8 +80,14 @@ type Bridge struct {
cpTgOwnerMu sync.Mutex cpTgOwnerMu sync.Mutex
cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост) cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост)
cbMu sync.Mutex cbMu sync.Mutex
breakers map[int64]*chatBreaker // destination chatID → breaker breakers map[int64]*chatBreaker // destination chatID → breaker
// maxBlockedUntil — unix-секунды, до которых считаем аккаунт MAX заблокированным
// (MAX вернул 403 account.blocked). Пока активно — глушим пользовательские
// уведомления о недоставке: это глобальный бан, слать каждому бессмысленно.
// Сбрасывается на первой успешной отправке в MAX.
maxBlockedUntil atomic.Int64
// Буферизация TG media groups (альбомы) // Буферизация TG media groups (альбомы)
mgMu sync.Mutex mgMu sync.Mutex
@ -86,28 +106,32 @@ type Bridge struct {
} }
// NewBridge создаёт экземпляр Bridge. // 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) // Derive webhook secret from tokens (stable across restarts)
h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken())) h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken()))
secret := hex.EncodeToString(h[:8]) secret := hex.EncodeToString(h[:8])
b := &Bridge{ b := &Bridge{
cfg: cfg, cfg: cfg,
repo: repo, repo: repo,
tg: tg, tg: tg,
maxApi: maxApi, maxApi: maxApi,
maxApiOld: maxApiOld,
maxBotUID: maxBotUID, maxBotUID: maxBotUID,
maxOldUID: maxOldUID,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 5 * time.Minute, // для download/upload больших файлов Timeout: 5 * time.Minute, // для download/upload больших файлов
}, },
apiClient: &http.Client{ apiClient: &http.Client{
Timeout: 15 * time.Second, // для коротких API-запросов Timeout: 15 * time.Second, // для коротких API-запросов
}, },
whSecret: secret, whSecret: secret,
cpWait: make(map[int64]int64), cpWait: make(map[int64]int64),
cpTgOwner: make(map[int64]int64), cpTgOwner: make(map[int64]int64),
breakers: make(map[int64]*chatBreaker), breakers: make(map[int64]*chatBreaker),
mgBuffers: make(map[string]*mediaGroupBuffer), mgBuffers: make(map[string]*mediaGroupBuffer),
maxBotCache: make(map[int64]string),
maxSeenMid: make(map[string]int64),
} }
b.addon = loadAddon(b) b.addon = loadAddon(b)
return b return b
@ -149,11 +173,129 @@ func (b *Bridge) cbFail(chatID int64) bool {
return false return false
} }
// cbSuccess сбрасывает счётчик ошибок для чата. // cbSuccess сбрасывает счётчик ошибок для чата. Любая успешная отправка в MAX также
// снимает глобальный флаг «аккаунт заблокирован» (бан, видимо, сняли).
func (b *Bridge) cbSuccess(chatID int64) { func (b *Bridge) cbSuccess(chatID int64) {
b.cbMu.Lock() b.cbMu.Lock()
defer b.cbMu.Unlock()
delete(b.breakers, chatID) 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). // 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), чтобы не мусорить в канал. // Для crosspost — в ЛС владельцу связки (tg_owner_id), чтобы не мусорить в канал.
// Если владелец не задан (legacy) — уведомление дропается с warn-логом. // Если владелец не задан (legacy) — уведомление дропается с warn-логом.
func (b *Bridge) notifyTgUser(ctx context.Context, srcChat *TGMessage, maxChatID int64, text string, isCrosspost bool) { 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 { if isCrosspost {
_, tgOwner := b.repo.GetCrosspostOwner(maxChatID) _, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
if tgOwner == 0 { if tgOwner == 0 {
@ -296,6 +444,8 @@ func (b *Bridge) registerCommands(ctx context.Context) {
cmds := []BotCommand{ cmds := []BotCommand{
{Command: "bridge", Description: "Связать чат с MAX-чатом"}, {Command: "bridge", Description: "Связать чат с MAX-чатом"},
{Command: "bridge_update", Description: "Обновить связку группы (показать в кабинете)"}, {Command: "bridge_update", Description: "Обновить связку группы (показать в кабинете)"},
{Command: "pause", Description: "Поставить связку на паузу"},
{Command: "unpause", Description: "Возобновить пересылку"},
{Command: "unbridge", Description: "Удалить связку чатов"}, {Command: "unbridge", Description: "Удалить связку чатов"},
{Command: "thread", Description: "Установить топик для сообщений из MAX"}, {Command: "thread", Description: "Установить топик для сообщений из MAX"},
{Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"}, {Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"},
@ -315,7 +465,8 @@ func (b *Bridge) registerCommands(ctx context.Context) {
// Описание бота (экран "Что умеет этот бот"). Доп. строка от расширения (impl // Описание бота (экран "Что умеет этот бот"). Доп. строка от расширения (impl
// в loadAddon) — без аддона пусто, в публичной сборке про импорт ни слова. // в 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 { if err := b.tg.SetMyDescription(ctx, desc); err != nil {
slog.Error("TG setMyDescription failed", "err", err) 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'ы + периодическую очистку. // Run запускает TG и MAX listener'ы + периодическую очистку.
func (b *Bridge) Run(ctx context.Context) { func (b *Bridge) Run(ctx context.Context) {
b.registerCommands(ctx) b.registerCommands(ctx)

62
docs/pikabu-post.md Normal file
View file

@ -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'ов. Но работает!

163
dualbot.go Normal file
View file

@ -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))
}

View file

@ -2,6 +2,7 @@ package main
import ( import (
"html" "html"
"regexp"
"strings" "strings"
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" 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 return bold + ": " + text
} }
// tgForwardLine — метка «↪️ Переслано из X\n» (HTML, italic) для зеркалируемых форвардов.
// "" если сообщение не форвард. Подмешивается в начало тела (после атрибуции автора).
func tgForwardLine(msg *TGMessage) string {
if msg.ForwardFrom == "" {
return ""
}
return "↪️ <i>Переслано из " + html.EscapeString(msg.ForwardFrom) + "</i>\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) // formatTgCaption — для пересылки (текст или caption)
func formatTgCaption(msg *TGMessage, prefix, newline bool) string { func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
name := tgName(msg) name := tgName(msg)
@ -55,6 +78,9 @@ func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
if text == "" { if text == "" {
text = msg.Caption text = msg.Caption
} }
if text == "" {
text = tgContactText(msg)
}
if prefix { if prefix {
return formatAttribution("[TG] "+name, text, newline) return formatAttribution("[TG] "+name, text, newline)
} }
@ -68,6 +94,9 @@ func formatTgMessage(msg *TGMessage, prefix, newline bool) string {
if text == "" { if text == "" {
text = msg.Caption text = msg.Caption
} }
if text == "" {
text = tgContactText(msg)
}
if text == "" { if text == "" {
return "" return ""
} }
@ -106,12 +135,33 @@ func formatTgCrosspostCaption(msg *TGMessage) string {
text = msg.Caption text = msg.Caption
entities = msg.CaptionEntities entities = msg.CaptionEntities
} }
return tgEntitiesToHTML(text, entities) return collapseWhitespace(tgEntitiesToHTML(text, entities))
} }
// formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов) // formatMaxCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)
func formatMaxCrosspostCaption(upd *maxschemes.MessageCreatedUpdate) string { 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-типа, если оригинальное имя отсутствует. // mimeToFilename генерирует имя файла из MIME-типа, если оригинальное имя отсутствует.

163
helpmenu.go Normal file
View file

@ -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 := "🔗 <b>Мост между Telegram и MAX</b>\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 "📋 <b>Как связать группы</b>\n\n" +
"1. Добавьте бота в обе группы:\n" +
" • Telegram — кнопка «➕ В группу Telegram»\n" +
" • MAX — " + b.cfg.MaxBotURL + "\n" +
" (если поиск в MAX не находит по полному имени — введите имя <b>без суффикса</b> <code>_bot</code>)\n" +
"2. В MAX сделайте бота администратором группы <b>с правом «Доступ к сообщениям»</b> " +
"(читать все сообщения). Без этого права бот не видит команды и сообщения — связка не сработает.\n" +
"3. В Telegram — администратором, только если группа с темами (форум). " +
"В обычных группах не требуется.\n" +
"4. В одной из групп отправьте <code>/bridge</code> — бот выдаст ключ.\n" +
"5. Отправьте <code>/bridge ключ</code> во второй группе.\n" +
"6. Готово — сообщения зеркалятся в обе стороны." + b.reserveBotHint()
}
func (b *Bridge) helpThreadsText() string {
return "🧵 <b>Темы форума (threads)</b>\n\n" +
"Если TG-группа — форум с темами (topics):\n\n" +
"<code>/thread</code> — выполните <b>внутри нужной темы</b>: сообщения из связанной " +
"MAX-группы будут приходить именно в эту тему.\n\n" +
"<code>/thread_bridge</code> — связать <b>отдельную</b> тему с <b>отдельной</b> MAX-группой: " +
"выполните в теме → бот выдаст ключ → в MAX-группе отправьте <code>/thread_bridge ключ</code>.\n\n" +
"<code>/thread_unbridge</code> — разорвать связку темы.\n\n" +
"⚠️ При обычном <code>/bridge</code> на всю группу в MAX уходят сообщения из ВСЕХ тем. " +
"Чтобы зеркалить только одну тему: <code>/unbridge</code> на группе → <code>/thread_bridge</code> в нужной теме."
}
func (b *Bridge) helpChannelsText() string {
return "📡 <b>Кросспостинг каналов</b>\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 + ") отправьте: <code>/crosspost ID</code>\n" +
"5. Перешлите пост из MAX-канала туда же — готово!\n\n" +
"Управление: <code>/crosspost</code> — список связок с кнопками, либо перешлите " +
"пост из связанного канала." + b.reserveBotHint()
}
func (b *Bridge) helpCmdsText() string {
return "⌨️ <b>Команды</b>\n\n" +
"<code>/bridge</code> — создать ключ для связки групп\n" +
"<code>/bridge ключ</code> — связать эту группу по ключу\n" +
"<code>/bridge prefix on|off</code> — префикс [TG]/[MAX]\n" +
"<code>/pause</code> — поставить связку на паузу (не зеркалить, не удаляя)\n" +
"<code>/unpause</code> — возобновить пересылку\n" +
"<code>/unbridge</code> — удалить связку\n" +
"<code>/thread</code> — направить MAX → текущий топик (форум)\n" +
"<code>/thread_bridge</code> — связать тред с отдельной MAX-группой\n" +
"<code>/thread_unbridge</code> — разорвать связку треда\n" +
"<code>/crosspost</code> — связки каналов и управление"
}
func (b *Bridge) helpFaqText() string {
return "❓ <b>Частые вопросы</b>\n\n" +
"<b>Бот хранит мои сообщения?</b>\n" +
"Нет. Только ID для синхронизации правок, удаляются через 30 дней.\n\n" +
"<b>Что пересылается?</b>\n" +
"Текст, фото, видео, голосовые, документы, альбомы, правки.\n\n" +
"<b>Нужно ли делать бота админом?</b>\n" +
"В MAX — да. В Telegram — только в форум-группах и в каналах (для постинга).\n\n" +
"<b>Сообщения не доходят?</b>\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
}

37
main.go
View file

@ -55,8 +55,10 @@ func main() {
cfg := Config{ cfg := Config{
MaxToken: mustEnv("MAX_TOKEN"), MaxToken: mustEnv("MAX_TOKEN"),
MaxTokenOld: os.Getenv("MAX_TOKEN_OLD"),
TgBotURL: envOr("TG_BOT_URL", "https://t.me/MaxTelegramBridgeBot"), 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"), MaxWebhookURL: os.Getenv("MAX_WEBHOOK_URL"),
MaxWebhookPort: envOr("MAX_WEBHOOK_PORT", "8443"), MaxWebhookPort: envOr("MAX_WEBHOOK_PORT", "8443"),
TgWebhookURL: os.Getenv("TG_WEBHOOK_URL"), TgWebhookURL: os.Getenv("TG_WEBHOOK_URL"),
@ -163,12 +165,37 @@ func main() {
slog.Error("MAX bot error", "err", err) slog.Error("MAX bot error", "err", err)
os.Exit(1) os.Exit(1)
} }
var maxUID int64
maxInfo, err := maxApi.Bots.GetBot(context.Background()) maxInfo, err := maxApi.Bots.GetBot(context.Background())
if err != nil { if err != nil {
slog.Error("MAX bot info error", "err", err) // НЕ валим старт, если MAX недоступен или аккаунт бота заблокирован
os.Exit(1) // (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) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@ -178,7 +205,7 @@ func main() {
cancel() cancel()
}() }()
bridge := NewBridge(cfg, repo, tg, maxApi, maxInfo.UserId) bridge := NewBridge(cfg, repo, tg, maxApi, maxUID, maxApiOld, maxOldUID)
bridge.Run(ctx) bridge.Run(ctx)
slog.Info("Bridge stopped") slog.Info("Bridge stopped")
} }

109
markup.go
View file

@ -137,6 +137,11 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
runes := []rune(text) runes := []rune(text)
utf16units := utf16.Encode(runes) utf16units := utf16.Encode(runes)
// skip — код-юниты, которые НЕ выводим (кастомные эмодзи Telegram: MAX их не
// рендерит, а fallback-символ засоряет пересылку). Режем весь диапазон энтити.
skip := make([]bool, len(utf16units))
hasSkip := false
type tag struct { type tag struct {
pos int pos int
open bool open bool
@ -146,6 +151,19 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
var tags []tag var tags []tag
for i, e := range entities { 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 var openTag, closeTag string
switch e.Type { switch e.Type {
case "bold": 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}) 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) return html.EscapeString(text)
} }
@ -205,10 +223,12 @@ func tgEntitiesToHTML(text string, entities []Entity) string {
} }
if i < len(utf16units) { if i < len(utf16units) {
if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) { if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) {
r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1])) if !skip[i] {
sb.WriteString(html.EscapeString(string(r))) r := utf16.DecodeRune(rune(utf16units[i]), rune(utf16units[i+1]))
i++ sb.WriteString(html.EscapeString(string(r)))
} else { }
i++ // суррогатная пара — пропускаем и низкий код-юнит
} else if !skip[i] {
sb.WriteString(html.EscapeString(string(rune(utf16units[i])))) sb.WriteString(html.EscapeString(string(rune(utf16units[i]))))
} }
} }
@ -286,50 +306,69 @@ func maxMarkupsToHTML(text string, markups []maxschemes.MarkUp) string {
runes := []rune(text) runes := []rune(text)
utf16units := utf16.Encode(runes) utf16units := utf16.Encode(runes)
type tag struct { // open/close HTML для типа маркапа (для ссылки URL входит в идентичность тега).
pos int type tagPair struct{ open, close string }
open bool tagFor := func(m maxschemes.MarkUp) (tagPair, bool) {
order int
tag string
}
var tags []tag
for _, m := range markups {
var openTag, closeTag string
switch m.Type { switch m.Type {
case maxschemes.MarkupStrong: case maxschemes.MarkupStrong:
openTag, closeTag = "<b>", "</b>" return tagPair{"<b>", "</b>"}, true
case maxschemes.MarkupEmphasized: case maxschemes.MarkupEmphasized:
openTag, closeTag = "<i>", "</i>" return tagPair{"<i>", "</i>"}, true
case maxschemes.MarkupMonospaced: case maxschemes.MarkupMonospaced:
openTag, closeTag = "<code>", "</code>" return tagPair{"<code>", "</code>"}, true
case maxschemes.MarkupStrikethrough: case maxschemes.MarkupStrikethrough:
openTag, closeTag = "<s>", "</s>" return tagPair{"<s>", "</s>"}, true
case maxschemes.MarkupUnderline: case maxschemes.MarkupUnderline:
openTag, closeTag = "<u>", "</u>" return tagPair{"<u>", "</u>"}, true
case maxschemes.MarkupLink: case maxschemes.MarkupLink:
openTag = `<a href="` + html.EscapeString(m.URL) + `">` return tagPair{`<a href="` + html.EscapeString(m.URL) + `">`, "</a>"}, true
closeTag = "</a>"
default:
continue
} }
tags = append(tags, tag{pos: m.From, open: true, order: 0, tag: openTag}) return tagPair{}, false
tags = append(tags, tag{pos: m.From + m.Length, open: false, order: 1, tag: closeTag})
} }
sort.Slice(tags, func(i, j int) bool { // activeAt — детерминированно упорядоченный набор тегов, активных на позиции i.
if tags[i].pos != tags[j].pos { // MAX допускает ПЕРЕКРЫВАЮЩИЕСЯ маркапы (ссылка+жирный внахлёст), а HTML требует
return tags[i].pos < tags[j].pos // строгой вложенности. Поэтому на каждой границе закрываем все открытые теги и
// переоткрываем актуальные — так 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 var sb strings.Builder
tagIdx := 0 var open []tagPair
for i := 0; i <= len(utf16units); i++ { for i := 0; i <= len(utf16units); i++ {
for tagIdx < len(tags) && tags[tagIdx].pos == i { var cur []tagPair
sb.WriteString(tags[tagIdx].tag) if i < len(utf16units) {
tagIdx++ 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 i < len(utf16units) {
if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) { if utf16.IsSurrogate(rune(utf16units[i])) && i+1 < len(utf16units) {

514
max.go

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,17 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
return 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) uid := tgUserID(items[0].msg)
prefix := !isCrosspost && b.hasPrefix("tg", items[0].msg.Chat.ID) 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) m.AddPhoto(uploaded)
} else { } else {
uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL) uploaded, err := mc.Uploads.UploadPhotoFromUrl(ctx, fileURL)
if err != nil { if err != nil {
slog.Error("media group: photo upload failed", "err", err) slog.Error("media group: photo upload failed", "err", err)
photoFailErr = err photoFailErr = err
@ -214,10 +225,12 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
// Если есть фото — отправляем через SDK (поддерживает AddPhoto) // Если есть фото — отправляем через SDK (поддерживает AddPhoto)
if photosSent > 0 { if photosSent > 0 {
result, err := b.maxApi.Messages.SendWithResult(ctx, m) result, err := mc.Messages.SendWithResult(ctx, m)
if err != nil { if err != nil {
slog.Error("TG→MAX media group send failed", "err", err) 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, b.notifyTgUser(ctx, items[0].msg, maxChatID,
fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost) fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost)
} }
@ -234,7 +247,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
} else { } else {
cap = formatTgCaption(it.msg, prefix, b.cfg.MessageNewline) 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 return
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE crossposts DROP COLUMN paused;
ALTER TABLE pairs DROP COLUMN paused;

View file

@ -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;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS crosspost_seen;

View file

@ -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)
);

View file

@ -0,0 +1,2 @@
ALTER TABLE crossposts DROP COLUMN paused;
ALTER TABLE pairs DROP COLUMN paused;

View file

@ -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;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS crosspost_seen;

View file

@ -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)
);

View file

@ -254,9 +254,7 @@ func (r *pgRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int,
} }
func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) { func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
if threadID == 0 { // threadID==0 — General-топик форума (валидная связка), НЕ отбиваем (см. sqlite).
return 0, false
}
var id int64 var id int64
err := r.db.QueryRow( err := r.db.QueryRow(
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2", "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 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) { func (r *pgRepo) TouchUser(userID int64, platform, username, firstName string) {
now := time.Now().Unix() 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) r.db.Exec(`INSERT INTO users (user_id, platform, username, first_name, first_seen, last_seen) VALUES ($1, $2, $3, $4, $5, $5)

View file

@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
@ -42,6 +43,11 @@ func humanQueueError(errStr string) string {
// notifyTg2MaxFailure сообщает о невозможности доставить пост в MAX: владельцу // notifyTg2MaxFailure сообщает о невозможности доставить пост в MAX: владельцу
// связки (для crosspost) с номером поста и причиной, иначе — в исходный чат. // связки (для crosspost) с номером поста и причиной, иначе — в исходный чат.
func (b *Bridge) notifyTg2MaxFailure(ctx context.Context, item QueueItem, reason string) { 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 := "" post := ""
if item.SrcMsgID != "" && item.SrcMsgID != "0" { if item.SrcMsgID != "" && item.SrcMsgID != "0" {
post = fmt.Sprintf(" (пост #%s)", item.SrcMsgID) 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 обрабатывает очередь — вызывается периодически. // processQueue обрабатывает очередь — вызывается периодически.
func (b *Bridge) processQueue(ctx context.Context) { func (b *Bridge) processQueue(ctx context.Context) {
items, err := b.repo.PeekQueue(queueBatchSize) 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) { 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) { 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) { 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 sentMsgID int
var err error var err error
threadID := b.repo.GetTgThreadID(item.DstChatID) 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} opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID}
switch item.AttType { switch item.AttType {
case "photo": case "photo":

View file

@ -101,6 +101,19 @@ type Repository interface {
GetCrosspostSyncEdits(maxChatID int64) bool GetCrosspostSyncEdits(maxChatID int64) bool
SetCrosspostSyncEdits(maxChatID int64, on bool) error 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 // Users
TouchUser(userID int64, platform, username, firstName string) TouchUser(userID int64, platform, username, firstName string)
ListUsers(platform string) ([]int64, error) ListUsers(platform string) ([]int64, error)

View file

@ -249,9 +249,8 @@ func (r *sqliteRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, i
} }
func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) { func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
if threadID == 0 { // threadID==0 — это General-топик форума (валидная тред-связка), НЕ отбиваем:
return 0, false // если для (чат, 0) связки нет — вернётся пусто, и вызывающий уйдёт на групповую.
}
var id int64 var id int64
err := r.db.QueryRow( err := r.db.QueryRow(
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?", "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 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) { func (r *sqliteRepo) TouchUser(userID int64, platform, username, firstName string) {
now := time.Now().Unix() now := time.Now().Unix()
r.mu.Lock() r.mu.Lock()

View file

@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@ -58,11 +59,35 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
if b.isSelfTgBot(edited.From) { if b.isSelfTgBot(edited.From) {
continue 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 maxChatID int64
var linked bool 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 { if !linked {
maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID) maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID)
} }
@ -79,7 +104,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
if hasMedia && !hasMapping { if hasMedia && !hasMapping {
prefix := b.hasPrefix("tg", edited.Chat.ID) prefix := b.hasPrefix("tg", edited.Chat.ID)
caption := formatTgCaption(edited, prefix, b.cfg.MessageNewline) 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 continue
} }
@ -114,7 +139,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
fwd := formatAttributionHTML(name, mdText, b.cfg.MessageNewline) fwd := formatAttributionHTML(name, mdText, b.cfg.MessageNewline)
m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd) m := maxbot.NewMessage().SetChat(maxChatID).SetText(fwd)
m.SetFormat("html") 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) slog.Error("TG→MAX edit failed", "err", err, "uid", tgUserID(edited), "tgChat", edited.Chat.ID)
} else { } else {
slog.Info("TG→MAX edited", "mid", maxMsgID, "uid", tgUserID(edited), "tgChat", edited.Chat.ID) 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 text == "/start" || text == "/help" {
if ch := customHelp(); ch != "" { 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 continue
} }
b.tg.SendMessage(ctx, msg.Chat.ID, intro, kb := b.tgStartMenu()
"Бот-мост между Telegram и MAX.\n\n"+ b.tg.SendMessage(ctx, msg.Chat.ID, intro,
"Команды (группы):\n"+ &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID, ReplyMarkup: kb})
"/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 <TG_ID>\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})
continue continue
} }
@ -332,7 +326,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
} }
b.tg.SendMessage(ctx, msg.Chat.ID, b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("TG-канал «%s»\nID: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.", channelTitle, channelID, channelID, b.cfg.MaxBotURL), fmt.Sprintf("TG-канал «%s»\nID: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.%s", channelTitle, channelID, channelID, b.cfg.MaxBotURL, b.reserveBotHint()),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue continue
} }
@ -481,7 +475,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key) slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key)
} else if generatedKey != "" { } else if generatedKey != "" {
b.tg.SendMessage(ctx, msg.Chat.ID, b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Ключ для связки: <code>%s</code>\n\nДобавьте MAX-бота в нужную MAX-группу и отправьте <b>в ней</b> (не в ЛС бота):\n<code>/bridge %s</code>\n\nСсылка на MAX-бота (для добавления в группу): %s", generatedKey, generatedKey, b.cfg.MaxBotURL), fmt.Sprintf("Ключ для связки: <code>%s</code>\n\nДобавьте MAX-бота в нужную MAX-группу, сделайте его <b>администратором с правом «Доступ к сообщениям»</b> (читать все сообщения — иначе бот не увидит команду), и отправьте <b>в ней</b> (не в ЛС бота):\n<code>/bridge %s</code>\n\nСсылка на MAX-бота (для добавления в группу): %s%s", generatedKey, generatedKey, b.cfg.MaxBotURL, b.reserveBotHint()),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey) slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey)
} else { } else {
@ -506,6 +500,33 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
continue 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-чатом // /thread_bridge — связать конкретный тред с отдельным MAX-чатом
if text == "/thread_bridge" { if text == "/thread_bridge" {
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { 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}) b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue continue
} }
if msg.MessageThreadID == 0 { // General-топик форума имеет thread_id=0 — это валидный тред, связываем как
b.tg.SendMessage(ctx, msg.Chat.ID, // и любой другой (id=0). Маршрутизация ниже проверяет thread-bridge для всех id.
"Отправьте команду <b>внутри конкретного треда</b> (не в General). Тред будет связан с отдельным MAX-чатом.",
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
continue
}
if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok { if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok {
b.tg.SendMessage(ctx, msg.Chat.ID, b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID), fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID),
@ -538,7 +555,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
continue continue
} }
_, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID, _, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s", key, key, b.cfg.MaxBotURL), fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s%s", key, key, b.cfg.MaxBotURL, b.reserveBotHint()),
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
if sendErr != nil { if sendErr != nil {
slog.Error("thread-bridge reply send failed", "err", sendErr, "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID) 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}) b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
continue continue
} }
if msg.MessageThreadID == 0 {
b.tg.SendMessage(ctx, msg.Chat.ID, "Отправьте команду внутри треда.", nil)
continue
}
if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) { if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) {
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID}) b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
} else { } else {
@ -571,25 +584,73 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
// Группа обсуждения канала: ловим авто-форвард поста (строим маппинг // Группа обсуждения канала: ловим авто-форвард поста (строим маппинг
// пост↔тред) и ингестим комменты реальных юзеров в мини-апп (TG→app). // пост↔тред) и ингестим комменты реальных юзеров в мини-апп (TG→app).
if isGroup { 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 { if len(msg.NewChatMembers) > 0 {
slog.Warn("save discussion map failed", "err", err) 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 ниже). // /antispam и модерация комментов не срабатывают (сообщение уходит в continue ниже).
if msg.From != nil && !msg.IsService { // SenderChat != nil — сообщение «от имени канала» (пост связанного канала,
// репост канала): это контент канала, а не юзер-спам — не модерируем.
if msg.From != nil && !msg.IsService && msg.SenderChat == nil {
mtext := msg.Text mtext := msg.Text
if mtext == "" { if mtext == "" {
mtext = msg.Caption 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{ if b.moderateGroupMessage(ctx, GroupMessage{
Platform: "tg", ChatID: msg.Chat.ID, UserID: msg.From.ID, 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 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 { if msg.MessageThreadID != 0 && msg.From != nil && !b.isSelfTgBot(msg.From) && !msg.IsService {
chCh, chMsg, ok := b.repo.LookupChannelByDiscussion(msg.Chat.ID, msg.MessageThreadID) chCh, chMsg, ok := b.repo.LookupChannelByDiscussion(msg.Chat.ID, msg.MessageThreadID)
@ -615,12 +676,11 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
// (антиспам-модерация и /antispam уже выполнены выше — до ингеста комментов обсуждения) // (антиспам-модерация и /antispam уже выполнены выше — до ингеста комментов обсуждения)
// Пересылка. Приоритет у thread-bridge: если сообщение в связанном треде — шлём в его MAX-чат. // Пересылка. Приоритет у thread-bridge (вкл. General, thread_id=0): если этот
// тред связан отдельно — шлём в его MAX-чат, иначе фоллбэк на групповую связку.
var maxChatID int64 var maxChatID int64
var linked bool 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 { if !linked {
maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID) maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID)
} }
@ -665,7 +725,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
continue 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 != "" 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 — не отправляем повторно. // Дедуп: если это сообщение уже доставлено в MAX — не отправляем повторно.
if b.alreadyDeliveredToMax(msg.Chat.ID, msg.MessageID) { if b.alreadyDeliveredToMax(msg.Chat.ID, msg.MessageID) {
slog.Info("skip duplicate TG→MAX", "tgChat", msg.Chat.ID, "tgMsg", msg.MessageID, "crosspost", isCrosspost) 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) { if b.cbBlocked(maxChatID) {
return 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) uid := tgUserID(msg)
@ -758,6 +856,9 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
rawText = msg.Text rawText = msg.Text
} }
mdText := tgEntitiesToHTML(rawText, msg.CaptionEntities) mdText := tgEntitiesToHTML(rawText, msg.CaptionEntities)
if fl := tgForwardLine(msg); fl != "" {
mdText = fl + mdText
}
name := tgName(msg) name := tgName(msg)
if b.hasPrefix("tg", msg.Chat.ID) { if b.hasPrefix("tg", msg.Chat.ID) {
name = "[TG] " + name name = "[TG] " + name
@ -784,7 +885,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
return return
} }
} else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil { } 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) m.AddPhoto(uploaded)
} else { } else {
slog.Error("TG→MAX photo upload failed", "err", err) 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) 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 { if err != nil {
slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) 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, b.notifyTgUser(ctx, msg, maxChatID,
fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), isCrosspost) 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 { } else {
// Обычный стикер WebP → отправляем как фото // Обычный стикер WebP → отправляем как фото
if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil { 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 := maxbot.NewMessage().SetChat(maxChatID).SetText(caption)
m.AddPhoto(uploaded) m.AddPhoto(uploaded)
if msg.ReplyToMessage != nil { if msg.ReplyToMessage != nil {
@ -861,7 +964,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
m.AddKeyboard(kb) m.AddKeyboard(kb)
} }
slog.Info("TG→MAX sending sticker as photo", "uid", uid, "tgChat", msg.Chat.ID) 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 { if err != nil {
slog.Error("TG→MAX sticker send failed", "err", err) slog.Error("TG→MAX sticker send failed", "err", err)
b.notifyTgUser(ctx, msg, maxChatID, uploadErrMsg("Не удалось отправить стикер в MAX", err), isCrosspost) 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) 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 // Fallback: медиа было, но не загрузилось (либо не хватило места, либо Bot API
// не смог его скачать). Подмешиваем маркер типа, чтобы хотя бы текст прошёл. // не смог его скачать). Подмешиваем маркер типа, чтобы хотя бы текст прошёл.
hasMedia := msg.Video != nil || msg.VideoNote != nil || msg.Document != nil || 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 { if sendErr != nil {
errStr := sendErr.Error() errStr := sendErr.Error()
slog.Error("TG→MAX send failed", "err", errStr, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) 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, не ретраим // 403/404 — permanent error, не ретраим
if !strings.Contains(errStr, "403") && !strings.Contains(errStr, "404") && !strings.Contains(errStr, "chat.denied") { 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) 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, b.notifyTgUser(ctx, msg, maxChatID,
"MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost) "MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", isCrosspost)
} }
@ -1219,7 +1339,7 @@ func (b *Bridge) editTgMediaInMax(ctx context.Context, msg *TGMessage, maxChatID
} }
// Стикеры/VideoNote редактирование подписи не поддерживают со стороны TG, поэтому пропускаем. // Стикеры/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) slog.Error("TG→MAX edit media failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxMsgID", maxMsgID)
} else { } else {
slog.Info("TG→MAX edited media", "mid", maxMsgID, "uid", uid, "tgChat", msg.Chat.ID) 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 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) go b.publishTgCrosspost(ctx, msg, maxChatID)
} }
@ -1295,7 +1423,7 @@ func (b *Bridge) publishTgCrosspost(ctx context.Context, msg *TGMessage, maxChat
return return
} }
b.forwardTgToMax(ctx, msg, maxChatID, caption, true) b.forwardTgToMax(ctx, msg, maxChatID, caption, true, false)
} }
// handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management). // 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 // cpd:dir:maxChatID — change direction
if strings.HasPrefix(data, "cpd:") { if strings.HasPrefix(data, "cpd:") {
parts := strings.SplitN(data, ":", 3) 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) 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) slog.Error("TG→MAX crosspost edit failed", "err", err)
} else { } else {
slog.Info("TG→MAX crosspost edited", "mid", maxMsgID) slog.Info("TG→MAX crosspost edited", "mid", maxMsgID)

View file

@ -26,6 +26,14 @@ type PhotoSize struct {
FileSize int FileSize int
} }
// ContactInfo — контакт (sharing телефона).
type ContactInfo struct {
PhoneNumber string
FirstName string
LastName string
UserID int64
}
type FileInfo struct { type FileInfo struct {
FileID string FileID string
FileName string FileName string
@ -77,6 +85,7 @@ type TGMessage struct {
MediaGroupID string MediaGroupID string
ReplyToMessage *TGMessage ReplyToMessage *TGMessage
ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin
ForwardFrom string // отображаемое имя источника форварда (канал/группа/юзер/скрытый), "" если не форвард
ForwardOriginMsgID int // msg_id оригинала в канале-источнике (для старта импорта с поста) ForwardOriginMsgID int // msg_id оригинала в канале-источнике (для старта импорта с поста)
ForwardOriginDate int // дата оригинала (unix) — у постов одного альбома совпадает ForwardOriginDate int // дата оригинала (unix) — у постов одного альбома совпадает
MigrateToChatID int64 MigrateToChatID int64
@ -88,6 +97,15 @@ type TGMessage struct {
// IsAutomaticForward — авто-форвард поста канала в его группу обсуждения // IsAutomaticForward — авто-форвард поста канала в его группу обсуждения
// (Telegram ставит is_automatic_forward). По нему строим маппинг пост↔тред. // (Telegram ставит is_automatic_forward). По нему строим маппинг пост↔тред.
IsAutomaticForward bool IsAutomaticForward bool
// HasExternalReply — сообщение цитирует пост из ДРУГОГО чата/канала (external_reply).
// Частый приём спама: чистая наживка + цитата скам-канала со ссылкой (текст чистый,
// пейлоад в цитате). ExternalReplyText — текст цитаты + название источника + ссылка.
HasExternalReply bool
ExternalReplyText string
// NewChatMembers — вступившие в группу (для капчи/анти-рейда).
NewChatMembers []UserInfo
// Contact — пересланный/отправленный контакт (телефон). Без текста — иначе зеркало пустое.
Contact *ContactInfo
// ChatShared — пользователь выбрал чат через нативную кнопку KeyboardButtonRequestChat // ChatShared — пользователь выбрал чат через нативную кнопку KeyboardButtonRequestChat
// (флоу выбора группы из лички). Содержит chat_id выбранной группы. // (флоу выбора группы из лички). Содержит chat_id выбранной группы.
ChatShared *ChatSharedInfo ChatShared *ChatSharedInfo
@ -230,8 +248,12 @@ type TGSender interface {
GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error) GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error)
// RestrictChatMember мутит участника (запрет писать) до untilUnix (0 = навсегда). // RestrictChatMember мутит участника (запрет писать) до untilUnix (0 = навсегда).
RestrictChatMember(ctx context.Context, chatID, userID int64, untilUnix int) error RestrictChatMember(ctx context.Context, chatID, userID int64, untilUnix int) error
// UnrestrictChatMember снимает мут (возвращает базовые права писать) — для капчи.
UnrestrictChatMember(ctx context.Context, chatID, userID int64) error
// BanChatMember банит участника и удаляет его сообщения. // BanChatMember банит участника и удаляет его сообщения.
BanChatMember(ctx context.Context, chatID, userID int64) error 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 SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error
SetMyDescription(ctx context.Context, description string) error SetMyDescription(ctx context.Context, description string) error
SetMenuButtonWebApp(ctx context.Context, text, url string) error SetMenuButtonWebApp(ctx context.Context, text, url string) error

View file

@ -8,6 +8,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/go-telegram/bot" "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
@ -93,7 +94,12 @@ func (s *tgBotSender) SendMessage(ctx context.Context, chatID int64, text string
Text: text, Text: text,
} }
applySendMessageOpts(p, opts) 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 { if err != nil {
return 0, wrapErr(err) 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) { func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) {
items := make([]models.InputMedia, 0, len(media)) p := &bot.SendMediaGroupParams{ChatID: chatID}
for _, m := range media {
items = append(items, toLibInputMedia(m))
}
p := &bot.SendMediaGroupParams{
ChatID: chatID,
Media: items,
}
if opts != nil { if opts != nil {
if opts.ThreadID != 0 { if opts.ThreadID != 0 {
p.MessageThreadID = opts.ThreadID 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} 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 { if err != nil {
return nil, wrapErr(err) return nil, wrapErr(err)
} }
@ -274,6 +290,28 @@ func (s *tgBotSender) RestrictChatMember(ctx context.Context, chatID, userID int
return wrapErr(err) 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 { func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) error {
_, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{ _, err := s.b.BanChatMember(ctx, &bot.BanChatMemberParams{
ChatID: chatID, ChatID: chatID,
@ -283,6 +321,19 @@ func (s *tgBotSender) BanChatMember(ctx context.Context, chatID, userID int64) e
return wrapErr(err) 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 { func (s *tgBotSender) SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error {
cmds := make([]models.BotCommand, len(commands)) cmds := make([]models.BotCommand, len(commands))
for i, c := range commands { for i, c := range commands {
@ -533,6 +584,36 @@ func applySendDocumentOpts(p *bot.SendDocumentParams, opts *SendOpts) {
// --- Error wrapping --- // --- 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 { func wrapErr(err error) error {
if err == nil { if err == nil {
return nil return nil
@ -640,6 +721,21 @@ func convertMsg(m *models.Message) *TGMessage {
msg.ForwardOriginDate = m.ForwardOrigin.MessageOriginChannel.Date 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 // Photo
for _, p := range m.Photo { for _, p := range m.Photo {
msg.Photo = append(msg.Photo, PhotoSize{ msg.Photo = append(msg.Photo, PhotoSize{
@ -674,6 +770,44 @@ func convertMsg(m *models.Message) *TGMessage {
msg.ReplyToMessage = convertMsg(m.ReplyToMessage) 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 { for _, e := range m.Entities {
msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL}) msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL})
} }

View file

@ -90,7 +90,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) 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) resp, err := b.apiClient.Do(req)
if err != nil { 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)) 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). // uploadTgPhotoToMax скачивает фото из TG и загружает в MAX (возвращает PhotoTokens).
// Загрузка — в обход SDK (наш customUploadPhotoToMax): SDK добавляет к /uploads // Загрузка — в обход SDK (наш customUploadPhotoToMax): SDK добавляет к /uploads
// устаревший параметр v=1.2.5, из-за которого MAX отдаёт 404 (как было с видео). // устаревший параметр v=1.2.5, из-за которого MAX отдаёт 404 (как было с видео).
@ -213,7 +246,7 @@ func (b *Bridge) customUploadPhotoToMax(ctx context.Context, reader io.Reader, f
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) 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) resp, err := b.apiClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("get upload url: %w", err) 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 (файл ещё обрабатывается) // Retry при attachment.not.ready (файл ещё обрабатывается)
for attempt := 0; attempt < 20; attempt++ { for attempt := 0; attempt < 20; attempt++ {
if attempt > 0 { if attempt > 0 && !flipped {
delay := time.Duration(3+attempt*2) * time.Second delay := time.Duration(3+attempt*2) * time.Second
select { select {
case <-ctx.Done(): 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) slog.Warn("MAX retry", "attempt", attempt+1, "maxAttempts", 20)
} }
flipped = false
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Authorization", b.cfg.MaxToken) req.Header.Set("Authorization", sendTok)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := b.apiClient.Do(req) 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 { if err := json.Unmarshal(respBody, &result); err != nil {
return "", err return "", err
} }
b.markChatBot(chatID, sendTok) // запомним рабочего бота для будущих отправок
return result.Message.Body.Mid, nil return result.Message.Body.Mid, nil
} }
@ -495,7 +535,21 @@ func (b *Bridge) sendMaxChunk(ctx context.Context, chatID int64, text string, at
continue 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") return "", fmt.Errorf("MAX attachment not ready after 10 retries")
} }