package telegram import ( "context" "fmt" "log/slog" "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" ) // Bot sends a daily memory via Telegram. type Bot struct { api *tgbotapi.BotAPI 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. func NewBot( cfg config.TelegramConfig, service *memory.Service, client *memos.Client, memosURL, publicURL string, 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) } pub := publicURL if pub == "" { pub = memosURL } pub = strings.TrimRight(pub, "/") logger.Info("telegram bot authorized", "username", api.Self.UserName) return &Bot{ api: api, service: service, client: client, chatID: cfg.ChatID, sendAt: cfg.SendAt, publicURL: pub, loc: loc, logger: logger, allowLoadMore: allowLoadMore, }, nil } // Run starts the scheduling loop. It blocks until ctx is cancelled. func (b *Bot) Run(ctx context.Context) { 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) } } } // 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 }