8 Commits
Author SHA1 Message Date
avandClaude Opus 5 39f4a4dff9 web: картинки заметки отдаются через ресайз
Раньше страница ссылалась прямо на файлы Memos, и в браузер уезжали
исходники с телефона на десятки мегабайт. Теперь вложения идут через
эндпоинт /image/{uid}: скачиваем тем же клиентом, что и телеграм-часть,
ужимаем ImageMagick до 1600px по большей стороне и кэшируем результат
в памяти (FIFO на 20 картинок) плюс Cache-Control на час.

GIF и SVG отдаются без конверсии: анимация и вектор в JPEG теряются.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:26:33 +03:00
avandClaude Opus 5 8e8cc990c4 search: фильтры created_ts переведены на timestamp для memos 0.30
В memos 0.30.0 поле created_ts в CEL стало типом timestamp, и сравнение
с Unix-числом перестало компилироваться. Границы диапазона теперь
формируются как timestamp("<RFC3339>") в UTC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:21:53 +03:00
av c3acb50fcc telegram: недоступность api.telegram.org больше не роняет старт
- авторизация бота ушла из NewBot в Run: повторяется в фоне с backoff
  от 30 секунд до 15 минут, пока не появится связь
- отказ по токену (401/404) повторами не лечится — бот просто выключается,
  веб-часть продолжает работать
- у клиента Telegram ограничен только dial (15 c): long polling и заливка
  вложений должны оставаться без общего таймаута
2026-07-25 14:17:59 +03:00
av 13dafa2f68 удалены release-workflow и goreleaser
- образ больше не нужен в Yandex Container Registry: деплой собирает его
  локально и везёт через docker save/load
- своего gitea-раннера пока нет, так что и бинарные релизы никто не собирает
2026-07-25 14:06:06 +03:00
av 5bbcfa18bc добавлен Taskfile, образ собирается по контракту роли app_image
- таск `image` собирает `remembos:$BUILD_ID` (по умолчанию `dev`) — этот
  контракт зовёт роль app_image при деплое из pet-project-server
