telegram: недоступность api.telegram.org больше не роняет старт

- авторизация бота ушла из NewBot в Run: повторяется в фоне с backoff
  от 30 секунд до 15 минут, пока не появится связь
- отказ по токену (401/404) повторами не лечится — бот просто выключается,
  веб-часть продолжает работать
- у клиента Telegram ограничен только dial (15 c): long polling и заливка
  вложений должны оставаться без общего таймаута
This commit is contained in:
av
2026-07-25 14:17:59 +03:00
parent 13dafa2f68
commit c3acb50fcc
3 changed files with 76 additions and 22 deletions
+71 -14
View File
@@ -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)