mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)
This commit is contained in:
@@ -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)
|
||||
|
||||
## Открытые пункты
|
||||
- [ ] publisher: медиа из card.media — путь в БД /opt/vesti/media/... не совпадает с монтированием в контейнере (/srv/publisher/media) → send_photo не уходит при Docker-запуске (нужен маппинг путей или передача имени файла)
|
||||
Reference in New Issue
Block a user