5 Commits

Author SHA1 Message Date
av 2ca838a50f remove tags from telegram post
release / docker-image (push) Successful in 1m21s
release / goreleaser (push) Successful in 10m20s
2026-02-22 12:25:38 +03:00
av 2c6e71bad5 fix today memory after restart
release / docker-image (push) Successful in 1m8s
release / goreleaser (push) Successful in 10m14s
2026-02-13 09:58:37 +03:00
av 738dfda7a0 docker: add tzdata
release / docker-image (push) Successful in 1m12s
release / goreleaser (push) Successful in 10m16s
2026-02-13 09:49:19 +03:00
av 7e7cdc3713 update go to 1.26.0
release / docker-image (push) Successful in 1m10s
release / goreleaser (push) Successful in 10m27s
2026-02-13 09:38:09 +03:00
av e1ebb83641 add image conversion for telegram 2026-02-13 09:37:54 +03:00
11 changed files with 321 additions and 53 deletions
+3 -3
View File
@@ -12,10 +12,10 @@ COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/remembos RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/remembos
FROM gcr.io/distroless/static:nonroot FROM alpine:3.21
RUN apk add --no-cache imagemagick tzdata
COPY --from=build /out/remembos /remembos COPY --from=build /out/remembos /remembos
USER nonroot:nonroot
ENTRYPOINT ["/remembos"] ENTRYPOINT ["/remembos"]
+1 -1
View File
@@ -66,7 +66,7 @@ func main() {
selector := search.NewSelector(client, store, &cfg.Search, loc, logger) selector := search.NewSelector(client, store, &cfg.Search, loc, logger)
// Memory service // Memory service
memorySvc := memory.NewService(selector, store, loc, logger) memorySvc := memory.NewService(selector, store, client, loc, logger)
// Web handler // Web handler
handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger) handler := web.NewHandler(memorySvc, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
+7 -4
View File
@@ -1,11 +1,15 @@
module git.vakhrushev.me/av/remembos module git.vakhrushev.me/av/remembos
go 1.25.0 go 1.26.0
require (
github.com/BurntSushi/toml v1.6.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
modernc.org/sqlite v1.45.0
)
require ( require (
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -15,5 +19,4 @@ require (
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.45.0 // indirect
) )
+30
View File
@@ -4,8 +4,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -14,14 +18,40 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+66
View File
@@ -0,0 +1,66 @@
package media
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB
// CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit.
// It uses ImageMagick's magick command to convert via stdin/stdout.
// Returns the (possibly compressed) data, updated filename, and any error.
func CompressImage(ctx context.Context, data []byte, filename string) (out []byte, outName string, err error) {
if len(data) <= maxTelegramPhotoSize {
return data, filename, nil
}
// First attempt: just re-encode as JPEG with quality 85
out, err = runMagick(ctx, data, "-quality", "85")
if err != nil {
return nil, "", fmt.Errorf("compress image: %w", err)
}
newFilename := replaceExt(filename, ".jpg")
if len(out) <= maxTelegramPhotoSize {
return out, newFilename, nil
}
// Second attempt: resize to 50% and quality 85
out, err = runMagick(ctx, data, "-resize", "50%", "-quality", "85")
if err != nil {
return nil, "", fmt.Errorf("compress image with resize: %w", err)
}
if len(out) > maxTelegramPhotoSize {
return nil, "", fmt.Errorf("image still too large after compression (%d bytes)", len(out))
}
return out, newFilename, nil
}
func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) {
cmdArgs := append([]string{"-"}, args...)
cmdArgs = append(cmdArgs, "jpeg:-")
cmd := exec.CommandContext(ctx, "magick", cmdArgs...)
cmd.Stdin = bytes.NewReader(data)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("magick: %w", err)
}
return out, nil
}
func replaceExt(filename, newExt string) string {
if i := strings.LastIndex(filename, "."); i >= 0 {
return filename[:i] + newExt
}
return filename + newExt
}
+37 -2
View File
@@ -6,6 +6,7 @@ import (
"sync" "sync"
"time" "time"
"git.vakhrushev.me/av/remembos/internal/memos"
"git.vakhrushev.me/av/remembos/internal/search" "git.vakhrushev.me/av/remembos/internal/search"
"git.vakhrushev.me/av/remembos/internal/storage" "git.vakhrushev.me/av/remembos/internal/storage"
) )
@@ -14,6 +15,7 @@ import (
type Service struct { type Service struct {
selector *search.Selector selector *search.Selector
store *storage.Storage store *storage.Storage
client *memos.Client
loc *time.Location loc *time.Location
logger *slog.Logger logger *slog.Logger
@@ -22,16 +24,18 @@ type Service struct {
cached *search.Memory cached *search.Memory
} }
func NewService(selector *search.Selector, store *storage.Storage, loc *time.Location, logger *slog.Logger) *Service { func NewService(selector *search.Selector, store *storage.Storage, client *memos.Client, loc *time.Location, logger *slog.Logger) *Service {
return &Service{ return &Service{
selector: selector, selector: selector,
store: store, store: store,
client: client,
loc: loc, loc: loc,
logger: logger, logger: logger,
} }
} }
// GetTodayMemory returns the memory for today, caching the result. // GetTodayMemory returns the memory for today, caching the result.
// On cache miss (e.g. after restart), it checks the DB for a memo already shown today.
func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) { func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
today := time.Now().In(s.loc) today := time.Now().In(s.loc)
dayKey := today.Format("2006-01-02") dayKey := today.Format("2006-01-02")
@@ -44,6 +48,38 @@ func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
} }
s.mu.Unlock() s.mu.Unlock()
// Try to restore from DB — check if a memo was already shown today
dayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.loc)
dayEnd := dayStart.AddDate(0, 0, 1)
memoName, tier, err := s.store.GetLastShownToday(ctx, dayStart.Unix(), dayEnd.Unix())
if err != nil {
s.logger.Error("failed to check today's show history", "error", err)
// Fall through to select a new one
}
if memoName != "" {
s.logger.Info("restoring today's memory from history", "memo", memoName)
memo, err := s.client.GetMemo(ctx, memoName)
if err != nil {
s.logger.Error("failed to fetch memo from history, selecting new", "memo", memoName, "error", err)
} else {
mem := &search.Memory{
Memo: memo,
Tier: tier,
Date: memo.DisplayTime,
}
s.mu.Lock()
s.cacheDay = dayKey
s.cached = mem
s.mu.Unlock()
return mem, nil
}
}
s.logger.Info("selecting new memory", "date", dayKey) s.logger.Info("selecting new memory", "date", dayKey)
mem, err := s.selector.Select(ctx, today) mem, err := s.selector.Select(ctx, today)
@@ -58,7 +94,6 @@ func (s *Service) GetTodayMemory(ctx context.Context) (*search.Memory, error) {
if err := s.store.RecordShow(ctx, mem.Memo.Name, mem.Tier); err != nil { if err := s.store.RecordShow(ctx, mem.Memo.Name, mem.Tier); err != nil {
s.logger.Error("failed to record show", "error", err) s.logger.Error("failed to record show", "error", err)
// Non-fatal: still return the memory
} }
s.mu.Lock() s.mu.Lock()
+28
View File
@@ -67,6 +67,34 @@ func (c *Client) ListMemos(ctx context.Context, filter string, pageSize int, pag
return &result, nil return &result, nil
} }
// GetMemo fetches a single memo by its resource name (e.g. "memos/123").
func (c *Client) GetMemo(ctx context.Context, name string) (*Memo, error) {
reqURL := fmt.Sprintf("%s/api/v1/%s", c.baseURL, name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, http.NoBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
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 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("memos API returned %d: %s", resp.StatusCode, body)
}
var memo Memo
if err := json.NewDecoder(resp.Body).Decode(&memo); err != nil {
return nil, fmt.Errorf("decode memo: %w", err)
}
return &memo, nil
}
// DownloadAttachment downloads the attachment data as bytes. // DownloadAttachment downloads the attachment data as bytes.
func (c *Client) DownloadAttachment(ctx context.Context, att Attachment) ([]byte, error) { func (c *Client) DownloadAttachment(ctx context.Context, att Attachment) ([]byte, error) {
var reqURL string var reqURL string
+18
View File
@@ -37,6 +37,24 @@ func (a Attachment) IsImage() bool {
return false return false
} }
// IsVideo returns true if the attachment is a video.
func (a Attachment) IsVideo() bool {
switch a.Type {
case "video/mp4", "video/quicktime", "video/webm", "video/mpeg":
return true
}
return false
}
// IsAudio returns true if the attachment is an audio file.
func (a Attachment) IsAudio() bool {
switch a.Type {
case "audio/mpeg", "audio/ogg", "audio/wav", "audio/mp4", "audio/aac":
return true
}
return false
}
// UID extracts the uid part from "attachments/{uid}". // UID extracts the uid part from "attachments/{uid}".
func (a Attachment) UID() string { func (a Attachment) UID() string {
const prefix = "attachments/" const prefix = "attachments/"
+17
View File
@@ -2,6 +2,8 @@ package storage
import ( import (
"context" "context"
"database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -65,6 +67,21 @@ func (s *Storage) GetShowCounts(ctx context.Context, memoNames []string) (map[st
return result, rows.Err() return result, rows.Err()
} }
// GetLastShownToday returns the memo_name and tier of the most recently shown memo
// within the given time range [from, to). Returns empty string if nothing was shown.
func (s *Storage) GetLastShownToday(ctx context.Context, from, to int64) (memoName string, tier int, err error) {
err = s.db.QueryRowContext(ctx,
`SELECT memo_name, tier FROM show_history WHERE shown_at >= ? AND shown_at < ? ORDER BY shown_at DESC LIMIT 1`,
from, to).Scan(&memoName, &tier)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", 0, nil
}
return "", 0, fmt.Errorf("get last shown today: %w", err)
}
return memoName, tier, nil
}
// RecordShow records that a memo was shown. // RecordShow records that a memo was shown.
func (s *Storage) RecordShow(ctx context.Context, memoName string, tier int) error { func (s *Storage) RecordShow(ctx context.Context, memoName string, tier int) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
+105 -28
View File
@@ -11,6 +11,7 @@ import (
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/remembos/internal/config" "git.vakhrushev.me/av/remembos/internal/config"
"git.vakhrushev.me/av/remembos/internal/media"
"git.vakhrushev.me/av/remembos/internal/memory" "git.vakhrushev.me/av/remembos/internal/memory"
"git.vakhrushev.me/av/remembos/internal/memos" "git.vakhrushev.me/av/remembos/internal/memos"
"git.vakhrushev.me/av/remembos/internal/search" "git.vakhrushev.me/av/remembos/internal/search"
@@ -130,14 +131,22 @@ func (b *Bot) sendMemory(ctx context.Context, mem *search.Memory) {
} }
text := formatMemory(mem, b.publicURL) text := formatMemory(mem, b.publicURL)
images := imageAttachments(mem.Memo) imageAtts, videoAtts, audioAtts := mediaAttachments(mem.Memo)
var downloaded []imageFile var images []mediaFile
if len(images) > 0 { var skipped bool
downloaded = b.downloadImages(ctx, images) if len(imageAtts) > 0 {
images, skipped = b.downloadAndCompressImages(ctx, imageAtts)
} }
if err := b.sendWithRetry(ctx, text, downloaded); err != nil { videos := b.downloadFiles(ctx, videoAtts)
audios := b.downloadFiles(ctx, audioAtts)
if skipped {
text += "\n\nПоказаны не все вложения"
}
if err := b.sendWithRetry(ctx, text, images, videos, audios); err != nil {
b.logger.Error("failed to send telegram message after retries", "error", err) b.logger.Error("failed to send telegram message after retries", "error", err)
} }
} }
@@ -183,35 +192,57 @@ func (b *Bot) handleMore(ctx context.Context) {
b.sendMemory(ctx, mem) b.sendMemory(ctx, mem)
} }
type imageFile struct { type mediaFile struct {
filename string filename string
data []byte data []byte
} }
// downloadImages downloads image attachments, skipping failures. // downloadAndCompressImages downloads image attachments and compresses them if needed.
func (b *Bot) downloadImages(ctx context.Context, attachments []memos.Attachment) []imageFile { // Returns downloaded files and whether any were skipped due to errors.
files := make([]imageFile, 0, len(attachments)) func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos.Attachment) ([]mediaFile, bool) {
files := make([]mediaFile, 0, len(attachments))
var skipped bool
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)
skipped = true
continue
}
data, filename, err := media.CompressImage(ctx, data, att.Filename)
if err != nil {
b.logger.Warn("failed to compress image, skipping", "name", att.Name, "error", err)
skipped = true
continue
}
files = append(files, mediaFile{filename: filename, data: data})
}
return files, skipped
}
// downloadFiles downloads attachments, skipping failures.
func (b *Bot) downloadFiles(ctx context.Context, attachments []memos.Attachment) []mediaFile {
files := make([]mediaFile, 0, len(attachments))
for _, att := range attachments { for _, att := range attachments {
data, err := b.client.DownloadAttachment(ctx, att) data, err := b.client.DownloadAttachment(ctx, att)
if err != nil { if err != nil {
b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err) b.logger.Warn("failed to download attachment, skipping", "name", att.Name, "error", err)
continue continue
} }
files = append(files, imageFile{ files = append(files, mediaFile{filename: att.Filename, data: data})
filename: att.Filename,
data: data,
})
} }
return files return files
} }
// sendWithRetry attempts to send the message with up to 3 retries. // sendWithRetry attempts to send the message with up to 3 retries.
func (b *Bot) sendWithRetry(ctx context.Context, text string, images []imageFile) error { func (b *Bot) sendWithRetry(ctx context.Context, text string, images, videos, audios []mediaFile) error {
backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second} backoffs := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second}
var lastErr error var lastErr error
for attempt := range 3 { for attempt := range 3 {
lastErr = b.send(text, images) lastErr = b.sendWithMedia(text, images, videos, audios)
if lastErr == nil { if lastErr == nil {
return nil return nil
} }
@@ -228,31 +259,59 @@ func (b *Bot) sendWithRetry(ctx context.Context, text string, images []imageFile
return lastErr return lastErr
} }
// send executes the actual Telegram API calls based on the sending strategy. // sendWithMedia executes the actual Telegram API calls for text, images, videos, and audios.
// Long text is split into multiple messages. Images are sent with a caption func (b *Bot) sendWithMedia(text string, images, videos, audios []mediaFile) error {
// only if the full text fits within the caption limit. hasMedia := len(images) > 0 || len(videos) > 0 || len(audios) > 0
func (b *Bot) send(text string, images []imageFile) error {
// Send text
switch { switch {
case len(images) == 0: case !hasMedia:
return b.sendTextParts(splitText(text, maxMessageLen)) return b.sendTextParts(splitText(text, maxMessageLen))
case len(text) <= maxCaptionLen: case len(text) <= maxCaptionLen && len(images) > 0:
// Short text — use as caption on image(s) // Short text — use as caption on image(s)
if len(images) == 1 { if len(images) == 1 {
return b.sendPhoto(images[0], text) if err := b.sendPhoto(images[0], text); err != nil {
return err
}
} else {
if err := b.sendMediaGroup(images, text); err != nil {
return err
}
} }
return b.sendMediaGroup(images, text)
default: default:
// Long text — send text messages first, then images without caption // Long text or no images — send text first, then all media
if err := b.sendTextParts(splitText(text, maxMessageLen)); err != nil { if err := b.sendTextParts(splitText(text, maxMessageLen)); err != nil {
return err return err
} }
// Send images without caption
if len(images) == 1 { if len(images) == 1 {
return b.sendPhoto(images[0], "") if err := b.sendPhoto(images[0], ""); err != nil {
return err
}
} else if len(images) > 1 {
if err := b.sendMediaGroup(images, ""); err != nil {
return err
}
} }
return b.sendMediaGroup(images, "")
} }
// Send videos one by one
for _, v := range videos {
if err := b.sendVideo(v); err != nil {
return err
}
}
// Send audios one by one
for _, a := range audios {
if err := b.sendAudio(a); err != nil {
return err
}
}
return nil
} }
func (b *Bot) sendTextParts(parts []string) error { func (b *Bot) sendTextParts(parts []string) error {
@@ -272,7 +331,7 @@ func (b *Bot) sendText(text string) error {
return err return err
} }
func (b *Bot) sendPhoto(img imageFile, caption string) error { func (b *Bot) sendPhoto(img mediaFile, caption string) error {
photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{ photo := tgbotapi.NewPhoto(b.chatID, tgbotapi.FileBytes{
Name: img.filename, Name: img.filename,
Bytes: img.data, Bytes: img.data,
@@ -285,7 +344,25 @@ func (b *Bot) sendPhoto(img imageFile, caption string) error {
return err return err
} }
func (b *Bot) sendMediaGroup(images []imageFile, caption string) error { func (b *Bot) sendVideo(v mediaFile) error {
video := tgbotapi.NewVideo(b.chatID, tgbotapi.FileBytes{
Name: v.filename,
Bytes: v.data,
})
_, err := b.api.Send(video)
return err
}
func (b *Bot) sendAudio(a mediaFile) error {
audio := tgbotapi.NewAudio(b.chatID, tgbotapi.FileBytes{
Name: a.filename,
Bytes: a.data,
})
_, err := b.api.Send(audio)
return err
}
func (b *Bot) sendMediaGroup(images []mediaFile, caption string) error {
media := make([]interface{}, len(images)) media := make([]interface{}, len(images))
for i, img := range images { for i, img := range images {
photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{ photo := tgbotapi.NewInputMediaPhoto(tgbotapi.FileBytes{
+9 -15
View File
@@ -29,16 +29,6 @@ func formatMemory(mem *search.Memory, publicURL string) string {
// Content // Content
b.WriteString(escapeHTML(mem.Memo.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 // Link to original
memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name) memoURL := fmt.Sprintf("%s/%s", publicURL, mem.Memo.Name)
b.WriteString("\n\n<a href=\"" + memoURL + "\">Оригинал</a>") b.WriteString("\n\n<a href=\"" + memoURL + "\">Оригинал</a>")
@@ -46,15 +36,19 @@ func formatMemory(mem *search.Memory, publicURL string) string {
return b.String() return b.String()
} }
// imageAttachments returns image attachments from the memo. // mediaAttachments splits memo attachments into images, videos, and audios.
func imageAttachments(memo *memos.Memo) []memos.Attachment { func mediaAttachments(memo *memos.Memo) (images, videos, audios []memos.Attachment) {
var images []memos.Attachment
for _, att := range memo.Attachments { for _, att := range memo.Attachments {
if att.IsImage() { switch {
case att.IsImage():
images = append(images, att) images = append(images, att)
case att.IsVideo():
videos = append(videos, att)
case att.IsAudio():
audios = append(audios, att)
} }
} }
return images return
} }
// splitText splits text into chunks of at most maxLen bytes, // splitText splits text into chunks of at most maxLen bytes,