add telegram bot
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewBot creates a new Telegram bot.
|
||||
func NewBot(
|
||||
cfg config.TelegramConfig,
|
||||
service *memory.Service,
|
||||
client *memos.Client,
|
||||
memosURL, publicURL string,
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the scheduling loop. It blocks until ctx is cancelled.
|
||||
func (b *Bot) Run(ctx context.Context) {
|
||||
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
|
||||
}
|
||||
if mem == nil {
|
||||
b.logger.Info("no memory for today, skipping telegram send")
|
||||
return
|
||||
}
|
||||
|
||||
mainText, captionText := formatMemory(mem, b.publicURL)
|
||||
images := imageAttachments(mem.Memo)
|
||||
|
||||
// Try to download images
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user