mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-07-09 17:18:37 +00:00
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
564 lines
25 KiB
Go
564 lines
25 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||
)
|
||
|
||
// Config — настройки bridge, читаемые из env.
|
||
type Config struct {
|
||
MaxToken string // токен MAX API (основной бот; нужен для direct-send/upload)
|
||
MaxTokenOld string // токен старого MAX-бота (резерв/failover; "" — выключено)
|
||
TgBotURL string // ссылка на TG-бота для /help
|
||
MaxBotURL string // ссылка на основного MAX-бота для /help (старый бот)
|
||
MaxBotURLReserve string // ссылка на запасного MAX-бота (для failover; "" — скрыть)
|
||
MaxWebhookURL string // базовый URL для webhook MAX (если пусто — long polling)
|
||
MaxWebhookPort string // порт HTTP-сервера для MAX webhook (по умолчанию 8443)
|
||
TgWebhookURL string // базовый URL для webhook TG (если пусто — long polling)
|
||
TgWebhookPort string // порт HTTP-сервера для TG webhook (по умолчанию 8444)
|
||
TgAPIURL string // custom TG Bot API URL (если пусто — api.telegram.org)
|
||
AllowedUsers []int64 // whitelist TG user IDs (empty = allow all)
|
||
TgMaxFileSizeMB int // max file size TG->MAX in MB (0 = unlimited)
|
||
MaxMaxFileSizeMB int // max file size MAX->TG in MB (0 = unlimited)
|
||
// MaxAllowedExts — whitelist расширений для TG→MAX (nil = не проверять локально).
|
||
// Если задан, файлы с не-вхождением блокируются до отправки на CDN.
|
||
MaxAllowedExts map[string]struct{}
|
||
// MessageNewline — если true, текст идёт с новой строки после имени отправителя:
|
||
// "Имя:\nтекст" вместо "Имя: текст". Задаётся через env MESSAGE_FORMAT=newline.
|
||
MessageNewline bool
|
||
// DisablePrefix — глобально отключает префиксы [TG]/[MAX] на всех чатах,
|
||
// независимо от настройки в БД. Задаётся через env DISABLE_PREFIX=true.
|
||
DisablePrefix bool
|
||
}
|
||
|
||
// chatBreaker хранит состояние circuit breaker для одного чата.
|
||
type chatBreaker struct {
|
||
fails int
|
||
blockedAt time.Time
|
||
}
|
||
|
||
const (
|
||
cbMaxFails = 3 // после N фейлов — блокируем
|
||
cbCooldown = 5 * time.Minute // на сколько блокируем
|
||
)
|
||
|
||
// Bridge — основная структура, объединяющая зависимости.
|
||
type Bridge struct {
|
||
cfg Config
|
||
repo Repository
|
||
tg TGSender
|
||
maxApi *maxbot.Api
|
||
maxApiOld *maxbot.Api // второй (старый) MAX-бот: резерв + обслуживание чатов без нового
|
||
maxBotUID int64 // MAX bot user ID (для фильтрации своих сообщений)
|
||
maxOldUID int64 // user ID старого MAX-бота (для фильтрации своих сообщений)
|
||
// maxBotCache — кэш «какой бот в чате»: chatID → токен для отправки. Заполняется
|
||
// по членству (GetChatMembership) и по тому, через чей вебхук пришёл апдейт.
|
||
maxBotMu sync.Mutex
|
||
maxBotCache map[int64]string // chatID → token (новый по умолчанию, старый если нового нет)
|
||
// maxSeenMid — дедуп входящих: один и тот же mid приходит от ОБОИХ ботов (если оба
|
||
// в чате). Обрабатываем один раз.
|
||
maxSeenMu sync.Mutex
|
||
maxSeenMid map[string]int64 // mid → unix-время (TTL-очистка)
|
||
|
||
httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут)
|
||
apiClient *http.Client // для коротких API-запросов (малый таймаут)
|
||
whSecret string // random path segment for webhook URLs
|
||
|
||
cpWaitMu sync.Mutex
|
||
cpWait map[int64]int64 // MAX userId → TG channel ID (ожидание пересылки)
|
||
|
||
cpTgOwnerMu sync.Mutex
|
||
cpTgOwner map[int64]int64 // TG channel ID → TG user ID (кто переслал пост)
|
||
|
||
cbMu sync.Mutex
|
||
breakers map[int64]*chatBreaker // destination chatID → breaker
|
||
|
||
// maxBlockedUntil — unix-секунды, до которых считаем аккаунт MAX заблокированным
|
||
// (MAX вернул 403 account.blocked). Пока активно — глушим пользовательские
|
||
// уведомления о недоставке: это глобальный бан, слать каждому бессмысленно.
|
||
// Сбрасывается на первой успешной отправке в MAX.
|
||
maxBlockedUntil atomic.Int64
|
||
|
||
// Буферизация TG media groups (альбомы)
|
||
mgMu sync.Mutex
|
||
mgBuffers map[string]*mediaGroupBuffer // MediaGroupID → buffer
|
||
|
||
// Опциональный аддон-расширение. Подключается через build-tag,
|
||
// в публичной сборке всегда nil.
|
||
addon Addon
|
||
// extraCommands — команды меню, добавленные расширением (заполняет loadAddon).
|
||
// Бридж не знает их семантику, просто показывает в setMyCommands.
|
||
extraCommands []BotCommand
|
||
// extraHelp — доп. блок для /help и /start (заполняет loadAddon). Пусто без аддона.
|
||
extraHelp string
|
||
// extraDescription — доп. строка к описанию бота (setMyDescription). Пусто без аддона.
|
||
extraDescription string
|
||
}
|
||
|
||
// NewBridge создаёт экземпляр Bridge.
|
||
func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api, maxBotUID int64, maxApiOld *maxbot.Api, maxOldUID int64) *Bridge {
|
||
// Derive webhook secret from tokens (stable across restarts)
|
||
h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken()))
|
||
secret := hex.EncodeToString(h[:8])
|
||
|
||
b := &Bridge{
|
||
cfg: cfg,
|
||
repo: repo,
|
||
tg: tg,
|
||
maxApi: maxApi,
|
||
maxApiOld: maxApiOld,
|
||
maxBotUID: maxBotUID,
|
||
maxOldUID: maxOldUID,
|
||
httpClient: &http.Client{
|
||
Timeout: 5 * time.Minute, // для download/upload больших файлов
|
||
},
|
||
apiClient: &http.Client{
|
||
Timeout: 15 * time.Second, // для коротких API-запросов
|
||
},
|
||
whSecret: secret,
|
||
cpWait: make(map[int64]int64),
|
||
cpTgOwner: make(map[int64]int64),
|
||
breakers: make(map[int64]*chatBreaker),
|
||
mgBuffers: make(map[string]*mediaGroupBuffer),
|
||
maxBotCache: make(map[int64]string),
|
||
maxSeenMid: make(map[string]int64),
|
||
}
|
||
b.addon = loadAddon(b)
|
||
return b
|
||
}
|
||
|
||
// cbBlocked проверяет, заблокирован ли чат.
|
||
func (b *Bridge) cbBlocked(chatID int64) bool {
|
||
b.cbMu.Lock()
|
||
defer b.cbMu.Unlock()
|
||
cb, ok := b.breakers[chatID]
|
||
if !ok {
|
||
return false
|
||
}
|
||
if cb.fails >= cbMaxFails && time.Since(cb.blockedAt) < cbCooldown {
|
||
return true
|
||
}
|
||
if cb.fails >= cbMaxFails {
|
||
// Кулдаун прошёл — сбрасываем, пробуем снова
|
||
delete(b.breakers, chatID)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// cbFail регистрирует ошибку. Возвращает true если чат только что заблокировался.
|
||
func (b *Bridge) cbFail(chatID int64) bool {
|
||
b.cbMu.Lock()
|
||
defer b.cbMu.Unlock()
|
||
cb, ok := b.breakers[chatID]
|
||
if !ok {
|
||
cb = &chatBreaker{}
|
||
b.breakers[chatID] = cb
|
||
}
|
||
cb.fails++
|
||
if cb.fails == cbMaxFails {
|
||
cb.blockedAt = time.Now()
|
||
slog.Warn("circuit breaker: chat blocked", "chatID", chatID, "cooldown", cbCooldown)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// cbSuccess сбрасывает счётчик ошибок для чата. Любая успешная отправка в MAX также
|
||
// снимает глобальный флаг «аккаунт заблокирован» (бан, видимо, сняли).
|
||
func (b *Bridge) cbSuccess(chatID int64) {
|
||
b.cbMu.Lock()
|
||
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).
|
||
func (c *Config) maxMaxFileBytes() int64 {
|
||
if c.MaxMaxFileSizeMB <= 0 {
|
||
return 0
|
||
}
|
||
return int64(c.MaxMaxFileSizeMB) * 1024 * 1024
|
||
}
|
||
|
||
// isUserAllowed проверяет, есть ли tgUserID в белом списке.
|
||
// Если AllowedUsers пуст — доступ разрешён всем.
|
||
func (b *Bridge) isUserAllowed(tgUserID int64) bool {
|
||
if len(b.cfg.AllowedUsers) == 0 {
|
||
return true
|
||
}
|
||
for _, id := range b.cfg.AllowedUsers {
|
||
if id == tgUserID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// checkUserAllowed проверяет доступ пользователя и отправляет сообщение об отказе если нужно.
|
||
// Возвращает true если доступ разрешён, false — если запрещён (и уже отправил ответ).
|
||
// userID == 0 трактуется как «нет отправителя» — доступ запрещается.
|
||
func (b *Bridge) checkUserAllowed(ctx context.Context, chatID, userID int64, threadID int) bool {
|
||
if userID != 0 && b.isUserAllowed(userID) {
|
||
return true
|
||
}
|
||
slog.Debug("TG user not allowed", "uid", userID)
|
||
b.tg.SendMessage(ctx, chatID, "У вас нет прав доступа к боту.", &SendOpts{ThreadID: threadID})
|
||
return false
|
||
}
|
||
|
||
// isCrosspostOwner проверяет, является ли userID владельцем связки.
|
||
// owner_id=0 и tg_owner_id=0 — старая связка, доступна всем.
|
||
func (b *Bridge) isCrosspostOwner(maxChatID, userID int64) bool {
|
||
maxOwner, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
|
||
if maxOwner == 0 && tgOwner == 0 {
|
||
return true // legacy, no owner
|
||
}
|
||
return userID == maxOwner || userID == tgOwner
|
||
}
|
||
|
||
// tgFileURL возвращает прямой URL файла из TG — через custom API если настроен.
|
||
func (b *Bridge) tgFileURL(ctx context.Context, fileID string) (string, error) {
|
||
filePath, err := b.tg.GetFile(ctx, fileID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return b.tg.GetFileDirectURL(filePath), nil
|
||
}
|
||
|
||
// tgChatTitle возвращает title TG-чата/канала по ID. Пустая строка если не удалось.
|
||
func (b *Bridge) tgChatTitle(ctx context.Context, chatID int64) string {
|
||
title, err := b.tg.GetChat(ctx, chatID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return title
|
||
}
|
||
|
||
// isSelfTgBot проверяет, является ли отправитель нашим ботом (а не чужим).
|
||
func (b *Bridge) isSelfTgBot(from *UserInfo) bool {
|
||
return from != nil && from.IsBot && from.UserName == b.tg.BotUsername()
|
||
}
|
||
|
||
// hasPrefix — обёртка над repo.HasPrefix с учётом глобального флага DisablePrefix.
|
||
// Возвращает false если префиксы отключены глобально через env.
|
||
func (b *Bridge) hasPrefix(platform string, chatID int64) bool {
|
||
if b.cfg.DisablePrefix {
|
||
return false
|
||
}
|
||
return b.repo.HasPrefix(platform, chatID)
|
||
}
|
||
|
||
// notifyTgUser отправляет пользовательское уведомление (например, об ошибке загрузки).
|
||
// Для bridge-режима — в чат, где пришло сообщение (в нужный тред, если форум).
|
||
// Для crosspost — в ЛС владельцу связки (tg_owner_id), чтобы не мусорить в канал.
|
||
// Если владелец не задан (legacy) — уведомление дропается с warn-логом.
|
||
func (b *Bridge) notifyTgUser(ctx context.Context, srcChat *TGMessage, maxChatID int64, text string, isCrosspost bool) {
|
||
// Глобальный бан аккаунта MAX — не заваливаем юзеров уведомлениями о недоставке
|
||
// (бан общий для всех чатов; сообщения и так копятся в очереди).
|
||
if b.maxAccountBlocked() {
|
||
slog.Debug("notify suppressed: MAX account blocked", "text", text)
|
||
return
|
||
}
|
||
if isCrosspost {
|
||
_, tgOwner := b.repo.GetCrosspostOwner(maxChatID)
|
||
if tgOwner == 0 {
|
||
slog.Warn("crosspost notify skipped: no tg owner", "maxChat", maxChatID, "text", text)
|
||
return
|
||
}
|
||
if _, err := b.tg.SendMessage(ctx, tgOwner, text, nil); err != nil {
|
||
slog.Warn("crosspost notify DM failed", "err", err, "tgOwner", tgOwner)
|
||
}
|
||
return
|
||
}
|
||
var opts *SendOpts
|
||
if srcChat != nil && srcChat.MessageThreadID != 0 {
|
||
opts = &SendOpts{ThreadID: srcChat.MessageThreadID}
|
||
}
|
||
b.tg.SendMessage(ctx, srcChat.Chat.ID, text, opts)
|
||
}
|
||
|
||
// uploadErrHint превращает техническую ошибку загрузки в короткий текст для юзера.
|
||
// Возвращает пустую строку для неизвестных ошибок — вызывающий код тогда
|
||
// отправит только generic-сообщение без технической мути.
|
||
func uploadErrHint(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
s := err.Error()
|
||
switch {
|
||
case strings.Contains(s, "file is too big"):
|
||
return "файл слишком большой"
|
||
case strings.Contains(s, "file is not found") || strings.Contains(s, "FILE_REFERENCE_EXPIRED"):
|
||
return "файл не найден"
|
||
case strings.Contains(s, "attachment.not.ready"):
|
||
return "попробуйте ещё раз"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// uploadErrMsg собирает user-facing сообщение: base + ": hint" если подсказка известна,
|
||
// иначе просто "base." (без технической ошибки).
|
||
func uploadErrMsg(base string, err error) string {
|
||
if hint := uploadErrHint(err); hint != "" {
|
||
return base + ": " + hint + "."
|
||
}
|
||
return base + "."
|
||
}
|
||
|
||
func (b *Bridge) tgWebhookPath() string {
|
||
return "/tg-webhook-" + b.whSecret
|
||
}
|
||
|
||
func (b *Bridge) maxWebhookPath() string {
|
||
return "/max-webhook-" + b.whSecret
|
||
}
|
||
|
||
// registerCommands регистрирует команды бота в Telegram.
|
||
func (b *Bridge) registerCommands(ctx context.Context) {
|
||
cmds := []BotCommand{
|
||
{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
||
{Command: "bridge_update", Description: "Обновить связку группы (показать в кабинете)"},
|
||
{Command: "pause", Description: "Поставить связку на паузу"},
|
||
{Command: "unpause", Description: "Возобновить пересылку"},
|
||
{Command: "unbridge", Description: "Удалить связку чатов"},
|
||
{Command: "thread", Description: "Установить топик для сообщений из MAX"},
|
||
{Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"},
|
||
{Command: "thread_unbridge", Description: "Удалить связку треда"},
|
||
{Command: "crosspost", Description: "Список связок кросспостинга"},
|
||
{Command: "help", Description: "Инструкция"},
|
||
}
|
||
// Команды, добавленные опциональными расширениями (через loadAddon). Бридж не
|
||
// знает, что это за команды — только что их нужно показать в меню.
|
||
cmds = append(cmds, b.extraCommands...)
|
||
if err := b.tg.SetMyCommands(ctx, cmds, nil); err != nil {
|
||
slog.Error("TG setMyCommands (default) failed", "err", err)
|
||
}
|
||
if err := b.tg.SetMyCommands(ctx, cmds, &CommandScope{Type: "all_chat_administrators"}); err != nil {
|
||
slog.Error("TG setMyCommands (admins) failed", "err", err)
|
||
}
|
||
|
||
// Описание бота (экран "Что умеет этот бот"). Доп. строка от расширения (impl
|
||
// в loadAddon) — без аддона пусто, в публичной сборке про импорт ни слова.
|
||
desc := "Мост между Telegram и MAX: зеркалит сообщения в связанных группах и кросспостит каналы." + b.extraDescription +
|
||
"\n\n💬 Поддержка и новости: https://t.me/+0ucbOj4wBwQzMWNi"
|
||
if err := b.tg.SetMyDescription(ctx, desc); err != nil {
|
||
slog.Error("TG setMyDescription failed", "err", err)
|
||
}
|
||
|
||
// Кнопка «Меню» бота → открыть веб-апп (кабинет). Только если задан MINIAPP_URL
|
||
// (в публичной сборке без него кнопка не ставится — приватный URL не светим).
|
||
if url := os.Getenv("MINIAPP_URL"); url != "" {
|
||
if err := b.tg.SetMenuButtonWebApp(ctx, "Кабинет", url); err != nil {
|
||
slog.Error("TG setChatMenuButton failed", "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// reserveBotHint — подсказка добавить запасного MAX-бота админом (для failover).
|
||
// Пусто, если запасной не настроен (MaxBotURLReserve == "").
|
||
func (b *Bridge) reserveBotHint() string {
|
||
if b.cfg.MaxBotURLReserve == "" {
|
||
return ""
|
||
}
|
||
return "\n\n🔁 Для надёжности добавьте админом и запасного бота (подменит основной, если тот недоступен): " + b.cfg.MaxBotURLReserve
|
||
}
|
||
|
||
// Run запускает TG и MAX listener'ы + периодическую очистку.
|
||
func (b *Bridge) Run(ctx context.Context) {
|
||
b.registerCommands(ctx)
|
||
go func() {
|
||
t := time.NewTicker(10 * time.Minute)
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
b.repo.CleanOldMessages()
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Воркер очереди — проверяет каждые 10 секунд
|
||
go func() {
|
||
t := time.NewTicker(10 * time.Second)
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
b.processQueue(ctx)
|
||
}
|
||
}
|
||
}()
|
||
|
||
startWebhookServer := func(port string, label string) {
|
||
go func() {
|
||
addr := ":" + port
|
||
srv := &http.Server{
|
||
Addr: addr,
|
||
ReadTimeout: 10 * time.Second,
|
||
WriteTimeout: 10 * time.Second,
|
||
IdleTimeout: 60 * time.Second,
|
||
}
|
||
slog.Info("Webhook server starting", "label", label, "addr", addr)
|
||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
slog.Error("Webhook server failed", "label", label, "err", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
if b.cfg.MaxWebhookURL != "" && b.cfg.TgWebhookURL != "" && b.cfg.MaxWebhookPort == b.cfg.TgWebhookPort {
|
||
// оба на одном порту — один сервер
|
||
startWebhookServer(b.cfg.MaxWebhookPort, "MAX+TG")
|
||
} else {
|
||
if b.cfg.MaxWebhookURL != "" {
|
||
startWebhookServer(b.cfg.MaxWebhookPort, "MAX")
|
||
}
|
||
if b.cfg.TgWebhookURL != "" {
|
||
startWebhookServer(b.cfg.TgWebhookPort, "TG")
|
||
}
|
||
}
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(2)
|
||
go func() { defer wg.Done(); b.listenTelegram(ctx) }()
|
||
go func() { defer wg.Done(); b.listenMax(ctx) }()
|
||
if b.addon != nil {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
if err := b.addon.Start(ctx); err != nil {
|
||
slog.Error("addon stopped with error", "err", err)
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
}
|