Files

383 lines
9.1 KiB
Go

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
}