383 lines
9.1 KiB
Go
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
|
|
}
|