diff --git a/cmd/remembos/main.go b/cmd/remembos/main.go index d97db85..d098272 100644 --- a/cmd/remembos/main.go +++ b/cmd/remembos/main.go @@ -69,7 +69,7 @@ func main() { memorySvc := memory.NewService(selector, store, loc, logger) // Web handler - handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, logger) + handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger) // HTTP server srv := &http.Server{ @@ -86,7 +86,7 @@ func main() { // Telegram bot if cfg.Telegram.Enabled { - tgBot, err := telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, loc, logger) + tgBot, err := telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, loc, logger) if err != nil { logger.Error("failed to create telegram bot", "error", err) os.Exit(1) diff --git a/config.dist.toml b/config.dist.toml index 516676b..1692d21 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -118,3 +118,7 @@ timezone = "Europe/Moscow" # Уровень логирования: debug, info, warn, error. log_level = "info" + +# Разрешить загрузку дополнительных воспоминаний (кнопка на веб-странице, /more в Telegram). +# Полезно для тестирования. Каждое загруженное воспоминание записывается в историю показов. +allow_load_more = false diff --git a/internal/config/config.go b/internal/config/config.go index 44f41f5..b655b76 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,8 +68,9 @@ type WebConfig struct { } type GeneralConfig struct { - Timezone string `toml:"timezone"` - LogLevel string `toml:"log_level"` + Timezone string `toml:"timezone"` + LogLevel string `toml:"log_level"` + AllowLoadMore bool `toml:"allow_load_more"` } func Load(path string) (*Config, error) { diff --git a/internal/memory/service.go b/internal/memory/service.go index f151713..c8a6112 100644 --- a/internal/memory/service.go +++ b/internal/memory/service.go @@ -68,3 +68,33 @@ func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) { return mem, nil } + +// LoadNewMemory selects a new memory ignoring the cache, records the show, +// and updates the cache so that subsequent GetTodayMemory calls return it. +func (s *Service) LoadNewMemory(ctx context.Context) (*search.Memory, error) { + today := time.Now().In(s.loc) + dayKey := today.Format("2006-01-02") + + s.logger.Info("loading additional memory", "date", dayKey) + + mem, err := s.selector.Select(ctx, today) + if err != nil { + return nil, err + } + + if mem == nil { + s.logger.Warn("no memory found for load more") + return nil, nil + } + + if err := s.store.RecordShow(ctx, mem.Memo.Name, mem.Tier); err != nil { + s.logger.Error("failed to record show", "error", err) + } + + s.mu.Lock() + s.cacheDay = dayKey + s.cached = mem + s.mu.Unlock() + + return mem, nil +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 21fa39b..4d2ae99 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -12,18 +12,20 @@ import ( "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 + 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. @@ -32,6 +34,7 @@ func NewBot( service *memory.Service, client *memos.Client, memosURL, publicURL string, + allowLoadMore bool, loc *time.Location, logger *slog.Logger, ) (*Bot, error) { @@ -49,19 +52,24 @@ func NewBot( 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, + 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) @@ -105,15 +113,20 @@ func (b *Bot) sendDaily(ctx context.Context) { 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 for today, skipping telegram send") + b.logger.Info("no memory to send, skipping") 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) @@ -124,6 +137,47 @@ func (b *Bot) sendDaily(ctx context.Context) { } } +// 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 diff --git a/internal/web/handler.go b/internal/web/handler.go index 40dc235..bf7c171 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -31,6 +31,7 @@ type memoryData struct { MemoURL string Tier int ShowCount int + AllowLoadMore bool } type errorData struct { @@ -38,27 +39,30 @@ type errorData struct { } type Handler struct { - service *memory.Service - logger *slog.Logger - mux *http.ServeMux - memosURL string // internal Memos URL (for attachment files) - publicURL string // public Memos URL (for memo links) + service *memory.Service + logger *slog.Logger + mux *http.ServeMux + memosURL string // internal Memos URL (for attachment files) + publicURL string // public Memos URL (for memo links) + allowLoadMore bool } -func NewHandler(service *memory.Service, memosURL, publicURL string, logger *slog.Logger) *Handler { +func NewHandler(service *memory.Service, memosURL, publicURL string, allowLoadMore bool, logger *slog.Logger) *Handler { pub := publicURL if pub == "" { pub = memosURL } h := &Handler{ - service: service, - memosURL: strings.TrimRight(memosURL, "/"), - publicURL: strings.TrimRight(pub, "/"), - logger: logger, - mux: http.NewServeMux(), + service: service, + memosURL: strings.TrimRight(memosURL, "/"), + publicURL: strings.TrimRight(pub, "/"), + allowLoadMore: allowLoadMore, + logger: logger, + mux: http.NewServeMux(), } h.mux.HandleFunc("GET /", h.handleMemory) h.mux.HandleFunc("GET /health", h.handleHealth) + h.mux.HandleFunc("POST /more", h.handleLoadMore) return h } @@ -116,6 +120,7 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) { MemoURL: memoURL, Tier: mem.Tier, ShowCount: mem.ShowCount, + AllowLoadMore: h.allowLoadMore, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -129,6 +134,20 @@ func (h *Handler) handleHealth(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "ok") } +func (h *Handler) handleLoadMore(w http.ResponseWriter, r *http.Request) { + if !h.allowLoadMore { + http.Error(w, "load more is disabled", http.StatusForbidden) + return + } + + _, err := h.service.LoadNewMemory(r.Context()) + if err != nil { + h.logger.Error("failed to load new memory", "error", err) + } + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + var months = [...]string{ 1: "января", 2: "февраля", 3: "марта", 4: "апреля", 5: "мая", 6: "июня", diff --git a/internal/web/templates/memory.html b/internal/web/templates/memory.html index 9176d3c..5026997 100644 --- a/internal/web/templates/memory.html +++ b/internal/web/templates/memory.html @@ -77,6 +77,21 @@ .meta a:hover { text-decoration: underline; } + .load-more { + margin-top: 1rem; + } + .load-more button { + background: #e8f0fe; + color: #1a73e8; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + } + .load-more button:hover { + background: #d2e3fc; + } @@ -102,6 +117,11 @@ Tier {{.Tier}} · показов: {{.ShowCount}} {{if .MemoURL}} · оригинал{{end}} + {{if .AllowLoadMore}} +
+ +
+ {{end}}