Files
remembos/internal/telegram/bot.go
T
av c3acb50fcc telegram: недоступность api.telegram.org больше не роняет старт
- авторизация бота ушла из NewBot в Run: повторяется в фоне с backoff
  от 30 секунд до 15 минут, пока не появится связь
- отказ по токену (401/404) повторами не лечится — бот просто выключается,
  веб-часть продолжает работать
- у клиента Telegram ограничен только dial (15 c): long polling и заливка
  вложений должны оставаться без общего таймаута
2026-07-25 14:17:59 +03:00

440 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package telegram
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/remembos/internal/config"
"git.vakhrushev.me/av/remembos/internal/media"
"git.vakhrushev.me/av/remembos/internal/memory"
"git.vakhrushev.me/av/remembos/internal/memos"
"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 {
token string
api *tgbotapi.BotAPI // назначается в connect, до этого бот не работает
service *memory.Service
client *memos.Client
chatID int64
sendAt string // "HH:MM"
publicURL string
loc *time.Location
logger *slog.Logger
allowLoadMore bool
}
// NewBot creates a new Telegram bot. В сеть не ходит: авторизация происходит
// в Run, чтобы недоступный api.telegram.org не мешал старту приложения.
func NewBot(
cfg config.TelegramConfig,
service *memory.Service,
client *memos.Client,
memosURL, publicURL string,
allowLoadMore bool,
loc *time.Location,
logger *slog.Logger,
) *Bot {
pub := publicURL
if pub == "" {
pub = memosURL
}
pub = strings.TrimRight(pub, "/")
return &Bot{
token: cfg.Token,
service: service,
client: client,
chatID: cfg.ChatID,
sendAt: cfg.SendAt,
publicURL: pub,
loc: loc,
logger: logger,
allowLoadMore: allowLoadMore,
}
}
// 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)
}
for {
next := b.nextSendTime()
delay := time.Until(next)
b.logger.Info("next telegram send scheduled", "at", next.Format("2006-01-02 15:04:05"), "in", delay.Round(time.Second))
select {
case <-ctx.Done():
b.logger.Info("telegram bot stopped")
return
case <-time.After(delay):
b.sendDaily(ctx)
}
}
}
// 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)
parts := strings.SplitN(b.sendAt, ":", 2)
hour := 9
minute := 0
if len(parts) == 2 {
if h, err := strconv.Atoi(parts[0]); err == nil {
hour = h
}
if m, err := strconv.Atoi(parts[1]); err == nil {
minute = m
}
}
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, b.loc)
if !target.After(now) {
target = target.AddDate(0, 0, 1)
}
return target
}
// sendDaily fetches today's memory and sends it.
func (b *Bot) sendDaily(ctx context.Context) {
b.logger.Info("sending daily memory via telegram")
mem, err := b.service.GetTodayMemory(ctx)
if err != nil {
b.logger.Error("failed to get today memory", "error", err)
return
}
b.sendMemory(ctx, mem)
}
// sendMemory formats and sends a memory via Telegram.
func (b *Bot) sendMemory(ctx context.Context, mem *search.Memory) {
if mem == nil {
b.logger.Info("no memory to send, skipping")
return
}
text := formatMemory(mem, b.publicURL)
imageAtts, videoAtts, audioAtts := mediaAttachments(mem.Memo)
var images []mediaFile
var skipped bool
if len(imageAtts) > 0 {
images, skipped = b.downloadAndCompressImages(ctx, imageAtts)
}
videos := b.downloadFiles(ctx, videoAtts)
audios := b.downloadFiles(ctx, audioAtts)
if skipped {
text += "\n\nПоказаны не все вложения"
}
if err := b.sendWithRetry(ctx, text, images, videos, audios); err != nil {
b.logger.Error("failed to send telegram message after retries", "error", err)
}
}
// listenForCommands polls for Telegram updates and handles /more commands.
func (b *Bot) listenForCommands(ctx context.Context) {
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := b.api.GetUpdatesChan(u)
for {
select {
case <-ctx.Done():
return
case update, ok := <-updates:
if !ok {
return
}
if update.Message == nil || !update.Message.IsCommand() {
continue
}
if update.Message.Chat.ID != b.chatID {
continue
}
if update.Message.Command() == "more" {
b.handleMore(ctx)
}
}
}
}
// handleMore loads a new memory and sends it.
func (b *Bot) handleMore(ctx context.Context) {
b.logger.Info("handling /more command")
mem, err := b.service.LoadNewMemory(ctx)
if err != nil {
b.logger.Error("failed to load new memory", "error", err)
return
}
b.sendMemory(ctx, mem)
}
type mediaFile struct {
filename string
data []byte
}
// downloadAndCompressImages downloads image attachments and compresses them if needed.
// Returns downloaded files and whether any were skipped due to errors.
func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos.Attachment) ([]mediaFile, bool) {
files := make([]mediaFile, 0, len(attachments))
var skipped bool
for _, att := range attachments {
data, err := b.client.DownloadAttachment(ctx, att)
if err != nil {
b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err)
skipped = true
continue
}
data, filename, err := media.CompressImage(ctx, data, att.Filename)
if err != nil {
b.logger.Warn("failed to compress image, skipping", "name", att.Name, "error", err)
skipped = true
continue
}
files = append(files, mediaFile{filename: filename, data: data})
}
return files, skipped
}
// downloadFiles downloads attachments, skipping failures.
func (b *Bot) downloadFiles(ctx context.Context, attachments []memos.Attachment) []mediaFile {
files := make([]mediaFile, 0, len(attachments))
for _, att := range attachments {
data, err := b.client.DownloadAttachment(ctx, att)
if err != nil {
b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err)
continue
}
files = append(files, mediaFile{filename: att.Filename, data: data})
}
return files
}
// sendWithRetry attempts to send the message with up to 3 retries.
func (b *Bot) sendWithRetry(ctx context.Context, text string, images, videos, audios []mediaFile) error {
backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second}
var lastErr error
for attempt := range 3 {
lastErr = b.sendWithMedia(text, images, videos, audios)
if lastErr == nil {
return nil
}
b.logger.Warn("telegram send failed", "attempt", attempt+1, "error", lastErr)
if attempt < 2 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoffs[attempt]):
}
}
}
return lastErr
}
// sendWithMedia executes the actual Telegram API calls for text, images, videos, and audios.
func (b *Bot) sendWithMedia(text string, images, videos, audios []mediaFile) error {
hasMedia := len(images) > 0 || len(videos) > 0 || len(audios) > 0
// Send text
switch {
case !hasMedia:
return b.sendTextParts(splitText(text, maxMessageLen))
case len(text) <= maxCaptionLen && len(images) > 0:
// Short text — use as caption on image(s)
if len(images) == 1 {
if err := b.sendPhoto(images[0], text); err != nil {
return err
}
} else {
if err := b.sendMediaGroup(images, text); err != nil {
return err
}
}
default:
// Long text or no images — send text first, then all media
if err := b.sendTextParts(splitText(text, maxMessageLen)); err != nil {
return err
}
// Send images without caption
if len(images) == 1 {
if err := b.sendPhoto(images[0], ""); err != nil {
return err
}
} else if len(images) > 1 {
if err := b.sendMediaGroup(images, ""); err != nil {
return err
}
}
}
// Send videos one by one
for _, v := range videos {
if err := b.sendVideo(v); err != nil {
return err
}
}
// Send audios one by one
for _, a := range audios {
if err := b.sendAudio(a); err != nil {
return err
}
}
return nil
}
func (b *Bot) sendTextParts(parts []string) error {
for _, part := range parts {
if err := b.sendText(part); err != nil {
return err
}
}
return nil
}
func (b *Bot) sendText(text string) error {
msg := tgbotapi.NewMessage(b.chatID, text)
msg.ParseMode = tgbotapi.ModeHTML
msg.DisableWebPagePreview = true
_, err := b.api.Send(msg)
return err
}
func (b *Bot) sendPhoto(img mediaFile, caption string) error {
photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{
Name: img.filename,
Bytes: img.data,
})
if caption != "" {
photo.Caption = caption
photo.ParseMode = tgbotapi.ModeHTML
}
_, err := b.api.Send(photo)
return err
}
func (b *Bot) sendVideo(v mediaFile) error {
video := tgbotapi.NewVideo(b.chatID, tgbotapi.FileBytes{
Name: v.filename,
Bytes: v.data,
})
_, err := b.api.Send(video)
return err
}
func (b *Bot) sendAudio(a mediaFile) error {
audio := tgbotapi.NewAudio(b.chatID, tgbotapi.FileBytes{
Name: a.filename,
Bytes: a.data,
})
_, err := b.api.Send(audio)
return err
}
func (b *Bot) sendMediaGroup(images []mediaFile, caption string) error {
media := make([]interface{}, len(images))
for i, img := range images {
photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{
Name: img.filename,
Bytes: img.data,
})
if i == 0 && caption != "" {
photo.Caption = caption
photo.ParseMode = tgbotapi.ModeHTML
}
media[i] = photo
}
mg := tgbotapi.NewMediaGroup(b.chatID, media)
_, err := b.api.SendMediaGroup(mg)
return err
}