Compare commits
11
Commits
v0.1.1
..
39f4a4dff9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39f4a4dff9
|
||
|
|
8e8cc990c4
|
||
|
|
c3acb50fcc
|
||
|
|
13dafa2f68
|
||
|
|
5bbcfa18bc
|
||
|
|
22fe1fdf98
|
||
|
|
2ca838a50f
|
||
|
|
2c6e71bad5
|
||
|
|
738dfda7a0
|
||
|
|
7e7cdc3713
|
||
|
|
e1ebb83641
|
@@ -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
|
|
||||||
@@ -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
|
|
||||||
+3
-3
@@ -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"]
|
||||||
|
|||||||
@@ -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** — основной язык
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ tier7 = 8
|
|||||||
[telegram]
|
[telegram]
|
||||||
|
|
||||||
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
# Включить Telegram-бот для ежедневной отправки воспоминаний.
|
||||||
|
# Если api.telegram.org недоступен, приложение всё равно стартует: веб-часть
|
||||||
|
# работает, а бот повторяет авторизацию в фоне, пока не появится связь.
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
||||||
# Токен бота, полученный от @BotFather.
|
# Токен бота, полученный от @BotFather.
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
|
// webImageMaxSide — максимальная сторона картинки, отдаваемой на веб-страницу.
|
||||||
|
// Экран всё равно не покажет больше, а исходники с телефона весят десятки мегабайт.
|
||||||
|
const webImageMaxSide = 1600
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
-1
@@ -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"`
|
||||||
@@ -37,6 +36,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/"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+176
-42
@@ -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"
|
||||||
@@ -11,14 +13,22 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Интервалы повторной авторизации в 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
|
||||||
@@ -29,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,
|
||||||
@@ -38,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,
|
||||||
@@ -62,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)
|
||||||
}
|
}
|
||||||
@@ -86,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)
|
||||||
@@ -130,14 +188,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 +249,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 +316,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 +388,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 +401,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{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
поэтому оно больше не доступно ни для фильтрации, ни для отображения.
|
||||||
|
|
||||||
### Ограничения
|
### Ограничения
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user