Бот ведёт несколько Telegram-каналов на автопилоте. Для каждого канала — своя RSS-лента, свой тон постов и своё расписание публикаций. Для каждого канала по его расписанию бот:
- Читает заданную для канала RSS-ленту.
- Отбрасывает уже обработанные записи (дедуп по id в PostgreSQL, отдельно на каждый канал — одна новость может выйти в разные каналы).
- Отправляет заголовки и краткие описания новых записей в DeepSeek, который выбирает самую важную/актуальную новость (подробнее — в разделе ниже).
- DeepSeek генерирует из неё пост для Telegram в тоне этого канала.
- Бот публикует пост в канал.
Каналы и их настройки управляются через личный чат с ботом (только админ). Канал добавляется присланной ссылкой — бот резолвит чат и проверяет, что он в нём администратор с правом публикации. Команды 📡 Лента, 🎨 Тон, 🕒 Часы, 🚀 Запустить действуют на выбранный (активный) канал.
Чтобы лента была разнообразной и не зацикливалась на одной теме, выбор делается с учётом того, что уже выходило:
- Из ленты берутся свежие записи, ещё не показанные боту (дедуп по id).
- Если свежих нет — fallback: берутся записи из ленты, которые уже были
показаны боту, но ещё не опубликованы в канал (
storage.published_amongисключает только опубликованное). Так ручной запуск почти всегда находит, что опубликовать; порядок при этом может не совпадать с хронологией — это допустимо. Статус «нет новостей» возникает, только когда в ленте всё уже опубликовано. - Из базы поднимаются заголовки, опубликованные за последние 24 часа
(
storage.recent_published_titles— записи сpublished=trueиposted_atв пределах суток, новые первыми). - DeepSeek (
deepseek.pick_most_relevant) получает оба списка — уже опубликованное и кандидатов — и инструкцию: выбрать одну важную и актуальную новость, которая при этом разнообразит ленту по теме и сюжету и не повторяет недавние публикации. Если несколько кандидатов про одно и то же — предпочесть новость из другой тематики. - Ответ — индекс выбранной новости (строгий JSON). При сбое парсинга берётся первый кандидат, чтобы прогон всё равно опубликовал что-то осмысленное.
Окно «последних суток» завязано на время публикации (posted_at), а не на
расписание запусков, поэтому остаётся корректным при любом числе прогонов в день.
aiogram 3— Telegram-бот, управление через reply-клавиатуруfeedparser— разбор RSS/AtomopenaiSDK → DeepSeek (OpenAI-совместимый API) — отбор и генерацияAPScheduler— запуск пайплайна по расписанию (cron)SQLAlchemy (async)+ PostgreSQL — хранение каналов, их настроек и виденных записейAlembic— миграции схемы (применяются на старте)
Управление — только эмодзи-клавиатурой, без slash-команд и без инлайн-кнопок
под сообщениями. Единственная команда — /start (встроенная кнопка «Start» в новом
чате): она показывает справку и клавиатуру. Клавиатура двухуровневая и повторяет
навигацию «выбор канала → настройка канала».
Экран 1 — каналы. Клавиатура показывает по кнопке на каждый канал (тап по
названию открывает канал, Экран 2), а также «➕ Добавить канал», «📊 Статус» и
«❓ Помощь». «➕ Добавить канал» запускает диалог: пришли ссылку (@username,
https://t.me/имя или числовой id) — бот резолвит чат и проверяет, что он
администратор канала с правом публиковать сообщения. Пригласительные ссылки
(t.me/+…) не подходят.
Экран 2 — настройка канала. Открывается при выборе канала и показывает его
текущие настройки, а клавиатура переключается на действия над ним: «🚀 Запустить»,
«👁 Предпросмотр», «📡 Лента», «📝 Сменить ленту», «🎨 Тон», «🕒 Часы»,
«🖼 Только с медиа / 📝 Разрешить текст» (фильтр публикаций, см. ниже),
«⏸ Выключить /
Кнопка «🕒 Часы» открывает сетку 0–23 на клавиатуре: тап включает/выключает час (✅/▫️) для активного канала, изменения применяются сразу; «✅ Готово» — назад. Кнопка «🎨 Тон» открывает выбор пресета подачи поста (текущий помечен ✅).
Фильтр «🖼 Только с медиа». Когда включён, в канал идут лишь новости, у которых есть картинка или видео; чисто текстовые записи пропускаются. Если у новости несколько картинок — публикуются все (альбомом). Переключается по каналу, «📝 Разрешить текст» возвращает к публикации любых постов.
Пресеты тона (как DeepSeek пишет пост): news — сухо и нейтрально (по
умолчанию), expert — факты плюс вывод «почему важно», casual — разговорный,
ironic — с лёгкой иронией, hype — энергично, digest — тезисами. Выбранный
пресет применяется сразу; сравнить вживую удобно через «👁 Предпросмотр».
При каждом запуске сервиса админу приходит сообщение «♻️ Сервис перезапущен»
со сводкой по всем каналам (лента, часы, тон каждого, активный помечен
Поднимает PostgreSQL и бота одной командой.
cp .env.example .env # BOT_TOKEN, ADMIN_ID, DEEPSEEK_API_KEY
docker compose up --build -d
docker compose logs -f botDATABASE_URL для контейнера бота переопределяется в compose на хост db,
так что значение в .env для Docker не важно. Остановить: docker compose down.
Пайплайн .github/workflows/ci.yml:
- Job
test(на каждый push и PR вmain):ruff→WPS→mypy→pytestчерезuv. - Job
build-push(только push вmain, после зелёных тестов): собирает образbotи пушит в GHCR —ghcr.io/troyan-dy/tg_aggс тегамиlatestи<sha>(sha — для отката на конкретный коммит). Сборка идёт в Actions, не на сервере, и мультиарх —linux/amd64(linux-сервер) +linux/arm64(Apple Silicon), через QEMU + buildx.docker pullсам выбирает нужную архитектуру.
Сборка использует кэш слоёв GitHub Actions (type=gha), так что повторные
сборки быстрые. Для пуша в GHCR прав хватает встроенного GITHUB_TOKEN.
Скрипт deploy/auto-update.sh раз в несколько минут
пуллит готовый образ из GHCR (сборки на сервере больше нет) и поднимает
docker-compose.prod.yml. Пересоздаётся лишь сервис
bot (старый контейнер гасится через SIGTERM с grace-period — long polling
останавливается штатно); db не трогается, данные сохраняются. Если ни образ,
ни compose-файл не изменились — скрипт тихо выходит, ничего не делая.
docker-compose.prod.yml отличается от dev-овского только сервисом bot:
вместо build: . — image: ghcr.io/troyan-dy/tg_agg:${BOT_TAG:-latest}
(можно зафиксировать BOT_TAG=<sha> для отката).
- Установлены Docker и плагин compose; пользователь добавлен в группу
docker(чтобыdocker composeработал безsudo— иначе cron не сможет). - Репозиторий склонирован по HTTPS — на сервере он нужен только ради
docker-compose.prod.yml,.envи самого скрипта (код приезжает образом):cd ~ && git clone https://github.com/troyan-dy/tg_agg.git cd ~/tg_agg && cp .env.example .env # заполнить секреты
- Доступ к GHCR. Если пакет приватный — один раз залогиниться (PAT с правом
read:packages); если сделать пакет публичным в настройках GitHub — логин не нужен:echo <PAT> | docker login ghcr.io -u troyan-dy --password-stdin
- Первый запуск — вручную:
docker compose -f docker-compose.prod.yml up -d. Дальше обновления подхватывает cron. - Миграции применяются автоматически на старте (
alembic upgrade headвapp.main), отдельный шаг не нужен — на чистой БД схема создаётся сама.
crontab -eДобавить (подставив своё имя пользователя вместо USER):
PATH=/usr/local/bin:/usr/bin:/bin
*/5 * * * * /home/USER/tg_agg/deploy/auto-update.sh >> /home/USER/tg_agg/deploy/cron.log 2>&1*/5 * * * *— проверка каждые 5 минут.- Строка
PATH=...обязательна: окружение cron урезанное, без неё не найдутсяdocker/git/flock. >> cron.logловит ошибки самого запуска (например, недоступен GitHub). Штатную работу скрипт пишет вdeploy/auto-update.log.
Проверить, что обновления приходят:
tail -f ~/tg_agg/deploy/auto-update.logЗависимостями управляет uv.
# 1. PostgreSQL
docker compose up -d db
# 2. Окружение (uv создаст .venv по pyproject.toml/uv.lock)
uv sync
cp .env.example .env # BOT_TOKEN, ADMIN_ID, DEEPSEEK_API_KEY
# 3. Старт (на старте сам применит миграции: alembic upgrade head)
uv run python -m app.mainПолезное:
uv add <pkg>/uv remove <pkg>— изменить зависимостиuv sync --group dev— поставить и dev-зависимости (SQLite для офлайн-тестов)
Затем в чате с ботом: «➕ Добавить канал» (пришли ссылку) → «📝 Сменить ленту» → настрой часы/тон → «🚀 Запустить».
Схему ведёт Alembic; app.main на старте сам гонит alembic upgrade head, так что
обычно руками ничего делать не нужно. При изменении моделей:
uv run alembic revision --autogenerate -m "что изменилось" # сгенерировать миграцию
uv run alembic upgrade head # применить
uv run alembic downgrade -1 # откатить на шаг
uv run alembic check # проверить, что модели и миграции не разошлисьURL базы и metadata берутся из app.config/app.models (migrations/env.py), а не
из alembic.ini — секретов в ini нет.
Все технические проверки одной командой (Makefile):
make check # ruff → WPS → mypy → pytest (останавливается на первой ошибке)Отдельные цели, если нужно прогнать что-то одно:
make lint # uv run ruff check .
make wps # uv run flake8 . --select=WPS (wemake-python-styleguide, конфиг в setup.cfg)
make types # uv run mypy
make test # uv run pytest -q (покрытие включено, падает ниже порога)В облачных сессиях Claude Code зависимости ставит SessionStart-хук
(.claude/hooks/session-start.sh, uv sync --frozen),
так что make check работает сразу. Локально хук ничего не делает.
- Бот у @BotFather →
BOT_TOKEN. - Свой
ADMIN_ID— у @userinfobot. - Ключ DeepSeek →
DEEPSEEK_API_KEY(https://platform.deepseek.com). - Каналы добавляются уже из чата: для каждого добавь бота администратором с правом публикации и пришли его ссылку через «➕ Добавить канал».
RUN_HOURS— часы публикации (вTIMEZONE), напр.9,13,18, иPOST_TONE— пресет тона (news/expert/casual/ironic/hype/digest): это дефолты для нового канала. Дальше у каждого канала свои часы (кнопка «🕒 Часы») и тон (кнопка «🎨 Тон»).
app/
├── main.py # точка входа: бот + планировщик
├── config.py # настройки из .env
├── db.py # движок, run_migrations (alembic upgrade head)
├── models.py # Channel (канал + его настройки, в т.ч. require_media), SeenItem (дедуп), Setting (активный канал, FSM-состояние)
├── storage.py # доступ к данным: каналы (CRUD), выбор активного, дедуп
├── tone.py # пресеты тона постов (реестр + дефолт)
├── pipeline.py # один прогон канала: RSS → отбор → генерация → публикация
├── bot/
│ ├── handlers.py # управление каналами и настройками (только админ)
│ └── fsm_storage.py # FSM-состояние в БД (переживает рестарт)
├── services/
│ ├── rss.py # загрузка и нормализация ленты (картинки/видео)
│ └── deepseek.py # выбор новости и генерация поста
└── scheduler/
└── worker.py # APScheduler: ежечасный тик, прогоняет «дозревшие» каналы
migrations/ # Alembic: env.py + versions/ (0001_initial — вся схема)