diff --git a/cmd/remembos/main.go b/cmd/remembos/main.go index ffb2244..2c704b0 100644 --- a/cmd/remembos/main.go +++ b/cmd/remembos/main.go @@ -71,16 +71,11 @@ func main() { // Web handler handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger) - // Telegram bot + // Telegram bot. Авторизация в Telegram идёт в фоне (Bot.Run) с повторами: + // недоступный api.telegram.org не должен мешать старту веб-части. var tgBot *telegram.Bot if cfg.Telegram.Enabled { - var err error - tgBot, err = telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, loc, logger) - if err != nil { - logger.Error("failed to create telegram bot", "error", err) - store.Close() - os.Exit(1) //nolint:gocritic // store.Close() called above; linter doesn't track manual cleanup - } + tgBot = telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, loc, logger) } // HTTP server diff --git a/config.dist.toml b/config.dist.toml index 1692d21..6958470 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -86,6 +86,8 @@ tier7 = 8 [telegram] # Включить Telegram-бот для ежедневной отправки воспоминаний. +# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть +# работает, а бот повторяет авторизацию в фоне, пока не появится связь. enabled = false # Токен бота, полученный от @BotFather. diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 3d40683..b1c94fe 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -2,8 +2,10 @@ package telegram import ( "context" - "fmt" + "errors" "log/slog" + "net" + "net/http" "strconv" "strings" "time" @@ -17,9 +19,16 @@ import ( "git.vakhrushev.me/av/remembos/internal/search" ) +// Интервалы повторной авторизации в Telegram: от 30 секунд с удвоением до 15 минут. +const ( + connectRetryMin = 30 * time.Second + connectRetryMax = 15 * time.Minute +) + // Bot sends a daily memory via Telegram. type Bot struct { - api *tgbotapi.BotAPI + token string + api *tgbotapi.BotAPI // назначается в connect, до этого бот не работает service *memory.Service client *memos.Client chatID int64 @@ -30,7 +39,8 @@ type Bot struct { allowLoadMore bool } -// NewBot creates a new Telegram bot. +// NewBot creates a new Telegram bot. В сеть не ходит: авторизация происходит +// в Run, чтобы недоступный api.telegram.org не мешал старту приложения. func NewBot( cfg config.TelegramConfig, service *memory.Service, @@ -39,22 +49,15 @@ func NewBot( allowLoadMore bool, loc *time.Location, logger *slog.Logger, -) (*Bot, error) { - api, err := tgbotapi.NewBotAPI(cfg.Token) - if err != nil { - return nil, fmt.Errorf("create telegram bot: %w", err) - } - +) *Bot { pub := publicURL if pub == "" { pub = memosURL } pub = strings.TrimRight(pub, "/") - logger.Info("telegram bot authorized", "username", api.Self.UserName) - return &Bot{ - api: api, + token: cfg.Token, service: service, client: client, chatID: cfg.ChatID, @@ -63,11 +66,15 @@ func NewBot( loc: loc, logger: logger, allowLoadMore: allowLoadMore, - }, nil + } } -// Run starts the scheduling loop. It blocks until ctx is cancelled. +// Run authorizes the bot and starts the scheduling loop. It blocks until ctx is cancelled. func (b *Bot) Run(ctx context.Context) { + if !b.connect(ctx) { + return + } + if b.allowLoadMore { go b.listenForCommands(ctx) } @@ -87,6 +94,56 @@ func (b *Bot) Run(ctx context.Context) { } } +// connect авторизует бота, повторяя попытки до успеха или отмены ctx. +// Сеть до api.telegram.org может быть недоступна (у сервера бывает заблокирован +// исходящий трафик) — это не повод ронять приложение: веб-часть работает без бота, +// а бот подхватится сам, когда связь появится. +func (b *Bot) connect(ctx context.Context) bool { + delay := connectRetryMin + + for attempt := 1; ; attempt++ { + api, err := tgbotapi.NewBotAPIWithClient(b.token, tgbotapi.APIEndpoint, newHTTPClient()) + if err == nil { + b.api = api + b.logger.Info("telegram bot authorized", "username", api.Self.UserName, "attempt", attempt) + return true + } + + // Отказ самого Telegram (неверный токен) повторами не лечится — выключаем бота. + var apiErr *tgbotapi.Error + if errors.As(err, &apiErr) && (apiErr.Code == http.StatusUnauthorized || apiErr.Code == http.StatusNotFound) { + b.logger.Error("telegram bot disabled: token rejected", "error", err) + return false + } + + b.logger.Warn("telegram authorization failed, will retry", + // Duration в JSON-логе иначе печатается наносекундами. + "attempt", attempt, "retry_in", delay.String(), "error", err) + + select { + case <-ctx.Done(): + b.logger.Info("telegram bot stopped before authorization") + return false + case <-time.After(delay): + } + delay = min(delay*2, connectRetryMax) + } +} + +// newHTTPClient — клиент для Telegram API. Общего таймаута намеренно нет: +// long polling ждёт до 60 секунд, а заливка вложений может идти долго. +// Ограничено только установление соединения, чтобы неудачная попытка +// авторизации не висела минутами на TCP-ретраях. +func newHTTPClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: 15 * time.Second, + }, + } +} + // nextSendTime returns the next occurrence of sendAt in the configured timezone. func (b *Bot) nextSendTime() time.Time { now := time.Now().In(b.loc)