openspec: архив 14 завершённых change-ов (веб-фиксы, crawler-queue, own-content-hub, publisher-service); спеки влиты в openspec/specs

This commit is contained in:
kpa39l
2026-09-16 17:01:00 +00:00
parent 584582a48c
commit 771f6a8276
88 changed files with 1632 additions and 3 deletions
@@ -0,0 +1,104 @@
# Design: publisher-service
## Approach
Выносим публикацию в Telegram из веб-процесса в изолированный FastAPI-микросервис.
Сервис — единственная точка, которая знает токен бота (прокси), каналы и Bot API.
Остальные компоненты (веб, в будущем cron/боты) вызывают его по HTTP.
Схема:
```
vesti-web (:8400) ──POST /api/v1/publish──▶ publisher-service (:8410) ──Bot API (SOCKS5 127.0.0.1:1080)──▶ Telegram
approve (человек) │ VESTI_BOT_TOKEN, VESTI_BOT_CHANNELS
▼
Telegram: @dedinit_vesti (+ другие каналы)
```
## Files
```bash
# Новый сервис
services/publisher/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI: POST /api/v1/publish, GET /healthz
│ ├── config.py # env: VESTI_BOT_TOKEN, TG_PROXY, VESTI_BOT_CHANNELS, LISTEN_PORT
│ ├── telegram.py # Bot API клиент (httpx + SOCKS5): send, get_views
│ └── channels.py # разбор списка каналов (VESTI_BOT_CHANNELS)
├── requirements.txt # fastapi, uvicorn, httpx[socks], pydantic, python-dotenv
├── Dockerfile # python:slim, non-root, read-only fs
├── docker-compose.yml # сервис, порт 8410, healthcheck, env из .env
├── .env.example # без секретов
└── README.md # API, порты, запуск, безопасность
# Изменения
web/app.py # approve → HTTP POST в publisher-service (вместо import publisher.bot)
.env.example # + VESTI_BOT_CHANNELS, TG_PROXY
STATUS.md / TODO.md / WALKTHROUGH.md # статус
```
## Data / Config
```bash
# .env (реальные значения; НЕ коммитить)
VESTI_BOT_TOKEN=<токен @dedinit_controller_bot> # уже в .env
TG_PROXY=socks5://127.0.0.1:1080 # уже есть
VESTI_BOT_CHANNELS=@dedinit_vesti # список каналов, разделитель запятая
PUBLISHER_PORT=8410
```
## API
### POST /api/v1/publish
```json
{
"card": {"text": "...", "media": "/path/to/photo.jpg", "direction": "linux", "lang": "ru"},
"channels": ["@dedinit_vesti"] // опционально; default = VESTI_BOT_CHANNELS
}
```
Ответ 200:
```json
{
"ok": true,
"results": {
"@dedinit_vesti": {"message_id": 123, "media_message_id": 122, "views": 0}
},
"dry_run": false
}
```
Ошибки: 400 (невалидный card), 502 (Bot API / прокси недоступен), 403 (бот не админ).
### GET /healthz
```json
{"status": "ok", "bot": "@dedinit_controller_bot", "proxy": "socks5://127.0.0.1:1080", "channels": ["@dedinit_vesti"]}
```
Healthcheck: `curl -f http://127.0.0.1:8410/healthz`.
## Commands
```bash
cd /opt/vesti
# dev (без Docker):
.venv/bin/pip install -r services/publisher/requirements.txt
.venv/bin/uvicorn services.publisher.app.main:app --host 127.0.0.1 --port 8410
curl -s http://127.0.0.1:8410/healthz
# prod (Docker):
docker compose -f services/publisher/docker-compose.yml up -d --build
curl -s http://127.0.0.1:8410/healthz
curl -s -X POST http://127.0.0.1:8410/api/v1/publish \
-H 'Content-Type: application/json' \
-d '{"card": {"text": "<b>Тест</b>", "direction": "linux", "lang": "ru"}}'
# переключение веба:
# в web/app.py заменить `from publisher.bot import publish_multi` на HTTP-клиент
# (или переменная окружения PUBLISHER_URL=http://127.0.0.1:8410)
```
## Verification
- [ ] `openspec validate publisher-service` → 0 ошибок
- [ ] `curl :8410/healthz` → ok, бот, прокси, каналы
- [ ] POST /api/v1/publish с тестовой карточкой → message_id в @dedinit_vesti
- [ ] Бот не админ / прокси упал → понятная ошибка (4xx/502), веб показывает
- [ ] approve в вебе → реальное сообщение в канале (микросервис вызван по HTTP)
@@ -0,0 +1,66 @@
## Why
Сейчас публикатор встроен в веб-приложение (web/app.py импортирует publisher.bot напрямую
и вызывает publish_multi). Это нарушает изоляцию элементов системы и мешает масштабированию:
- публикация привязана к процессу веба (ошибка Bot API роняет весь approve);
- нет отдельного жизненного цикла (нельзя перезапустить/обновить публикатор отдельно);
- каналы захардкожены шаблоном @dedinit_vesti_<dir>_<lang>_bot, а реальная схема —
«один бот-контроллер, несколько каналов» (на старте один @dedinit_vesti, потом больше);
- нет единой точки входа для публикации из любых компонентов (веб, cron, будущие боты VK/fediverse).
Цель — вынести публикацию в **изолированный микросервис** (отдельный FastAPI-сервис в
контейнере), который вызывается HTTP-запросом при необходимости. Это соответствует
архитектурному решению «сегментировать элементы системы на микросервисы в отдельных
контейнерах» (PRD, раздел 5).
## What Changes
- Новый сервис `publisher/` (или `services/publisher/`): FastAPI-приложение с эндпоинтом
`POST /api/v1/publish` (публикация карточки в один или несколько каналов) и
`GET /healthz` (healthcheck).
- Конфигурация сервиса: `VESTI_BOT_TOKEN` (токен бота-контроллера @dedinit_controller_bot),
`TG_PROXY=socks5://127.0.0.1:1080` (Telegram из РФ доступен только через SOCKS5),
список каналов (сейчас `@dedinit_vesti`, потом несколько) — из .env или отдельного YAML.
- Бот-контроллер: один (id 7765665742, @dedinit_controller_bot), публикует во все каналы,
в которые добавлен администратором.
- `publisher/bot.py` переносится в сервис (логика publish_card/publish_multi/get_views),
но с конфигом каналов вместо шаблона @dedinit_vesti_<dir>_<lang>_bot.
- `web/app.py` больше НЕ импортирует publisher напрямую: вместо publish_multi — HTTP POST
на publisher-service `/api/v1/publish`.
- Dockerfile + docker-compose для сервиса; healthcheck; логирование.
### Не меняется
- Карточка (publisher/card.py) остаётся (формат ≤4096, атрибуция «Дед в АйТи», ссылка на оригинал).
- Режим публикации «черновик на подтверждение» (веб подтверждает → публикация). Без автопостинга.
## Capabilities
### New Capabilities
- `tg-publisher-service`: Изолированный FastAPI-сервис публикации в Telegram:
единая точка `POST /api/v1/publish`, конфиг каналов, прокси SOCKS5 для Bot API,
сбор views, healthcheck.
### Modified Capabilities
- `tg-publisher`: логика публикации переезжает в сервис; вызывающий код (веб) использует HTTP.
- `vesti-web`: approve вызывает publisher-service по HTTP вместо прямого импорта.
- `news-store`: без изменений (бандлы создаёт веб, как раньше).
## Impact
- Затронутые сервисы/порты: publisher-service — новый порт (напр. 8410, локально);
vesti-web :8400 — меняет способ вызова публикатора (HTTP вместо импорта).
- Файлы:
- новый: services/publisher/ (app/, Dockerfile, docker-compose.yml, requirements.py, README.md)
- изменён: web/app.py (HTTP-вызов), .env.example (VESTI_BOT_CHANNELS, TG_PROXY)
- перенос: publisher/bot.py → services/publisher/ (логика сохраняется)
- Данные: без миграций БД (published.tg_message_id/distributed_dirs остаются).
- Секреты: тот же VESTI_BOT_TOKEN (бот-контроллер); TG_PROXY переиспользуется.
- Прокси: Bot API ТОЛЬКО через SOCKS5 127.0.0.1:1080 (api.telegram.org из РФ недоступен).
- Rollback: вернуть в web/app.py импорт publisher.bot (старый путь); сервис можно не запускать.
## Risks
- SOCKS5-прокси недоступен → сервис не может опубликовать: healthcheck должен это показывать.
- Бот не админ канала → sendMessage 403: сервис возвращает понятную ошибку, веб показывает ее.
- Несколько каналов в будущем: конфиг списком (VESTI_BOT_CHANNELS=@a,@b), fan-out по каналам.
- Рестарт сервисов — только извне (SSH sudo systemctl restart) — правило окружения.
@@ -0,0 +1,79 @@
# Spec: tg-publisher-service
## Purpose
Изолированный FastAPI-сервис публикации карточек в Telegram-каналы через Bot API.
Единая точка вызова для всех компонентов (веб, cron, будущие боты). Один бот-контроллер
публикует во все каналы, в которые добавлен администратором. Telegram доступен только
через SOCKS5-прокси (127.0.0.1:1080).
## ADDED Requirements
### Requirement: Изолированный сервис публикации
Сервис MUST быть отдельным FastAPI-приложением (контейнер), НЕ импортируемым модулем веба.
Он MUST предоставлять `GET /healthz` (статус, бот, прокси, каналы) и
`POST /api/v1/publish` (публикация карточки).
#### Scenario: Healthcheck
- **GIVEN** сервис запущен
- **THEN** `GET /healthz` возвращает 200 с `{"status":"ok","bot":...,"channels":[...]}`
и healthcheck в docker-compose (`curl -f`) проходит
#### Scenario: Публикация карточки
- **GIVEN** POST /api/v1/publish c `card: {text, direction, lang}` и `channels: ["@dedinit_vesti"]`
- **THEN** сервис отправляет сообщение через Bot API в каждый канал и возвращает
`{"ok":true,"results":{"@dedinit_vesti":{"message_id":<int>,"views":0}}}`
### Requirement: Один бот, несколько каналов (конфиг)
Сервис MUST поддерживать список каналов из конфигурации (`VESTI_BOT_CHANNELS`, через запятую).
`channels` в запросе MAY переопределять список. Если бот добавлен в канал администратором —
публикация MUST работать; иначе сервис MUST вернуть понятную ошибку (403).
#### Scenario: Несколько каналов
- **GIVEN** `VESTI_BOT_CHANNELS=@dedinit_vesti,@other_channel` (оба добавлены боту)
- **WHEN** POST /api/v1/publish без поля channels
- **THEN** карточка публикуется в оба канала; results содержит оба message_id
#### Scenario: Бот не админ канала
- **GIVEN** канал, в котором бот не администратор
- **WHEN** публикация в него
- **THEN** сервис возвращает 403 с текстом ошибки Bot API (sendMessage → Forbidden)
### Requirement: Прокси SOCKS5 для Bot API
Все запросы к api.telegram.org MUST идти через прокси из `TG_PROXY=socks5://127.0.0.1:1080`.
Если прокси недоступен — сервис MUST вернуть 502 (не падать).
#### Scenario: Прокси недоступен
- **GIVEN** прокси 127.0.0.1:1080 выключен
- **WHEN** POST /api/v1/publish
- **THEN** сервис возвращает 502 с ошибкой подключения (не 500, не краш)
### Requirement: Сбор views
Сервис MUST предоставлять способ получения просмотров для опубликованных сообщений
(через Bot API getMessage), чтобы веб показывал метрики.
#### Scenario: Views после публикации
- **GIVEN** сообщение опубликовано (message_id получен)
- **WHEN** запрос views для этого message_id
- **THEN** возвращается число просмотров (0 если ещё нет)
### Requirement: Режим подтверждения
Сервис MUST НЕ публиковать автоматически: вызывается только по HTTP-запросу (из веба
после клика «Опубликовать»). Автопостинга по таймеру внутри сервиса НЕТ.
#### Scenario: Нет автопостинга
- **GIVEN** сервис запущен без входящих запросов
- **THEN** ничего не публикуется (процесс только слушает HTTP)
## Modified Requirements (из tg-publisher)
- `publish_multi` и `get_views` переезжают в сервис (HTTP-интерфейс вместо импорта).
- vesti-web: approve вызывает POST /api/v1/publish; ответ используется для
distributed_dirs + views (как раньше, только источник данных — HTTP).
## NOT Requirements
- Не реализуем чтение каналов (краулинг) — это остаётся в tg-crawler (Telethon).
- Не реализуем веб-интерфейс сервиса (только API + healthz).
- Не храним БД в сервисе (вся персистентность — в vesti.db через веб).
- Не делаем автопостинг (см. Requirement выше).
@@ -0,0 +1,51 @@
# Tasks: publisher-service
## 1. Скелет сервиса
- [x] 1.1 Создать services/publisher/ (app/, requirements.txt, .env.example, README.md)
Проверка: `ls services/publisher/app/` → main.py, config.py, telegram.py, channels.py
- [x] 1.2 requirements.txt: fastapi, uvicorn, httpx[socks], pydantic, python-dotenv
Проверка: `.venv/bin/pip install -r services/publisher/requirements.txt` без ошибок
- [x] 1.3 config.py: env VESTI_BOT_TOKEN, TG_PROXY, VESTI_BOT_CHANNELS, PUBLISHER_PORT
Проверка: `python -c "from services.publisher.app.config import Settings; print(Settings().channels)"` → ['@dedinit_vesti']
- Замечание: токен не читался из-за порядка (os.getenv при ClassVar, до load_dotenv) и неверного пути BASE (3x parent → /opt/vesti/services, нужно parents[3] → /opt/vesti). Исправлено: Settings → @dataclass + __post_init__, load_dotenv(override=True), BASE=parents[3].
## 2. Telegram-клиент (Bot API через SOCKS5)
- [x] 2.1 telegram.py: клиент httpx с proxy=socks5://127.0.0.1:1080, методы sendMessage/sendPhoto/getMessage
- [x] 2.2 channels.py: разбор VESTI_BOT_CHANNELS ("@a,@b" → ["@a","@b"])
- [x] 2.3 Обработка ошибок: 403 (бот не админ), сеть (прокси) → 502
## 3. FastAPI-эндпоинты
- [x] 3.1 main.py: GET /healthz (status, bot, proxy, channels)
Проверка: `curl -s :8410/healthz` → ok + бот + каналы. Реальное: {"status":"ok","bot":"dedinit_controller_bot","token_set":true,"proxy":"socks5://127.0.0.1:1080","channels":["@dedinit_vesti"]}
- [x] 3.2 main.py: POST /api/v1/publish (card {text, media?, direction, lang}, channels?) → results по каналам
Проверка: `curl -X POST :8410/api/v1/publish -d '{"card":{"text":"тест","direction":"linux","lang":"ru"}}'` → ok, message_id в @dedinit_vesti. Реальное: {"ok":true,"results":{"@dedinit_vesti":{"message_id":2,...}}}
- [x] 3.3 views: эндпоинт GET /api/v1/views/{channel}/{mid} (в main.py) + поле views в ответе publish (get_views при публикации)
## 4. Контейнеризация
- [x] 4.1 Dockerfile: python:slim, non-root, read-only fs, expose 8410
- [x] 4.2 docker-compose.yml: сервис publisher, порт 127.0.0.1:8410, env из .env, healthcheck curl /healthz
- Проверено 2026-09-09: `docker compose up -d --build` → vesti-publisher Up (healthy), 127.0.0.1:8410, healthz: bot=dedinit_controller_bot, proxy=host.docker.internal.
- Питфолы Docker: (1) пути в compose отсчитываются от services/publisher/ → .env надо `../../.env`, media `../../media`; (2) TG_PROXY=127.0.0.1 в контейнере = сам контейнер → заменить на socks5://host.docker.internal:1080 + extra_hosts host-gateway; (3) load_dotenv(override=True) перебивал env контейнера → override=False (env окружения приоритетнее .env); (4) dataclass-дефолт tg_proxy="socks5://127.0.0.1:1080" был truthy → os.getenv не срабатывал → дефолт сделан пустым.
## 5. Интеграция с вебом
- [x] 5.1 web/app.py: убрать `from publisher.bot import publish_multi/get_views_multi`; вместо них web/publisher_client.py (HTTP POST на PUBLISHER_URL, default http://127.0.0.1:8410)
- Проверка: grep — импорт publisher.bot убран; карточка шлёт card{direction,lang}, сервис берёт каналы из VESTI_BOT_CHANNELS.
- [x] 5.2 Обновить .env.example (VESTI_BOT_CHANNELS, TG_PROXY, PUBLISHER_URL)
- [x] 5.3 Реальный approve через веб: сквозной путь веб → HTTP → publisher(Docker) → канал.
- Проверено 2026-09-09: approve постов 136 и 137 через POST /posts/<id>/approve — 302 → /published, статус published, бандл, tg_message_id=3 и 4 в @dedinit_vesti.
- Веб-баги, найденные при проверке: (1) `raise RedirectResponse(...)` в _require_auth → TypeError (исключение не BaseException) → 500 на /candidates; фикс: HTTPException(303, headers={"Location": "/login"}); (2) tg_message_id для внешних постов искался по направлению (results["linux"]), а ключи results — каналы (@dedinit_vesti) → 0; фикс: брать первый message_id из results.values().
## 6. Проверка и документация
- [x] 6.1 `openspec validate publisher-service` → 0 ошибок (valid)
- [x] 6.2 Реальная публикация тестовой карточки в @dedinit_vesti (бот админ) → message_id в ответе. Реальное: message_id=2, ok.
- Бот добавлен админом канала (подтверждено пользователем).
- [x] 6.3 Обновить STATUS.md / TODO.md — сделано; WALKTHROUGH/PRD — обновлено (см. PRD.md)
## Открытые пункты
- [x] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске — ЗАКРЫТО change fix-media-mount (2026-09-14): контейнер монтирует весь каталог media/, publisher резолвит media/ и media/media/