Files
remembos/internal/telegram/bot.go
T
2026-02-12 17:52:32 +03:00

295 lines
7.0 KiB
Go

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
}