- рядом обычные команды: build, run, test, lint, tidy, clean
2026-07-25 14:05:58 +03:00
av 22fe1fdf98 fix display date after memos 0.28 release
release / docker-image (push) Successful in 3m52s
release / goreleaser (push) Successful in 11m8s
2026-04-30 16:17:02 +03:00
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
18 changed files with 429 additions and 152 deletions
-58
View File
@@ -1,58 +0,0 @@
name: release
on:
push:
tags:
- 'v*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: 'v2.13.2'
distribution: goreleaser
args: release --clean
env:
GITEA_TOKEN: '${{ secrets.RELEASE_TOKEN }}'
docker-image:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Yandex Cloud Container Registry
uses: docker/login-action@v3
with:
registry: cr.yandex
username: oauth
password: ${{ secrets.YANDEX_CLOUD_OAUTH_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: ./Dockerfile
context: .
push: true
tags: |
cr.yandex/${{ secrets.YANDEX_CLOUD_REGISTRY_ID }}/remembos:${{ github.ref_name }}
cr.yandex/${{ secrets.YANDEX_CLOUD_REGISTRY_ID }}/remembos:latest
platforms: linux/amd64
-37
View File
@@ -1,37 +0,0 @@
version: 2
project_name: remembos
builds:
- id: remembos
main: ./cmd/remembos
binary: remembos
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
archives:
- id: remembos
formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- README.md
- config.dist.toml
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
gitea_urls:
api: https://git.vakhrushev.me/api/v1
download: https://git.vakhrushev.me
+12
View File
@@ -12,6 +12,18 @@
- [Алгоритм поиска воспоминаний](spec/SEARCH.md) - [Алгоритм поиска воспоминаний](spec/SEARCH.md)
## Сборка и деплой
Команды проекта — через [Task](https://taskfile.dev), список: `task --list`.
Деплоем занимается плейбук `playbook-remembos.yml` в `pet-project-server`:
роль `app_image` собирает образ локально этим репозиторием и везёт его на
сервер через `docker save`/`load` — реестр не участвует.
Контракт роли — задача `image`: она получает `BUILD_ID` через окружение и
собирает `remembos:$BUILD_ID`. Без переменной (`task image`) собирается
`remembos:dev` для локальной работы.
## Стек ## Стек
- **Go** — основной язык - **Go** — основной язык
+52
View File
@@ -0,0 +1,52 @@
# yaml-language-server: $schema=https://taskfile.dev/schema.json
#
# Запуск команд проекта через Task (https://taskfile.dev).
# Список задач: `task --list`.
version: '3'
vars:
BINARY: remembos
PKG: ./cmd/remembos
tasks:
default:
desc: Список доступных задач
cmds:
- task --list
silent: true
run:
desc: 'Локальный запуск (нужен ./config.toml)'
cmds:
- go run {{.PKG}} --config ./config.toml
build:
desc: Статический бинарь linux/amd64
cmds:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o {{.BINARY}} {{.PKG}}
test:
desc: Прогон тестов
cmds:
- go test ./...
lint:
desc: Запуск golangci-lint
cmds:
- golangci-lint run
tidy:
desc: go mod tidy
cmds:
- go mod tidy
image:
desc: 'Docker-образ (сборка Go внутри образа). Тег из $BUILD_ID, по умолчанию dev'
cmds:
- docker build -t {{.BINARY}}:${BUILD_ID:-dev} .
clean:
desc: Удалить собранный бинарь
cmds:
- rm -f {{.BINARY}}
+5 -10
View File
@@ -66,21 +66,16 @@ 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, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, logger)
// Telegram bot // Telegram bot. Авторизация в Telegram идёт в фоне (Bot.Run) с повторами:
// недоступный api.telegram.org не должен мешать старту веб-части.
var tgBot *telegram.Bot var tgBot *telegram.Bot
if cfg.Telegram.Enabled { if cfg.Telegram.Enabled {
var err error tgBot = telegram.NewBot(cfg.Telegram, memorySvc, client, cfg.Memos.URL, cfg.Memos.PublicURL, cfg.General.AllowLoadMore, 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)
store.Close()
os.Exit(1) //nolint:gocritic // store.Close() called above; linter doesn't track manual cleanup
}
} }
// HTTP server // HTTP server
+2
View File
@@ -86,6 +86,8 @@ tier7 = 8
[telegram] [telegram]
# Включить Telegram-бот для ежедневной отправки воспоминаний. # Включить Telegram-бот для ежедневной отправки воспоминаний.
# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть
# работает, а бот повторяет авторизацию в фоне, пока не появится связь.
enabled = false enabled = false
# Токен бота, полученный от @BotFather. # Токен бота, полученный от @BotFather.
+34
View File
@@ -10,6 +10,10 @@ import (
const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB
// webImageMaxSide — максимальная сторона картинки, отдаваемой на веб-страницу.
// Экран всё равно не покажет больше, а исходники с телефона весят десятки мегабайт.
const webImageMaxSide = 1600
// CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit. // CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit.
// It uses ImageMagick's magick command to convert via stdin/stdout. // It uses ImageMagick's magick command to convert via stdin/stdout.
// Returns the (possibly compressed) data, updated filename, and any error. // Returns the (possibly compressed) data, updated filename, and any error.
@@ -43,6 +47,36 @@ func CompressImage(ctx context.Context, data []byte, filename string) (out []byt
return out, newFilename, nil return out, newFilename, nil
} }
// ResizeForWeb downscales an image to webImageMaxSide and re-encodes it as JPEG.
// Images smaller than the limit are only re-encoded (ImageMagick's ">" modifier
// never enlarges). Returns the data and the resulting MIME type.
func ResizeForWeb(ctx context.Context, data []byte, mimeType string) ([]byte, string, error) {
// GIF (анимация) и SVG (вектор) при конверсии в JPEG теряют смысл — отдаём как есть.
if !resizableMIME(mimeType) {
return data, mimeType, nil
}
out, err := runMagick(ctx, data,
"-auto-orient",
"-resize", fmt.Sprintf("%dx%d>", webImageMaxSide, webImageMaxSide),
"-quality", "85",
"-strip",
)
if err != nil {
return nil, "", fmt.Errorf("resize image: %w", err)
}
return out, "image/jpeg", nil
}
func resizableMIME(mimeType string) bool {
switch mimeType {
case "image/gif", "image/svg+xml":
return false
}
return true
}
func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) { func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) {
cmdArgs := append([]string{"-"}, args...) cmdArgs := append([]string{"-"}, args...)
cmdArgs = append(cmdArgs, "jpeg:-") cmdArgs = append(cmdArgs, "jpeg:-")
+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.CreateTime,
}
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
-1
View File
@@ -10,7 +10,6 @@ type Memo struct {
Creator string `json:"creator"` Creator string `json:"creator"`
CreateTime time.Time `json:"createTime"` CreateTime time.Time `json:"createTime"`
UpdateTime time.Time `json:"updateTime"` UpdateTime time.Time `json:"updateTime"`
DisplayTime time.Time `json:"displayTime"`
Content string `json:"content"` Content string `json:"content"`
Visibility string `json:"visibility"` Visibility string `json:"visibility"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
+3 -3
View File
@@ -124,7 +124,7 @@ func (s *Selector) tryTiers(
// Build candidates // Build candidates
candidates := make([]candidate, len(filtered)) candidates := make([]candidate, len(filtered))
for i, m := range filtered { for i, m := range filtered {
yearsAgo := today.Year() - m.DisplayTime.Year() yearsAgo := today.Year() - m.CreateTime.Year()
if yearsAgo < 0 { if yearsAgo < 0 {
yearsAgo = 0 yearsAgo = 0
} }
@@ -151,7 +151,7 @@ func (s *Selector) tryTiers(
Tier: tier, Tier: tier,
YearsAgo: picked.yearsAgo, YearsAgo: picked.yearsAgo,
ShowCount: picked.showCount, ShowCount: picked.showCount,
Date: picked.memo.DisplayTime, Date: picked.memo.CreateTime,
}, nil }, nil
} }
@@ -244,7 +244,7 @@ func (s *Selector) fullFallback(ctx context.Context) (*Memory, error) {
return &Memory{ return &Memory{
Memo: picked.memo, Memo: picked.memo,
Tier: 0, Tier: 0,
Date: picked.memo.DisplayTime, Date: picked.memo.CreateTime,
}, nil }, nil
} }
+9 -2
View File
@@ -13,9 +13,16 @@ type DateRange struct {
} }
// BuildCELFilter creates a CEL filter string for a date range using created_ts. // BuildCELFilter creates a CEL filter string for a date range using created_ts.
// Since memos 0.30 created_ts is a CEL timestamp, so plain Unix ints are not
// comparable with it — the bounds must be wrapped in timestamp().
func BuildCELFilter(dr DateRange) string { func BuildCELFilter(dr DateRange) string {
return fmt.Sprintf("created_ts >= %d && created_ts < %d", return fmt.Sprintf("created_ts >= timestamp(%q) && created_ts < timestamp(%q)",
dr.Start.Unix(), dr.End.Unix()) formatCELTimestamp(dr.Start), formatCELTimestamp(dr.End))
}
// formatCELTimestamp renders a time as an RFC3339 literal in UTC.
func formatCELTimestamp(t time.Time) string {
return t.UTC().Format(time.RFC3339)
} }
func isLeapYear(year int) bool { func isLeapYear(year int) bool {
+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,
+71 -14
View File
@@ -2,8 +2,10 @@ package telegram
import ( import (
"context" "context"
"fmt" "errors"
"log/slog" "log/slog"
"net"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -17,9 +19,16 @@ import (
"git.vakhrushev.me/av/remembos/internal/search" "git.vakhrushev.me/av/remembos/internal/search"
) )
// Интервалы повторной авторизации в Telegram: от 30 секунд с удвоением до 15 минут.
const (
connectRetryMin = 30 * time.Second
connectRetryMax = 15 * time.Minute
)
// Bot sends a daily memory via Telegram. // Bot sends a daily memory via Telegram.
type Bot struct { type Bot struct {
api *tgbotapi.BotAPI token string
api *tgbotapi.BotAPI // назначается в connect, до этого бот не работает
service *memory.Service service *memory.Service
client *memos.Client client *memos.Client
chatID int64 chatID int64
@@ -30,7 +39,8 @@ type Bot struct {
allowLoadMore bool allowLoadMore bool
} }
// NewBot creates a new Telegram bot. // NewBot creates a new Telegram bot. В сеть не ходит: авторизация происходит
// в Run, чтобы недоступный api.telegram.org не мешал старту приложения.
func NewBot( func NewBot(
cfg config.TelegramConfig, cfg config.TelegramConfig,
service *memory.Service, service *memory.Service,
@@ -39,22 +49,15 @@ func NewBot(
allowLoadMore bool, allowLoadMore bool,
loc *time.Location, loc *time.Location,
logger *slog.Logger, logger *slog.Logger,
) (*Bot, error) { ) *Bot {
api, err := tgbotapi.NewBotAPI(cfg.Token)
if err != nil {
return nil, fmt.Errorf("create telegram bot: %w", err)
}
pub := publicURL pub := publicURL
if pub == "" { if pub == "" {
pub = memosURL pub = memosURL
} }
pub = strings.TrimRight(pub, "/") pub = strings.TrimRight(pub, "/")
logger.Info("telegram bot authorized", "username", api.Self.UserName)
return &Bot{ return &Bot{
api: api, token: cfg.Token,
service: service, service: service,
client: client, client: client,
chatID: cfg.ChatID, chatID: cfg.ChatID,
@@ -63,11 +66,15 @@ func NewBot(
loc: loc, loc: loc,
logger: logger, logger: logger,
allowLoadMore: allowLoadMore, allowLoadMore: allowLoadMore,
}, nil }
} }
// Run starts the scheduling loop. It blocks until ctx is cancelled. // Run authorizes the bot and starts the scheduling loop. It blocks until ctx is cancelled.
func (b *Bot) Run(ctx context.Context) { func (b *Bot) Run(ctx context.Context) {
if !b.connect(ctx) {
return
}
if b.allowLoadMore { if b.allowLoadMore {
go b.listenForCommands(ctx) go b.listenForCommands(ctx)
} }
@@ -87,6 +94,56 @@ func (b *Bot) Run(ctx context.Context) {
} }
} }
// connect авторизует бота, повторяя попытки до успеха или отмены ctx.
// Сеть до api.telegram.org может быть недоступна (у сервера бывает заблокирован
// исходящий трафик) — это не повод ронять приложение: веб-часть работает без бота,
// а бот подхватится сам, когда связь появится.
func (b *Bot) connect(ctx context.Context) bool {
delay := connectRetryMin
for attempt := 1; ; attempt++ {
api, err := tgbotapi.NewBotAPIWithClient(b.token, tgbotapi.APIEndpoint, newHTTPClient())
if err == nil {
b.api = api
b.logger.Info("telegram bot authorized", "username", api.Self.UserName, "attempt", attempt)
return true
}
// Отказ самого Telegram (неверный токен) повторами не лечится — выключаем бота.
var apiErr *tgbotapi.Error
if errors.As(err, &apiErr) && (apiErr.Code == http.StatusUnauthorized || apiErr.Code == http.StatusNotFound) {
b.logger.Error("telegram bot disabled: token rejected", "error", err)
return false
}
b.logger.Warn("telegram authorization failed, will retry",
// Duration в JSON-логе иначе печатается наносекундами.
"attempt", attempt, "retry_in", delay.String(), "error", err)
select {
case <-ctx.Done():
b.logger.Info("telegram bot stopped before authorization")
return false
case <-time.After(delay):
}
delay = min(delay*2, connectRetryMax)
}
}
// newHTTPClient — клиент для Telegram API. Общего таймаута намеренно нет:
// long polling ждёт до 60 секунд, а заливка вложений может идти долго.
// Ограничено только установление соединения, чтобы неудачная попытка
// авторизации не висела минутами на TCP-ретраях.
func newHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 15 * time.Second,
},
}
}
// nextSendTime returns the next occurrence of sendAt in the configured timezone. // nextSendTime returns the next occurrence of sendAt in the configured timezone.
func (b *Bot) nextSendTime() time.Time { func (b *Bot) nextSendTime() time.Time {
now := time.Now().In(b.loc) now := time.Now().In(b.loc)
-10
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>")
+9 -9
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"git.vakhrushev.me/av/remembos/internal/memory" "git.vakhrushev.me/av/remembos/internal/memory"
"git.vakhrushev.me/av/remembos/internal/memos"
) )
//go:embed templates/*.html //go:embed templates/*.html
@@ -40,26 +41,31 @@ type errorData struct {
type Handler struct { type Handler struct {
service *memory.Service service *memory.Service
client *memos.Client
logger *slog.Logger logger *slog.Logger
mux *http.ServeMux mux *http.ServeMux
publicURL string // public Memos URL (for images and memo links) images *imageCache
publicURL string // public Memos URL (for memo links)
allowLoadMore bool allowLoadMore bool
} }
func NewHandler(service *memory.Service, memosURL, publicURL string, allowLoadMore bool, logger *slog.Logger) *Handler { func NewHandler(service *memory.Service, client *memos.Client, memosURL, publicURL string, allowLoadMore bool, logger *slog.Logger) *Handler {
pub := publicURL pub := publicURL
if pub == "" { if pub == "" {
pub = memosURL pub = memosURL
} }
h := &Handler{ h := &Handler{
service: service, service: service,
client: client,
publicURL: strings.TrimRight(pub, "/"), publicURL: strings.TrimRight(pub, "/"),
allowLoadMore: allowLoadMore, allowLoadMore: allowLoadMore,
logger: logger, logger: logger,
mux: http.NewServeMux(), mux: http.NewServeMux(),
images: newImageCache(),
} }
h.mux.HandleFunc("GET /", h.handleMemory) h.mux.HandleFunc("GET /", h.handleMemory)
h.mux.HandleFunc("GET /health", h.handleHealth) h.mux.HandleFunc("GET /health", h.handleHealth)
h.mux.HandleFunc("GET /image/{uid}", h.handleImage)
h.mux.HandleFunc("POST /more", h.handleLoadMore) h.mux.HandleFunc("POST /more", h.handleLoadMore)
return h return h
} }
@@ -101,13 +107,7 @@ func (h *Handler) handleMemory(w http.ResponseWriter, r *http.Request) {
if !att.IsImage() { if !att.IsImage() {
continue continue
} }
var imgURL string images = append(images, imageData{URL: imageURL(att), Alt: att.Filename})
if att.ExternalLink != "" {
imgURL = att.ExternalLink
} else {
imgURL = fmt.Sprintf("%s/file/%s/%s", h.publicURL, att.Name, att.Filename)
}
images = append(images, imageData{URL: imgURL, Alt: att.Filename})
} }
// Link to original memo: {publicURL}/{memoName} // Link to original memo: {publicURL}/{memoName}
+141
View File
@@ -0,0 +1,141 @@
package web
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"git.vakhrushev.me/av/remembos/internal/media"
"git.vakhrushev.me/av/remembos/internal/memos"
)
// imagePathPrefix — URL, по которому веб отдаёт вложения текущего воспоминания.
// Картинки идут не напрямую из Memos, а через ресайз — как в телеграм-части,
// иначе в браузер уезжают исходники с телефона на десятки мегабайт.
const imagePathPrefix = "/image/"
// imageCacheSize — сколько обработанных картинок держим в памяти,
// чтобы не пережимать их заново на каждую перезагрузку страницы.
const imageCacheSize = 20
// attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}").
func attachmentUID(att memos.Attachment) string {
return strings.TrimPrefix(att.Name, "attachments/")
}
// imageURL builds the local URL for an attachment.
func imageURL(att memos.Attachment) string {
return imagePathPrefix + url.PathEscape(attachmentUID(att))
}
func (h *Handler) handleImage(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("uid")
att, ok := h.findAttachment(r.Context(), uid)
if !ok {
http.NotFound(w, r)
return
}
data, contentType, err := h.imageBytes(r.Context(), att)
if err != nil {
h.logger.Error("failed to prepare image", "name", att.Name, "error", err)
http.Error(w, "не удалось загрузить изображение", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("Cache-Control", "private, max-age=3600")
if _, err := w.Write(data); err != nil {
h.logger.Debug("image write failed", "name", att.Name, "error", err)
}
}
// findAttachment looks the attachment up among the images of the current memory:
// отдаём только то, что сейчас показано на странице, а не любое вложение Memos.
func (h *Handler) findAttachment(ctx context.Context, uid string) (memos.Attachment, bool) {
if uid == "" {
return memos.Attachment{}, false
}
mem, err := h.service.GetTodayMemory(ctx)
if err != nil {
h.logger.Error("failed to get memory for image", "error", err)
return memos.Attachment{}, false
}
if mem == nil {
return memos.Attachment{}, false
}
for _, att := range mem.Memo.Attachments {
if att.IsImage() && attachmentUID(att) == uid {
return att, true
}
}
return memos.Attachment{}, false
}
// imageBytes downloads and resizes the attachment, caching the result.
func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) {
if img, ok := h.images.get(att.Name); ok {
return img.data, img.contentType, nil
}
raw, err := h.client.DownloadAttachment(ctx, att)
if err != nil {
return nil, "", err
}
data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type)
if err != nil {
return nil, "", err
}
h.images.put(att.Name, cachedImage{data: data, contentType: contentType})
return data, contentType, nil
}
type cachedImage struct {
data []byte
contentType string
}
// imageCache — маленький FIFO-кэш обработанных картинок.
type imageCache struct {
mu sync.Mutex
items map[string]cachedImage
order []string
}
func newImageCache() *imageCache {
return &imageCache{items: make(map[string]cachedImage)}
}
func (c *imageCache) get(key string) (cachedImage, bool) {
c.mu.Lock()
defer c.mu.Unlock()
img, ok := c.items[key]
return img, ok
}
func (c *imageCache) put(key string, img cachedImage) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.items[key]; ok {
c.items[key] = img
return
}
if len(c.order) >= imageCacheSize {
delete(c.items, c.order[0])
c.order = c.order[1:]
}
c.items[key] = img
c.order = append(c.order, key)
}
+9 -6
View File
@@ -11,20 +11,23 @@
### Используемый эндпоинт ### Используемый эндпоинт
``` ```
GET /api/v1/memos?filter={CEL}&pageSize={N}&orderBy=display_time+desc GET /api/v1/memos?filter={CEL}&pageSize={N}
``` ```
### Фильтрация по дате ### Фильтрация по дате
API поддерживает CEL-фильтры по полю `created_ts` (Unix timestamp): API поддерживает CEL-фильтры по полю `created_ts`. Начиная с memos 0.30.0 это
значение типа `timestamp`, поэтому границы диапазона задаются через
`timestamp("<RFC3339>")` (в UTC); сравнение с голым Unix-числом больше не
компилируется:
``` ```
created_ts >= 1707696000 && created_ts < 1707782400 created_ts >= timestamp("2024-02-12T00:00:00Z") && created_ts < timestamp("2024-02-13T00:00:00Z")
``` ```
Поле `display_time` (пользовательская дата) недоступно для фильтрации через CEL. В качестве даты заметки используем `create_time` (поле `createTime` в JSON).
Поэтому используем `created_ts` как основу, а `display_time` учитываем при Поле `display_time` удалено из API в memos v0.28.0 (зарезервировано в proto),
пост-обработке, если он отличается от `created_ts`. поэтому оно больше не доступно ни для фильтрации, ни для отображения.
### Ограничения ### Ограничения