package telegram import ( "context" "fmt" "log/slog" "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/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 { fmt.Sscanf(parts[0], "%d", &hour) fmt.Sscanf(parts[1], "%d", &minute) } 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 } mainText, captionText := formatMemory(mem, b.publicURL) images := imageAttachments(mem.Memo) var downloaded []imageFile if len(images) > 0 { downloaded = b.downloadImages(ctx, images) } if err := b.sendWithRetry(ctx, mainText, captionText, downloaded); 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 imageFile struct { filename string data []byte } // downloadImages downloads image attachments, skipping failures. func (b *Bot) downloadImages(ctx context.Context, attachments []memos.Attachment) []imageFile { var files []imageFile 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, imageFile{ 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, mainText, captionText string, images []imageFile) error { backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second} var lastErr error for attempt := range 3 { lastErr = b.send(mainText, captionText, images) 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 } // send executes the actual Telegram API calls based on the sending strategy. func (b *Bot) send(mainText, captionText string, images []imageFile) error { switch { case len(images) == 0: // No images — just text return b.sendText(mainText) case len(images) == 1 && len(captionText) <= maxCaptionLen: // Single image with short caption return b.sendPhoto(images[0], captionText) case len(images) > 1 && len(captionText) <= maxCaptionLen: // Multiple images with short caption return b.sendMediaGroup(images, captionText) case len(images) >= 1 && len(captionText) > maxCaptionLen: // Images with long text — send text first, then images without caption if err := b.sendText(mainText); err != nil { return err } if len(images) == 1 { return b.sendPhoto(images[0], "") } return b.sendMediaGroup(images, "") default: return b.sendText(mainText) } } 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 imageFile, 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) sendMediaGroup(images []imageFile, 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 }