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)
+ }
+}