300 lines
7.1 KiB
Go
300 lines
7.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/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
|
|
}
|
|
|
|
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 {
|
|
files := make([]imageFile, 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, 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
|
|
}
|