telegram: недоступность api.telegram.org больше не роняет старт
- авторизация бота ушла из NewBot в Run: повторяется в фоне с backoff от 30 секунд до 15 минут, пока не появится связь - отказ по токену (401/404) повторами не лечится — бот просто выключается, веб-часть продолжает работать - у клиента Telegram ограничен только dial (15 c): long polling и заливка вложений должны оставаться без общего таймаута
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -86,6 +86,8 @@ tier7 = 8
|
||||
[telegram]
|
||||
|
||||
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
||||
# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть
|
||||
# работает, а бот повторяет авторизацию в фоне, пока не появится связь.
|
||||
enabled = false
|
||||
|
||||
# Токен бота, полученный от @BotFather.
|
||||
|
||||
+71
-14
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user