From 9d374a97cd28f0e2e60493cda738007bdb2a5e34 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 12 Feb 2026 17:36:57 +0300 Subject: [PATCH] add telegram bot --- cmd/remembos/main.go | 11 ++ go.mod | 1 + go.sum | 2 + internal/config/config.go | 14 +++ internal/memos/client.go | 37 ++++++ internal/telegram/bot.go | 240 ++++++++++++++++++++++++++++++++++++ internal/telegram/format.go | 146 ++++++++++++++++++++++ 7 files changed, 451 insertions(+) create mode 100644 internal/telegram/bot.go create mode 100644 internal/telegram/format.go diff --git a/cmd/remembos/main.go b/cmd/remembos/main.go index 05e981a..77d8c58 100644 --- a/cmd/remembos/main.go +++ b/cmd/remembos/main.go @@ -15,6 +15,7 @@ import ( "git.vakhrushev.me/av/remembos/internal/memos" "git.vakhrushev.me/av/remembos/internal/search" "git.vakhrushev.me/av/remembos/internal/storage" + "git.vakhrushev.me/av/remembos/internal/telegram" "git.vakhrushev.me/av/remembos/internal/web" ) @@ -83,6 +84,16 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + // Telegram bot + if cfg.Telegram.Token != "" { + tgBot, err := telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, loc, logger) + if err != nil { + logger.Error("failed to create telegram bot", "error", err) + os.Exit(1) + } + go tgBot.Run(ctx) + } + go func() { logger.Info("starting server", "addr", cfg.Web.Listen) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/go.mod b/go.mod index fe6ee92..031c5c0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 974170d..01fb219 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/config/config.go b/internal/config/config.go index 48772b6..b4b3ebe 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,10 +3,13 @@ package config import ( "fmt" "os" + "regexp" "github.com/BurntSushi/toml" ) +var timeFormatRe = regexp.MustCompile(`^\d{2}:\d{2}$`) + type Config struct { Memos MemosConfig `toml:"memos"` Database DatabaseConfig `toml:"database"` @@ -119,6 +122,9 @@ func setDefaults(cfg *Config) { if cfg.General.LogLevel == "" { cfg.General.LogLevel = "info" } + if cfg.Telegram.SendAt == "" { + cfg.Telegram.SendAt = "09:00" + } } func validate(cfg *Config) error { @@ -131,5 +137,13 @@ func validate(cfg *Config) error { if sum := cfg.Search.TierWeights.Sum(); sum != 100 { return fmt.Errorf("search.tier_weights must sum to 100, got %d", sum) } + if cfg.Telegram.Token != "" { + if cfg.Telegram.ChatID == 0 { + return fmt.Errorf("telegram.chat_id is required when telegram.token is set") + } + if !timeFormatRe.MatchString(cfg.Telegram.SendAt) { + return fmt.Errorf("telegram.send_at must be in HH:MM format, got %q", cfg.Telegram.SendAt) + } + } return nil } diff --git a/internal/memos/client.go b/internal/memos/client.go index a1e4d64..d03225b 100644 --- a/internal/memos/client.go +++ b/internal/memos/client.go @@ -67,6 +67,43 @@ func (c *Client) ListMemos(ctx context.Context, filter string, pageSize int, pag return &result, nil } +// DownloadAttachment downloads the attachment data as bytes. +func (c *Client) DownloadAttachment(ctx context.Context, att Attachment) ([]byte, error) { + var reqURL string + var needsAuth bool + + if att.ExternalLink != "" { + reqURL = att.ExternalLink + } else { + reqURL = fmt.Sprintf("%s/file/%s/%s", c.baseURL, att.Name, att.Filename) + needsAuth = true + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if needsAuth { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download attachment %s: status %d", att.Name, resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return data, nil +} + // GetRandomMemo fetches a single memo without any filter (for full fallback). func (c *Client) GetRandomMemo(ctx context.Context) (*Memo, error) { resp, err := c.ListMemos(ctx, "", 1, "") diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go new file mode 100644 index 0000000..21fa39b --- /dev/null +++ b/internal/telegram/bot.go @@ -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 +} diff --git a/internal/telegram/format.go b/internal/telegram/format.go new file mode 100644 index 0000000..75023e5 --- /dev/null +++ b/internal/telegram/format.go @@ -0,0 +1,146 @@ +package telegram + +import ( + "fmt" + "strings" + "time" + + "git.vakhrushev.me/av/remembos/internal/memos" + "git.vakhrushev.me/av/remembos/internal/search" +) + +const ( + maxMessageLen = 4096 + maxCaptionLen = 1024 +) + +// formatMemory returns (mainText, captionText) formatted as HTML for Telegram. +// mainText is for sendMessage (up to 4096 chars), captionText for photo captions (up to 1024 chars). +func formatMemory(mem *search.Memory, publicURL string) (mainText, captionText string) { + var b strings.Builder + + // Header: date and "ago" text + b.WriteString(fmt.Sprintf("%s", formatDate(mem.Date))) + if ago := agoText(mem.Date); ago != "" { + b.WriteString(fmt.Sprintf(" (%s)", ago)) + } + b.WriteString("\n\n") + + // Content + b.WriteString(escapeHTML(mem.Memo.Content)) + + // Tags + if len(mem.Memo.Tags) > 0 { + b.WriteString("\n\n") + tags := make([]string, len(mem.Memo.Tags)) + for i, t := range mem.Memo.Tags { + tags[i] = "#" + t + } + b.WriteString(strings.Join(tags, " ")) + } + + // Link to original + memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name) + b.WriteString(fmt.Sprintf("\n\nОригинал", memoURL)) + + full := b.String() + mainText = truncateHTML(full, maxMessageLen) + captionText = truncateHTML(full, maxCaptionLen) + return mainText, captionText +} + +// imageAttachments returns image attachments from the memo. +func imageAttachments(memo *memos.Memo) []memos.Attachment { + var images []memos.Attachment + for _, att := range memo.Attachments { + if att.IsImage() { + images = append(images, att) + } + } + return images +} + +// truncateHTML truncates text to maxLen, cutting at a word/line boundary and adding "...". +func truncateHTML(text string, maxLen int) string { + if len(text) <= maxLen { + return text + } + + // Reserve space for "..." + cut := maxLen - 3 + + // Find last newline or space before cut point + idx := strings.LastIndexAny(text[:cut], "\n ") + if idx <= 0 { + idx = cut + } + + return text[:idx] + "..." +} + +// escapeHTML escapes special HTML characters for Telegram HTML parse mode. +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +var months = [...]string{ + 1: "января", 2: "февраля", 3: "марта", + 4: "апреля", 5: "мая", 6: "июня", + 7: "июля", 8: "августа", 9: "сентября", + 10: "октября", 11: "ноября", 12: "декабря", +} + +func formatDate(t time.Time) string { + return fmt.Sprintf("%d %s %d", t.Day(), months[t.Month()], t.Year()) +} + +func agoText(t time.Time) string { + now := time.Now() + years := now.Year() - t.Year() + monthsDiff := int(now.Month()) - int(t.Month()) + if monthsDiff < 0 { + years-- + monthsDiff += 12 + } + + if years > 0 { + return fmt.Sprintf("%s назад", pluralYears(years)) + } + if monthsDiff > 0 { + return fmt.Sprintf("%s назад", pluralMonths(monthsDiff)) + } + return "" +} + +func pluralYears(n int) string { + mod10 := n % 10 + mod100 := n % 100 + switch { + case mod100 >= 11 && mod100 <= 14: + return fmt.Sprintf("%d лет", n) + case mod10 == 1: + return fmt.Sprintf("%d год", n) + case mod10 >= 2 && mod10 <= 4: + return fmt.Sprintf("%d года", n) + default: + return fmt.Sprintf("%d лет", n) + } +} + +func pluralMonths(n int) string { + mod10 := n % 10 + mod100 := n % 100 + switch { + case mod100 >= 11 && mod100 <= 14: + return fmt.Sprintf("%d месяцев", n) + case mod10 == 1: + return fmt.Sprintf("%d месяц", n) + case mod10 >= 2 && mod10 <= 4: + return fmt.Sprintf("%d месяца", n) + default: + return fmt.Sprintf("%d месяцев", n) + } +}