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
|
// Web handler
|
||||||
handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
|
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
|
var tgBot *telegram.Bot
|
||||||
if cfg.Telegram.Enabled {
|
if cfg.Telegram.Enabled {
|
||||||
var err error
|
tgBot = telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, loc, logger)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP server
|
// HTTP server
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ tier7 = 8
|
|||||||
[telegram]
|
[telegram]
|
||||||
|
|
||||||
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
||||||
|
# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть
|
||||||
|
# работает, а бот повторяет авторизацию в фоне, пока не появится связь.
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
||||||
# Токен бота, полученный от @BotFather.
|
# Токен бота, полученный от @BotFather.
|
||||||
|
|||||||
+71
-14
@@ -2,8 +2,10 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,9 +19,16 @@ import (
|
|||||||
"git.vakhrushev.me/av/remembos/internal/search"
|
"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.
|
// Bot sends a daily memory via Telegram.
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
api *tgbotapi.BotAPI
|
token string
|
||||||
|
api *tgbotapi.BotAPI // назначается в connect, до этого бот не работает
|
||||||
service *memory.Service
|
service *memory.Service
|
||||||
client *memos.Client
|
client *memos.Client
|
||||||
chatID int64
|
chatID int64
|
||||||
@@ -30,7 +39,8 @@ type Bot struct {
|
|||||||
allowLoadMore bool
|
allowLoadMore bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBot creates a new Telegram bot.
|
// NewBot creates a new Telegram bot. В сеть не ходит: авторизация происходит
|
||||||
|
// в Run, чтобы недоступный api.telegram.org не мешал старту приложения.
|
||||||
func NewBot(
|
func NewBot(
|
||||||
cfg config.TelegramConfig,
|
cfg config.TelegramConfig,
|
||||||
service *memory.Service,
|
service *memory.Service,
|
||||||
@@ -39,22 +49,15 @@ func NewBot(
|
|||||||
allowLoadMore bool,
|
allowLoadMore bool,
|
||||||
loc *time.Location,
|
loc *time.Location,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) (*Bot, error) {
|
) *Bot {
|
||||||
api, err := tgbotapi.NewBotAPI(cfg.Token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create telegram bot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub := publicURL
|
pub := publicURL
|
||||||
if pub == "" {
|
if pub == "" {
|
||||||
pub = memosURL
|
pub = memosURL
|
||||||
}
|
}
|
||||||
pub = strings.TrimRight(pub, "/")
|
pub = strings.TrimRight(pub, "/")
|
||||||
|
|
||||||
logger.Info("telegram bot authorized", "username", api.Self.UserName)
|
|
||||||
|
|
||||||
return &Bot{
|
return &Bot{
|
||||||
api: api,
|
token: cfg.Token,
|
||||||
service: service,
|
service: service,
|
||||||
client: client,
|
client: client,
|
||||||
chatID: cfg.ChatID,
|
chatID: cfg.ChatID,
|
||||||
@@ -63,11 +66,15 @@ func NewBot(
|
|||||||
loc: loc,
|
loc: loc,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
allowLoadMore: allowLoadMore,
|
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) {
|
func (b *Bot) Run(ctx context.Context) {
|
||||||
|
if !b.connect(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if b.allowLoadMore {
|
if b.allowLoadMore {
|
||||||
go b.listenForCommands(ctx)
|
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.
|
// nextSendTime returns the next occurrence of sendAt in the configured timezone.
|
||||||
func (b *Bot) nextSendTime() time.Time {
|
func (b *Bot) nextSendTime() time.Time {
|
||||||
now := time.Now().In(b.loc)
|
now := time.Now().In(b.loc)
|
||||||
|
|||||||
Reference in New Issue
Block a user