Local-first инструмент для подготовки постов в Telegram-канал. Telegram Mini App открывается из бота, владелец канала собирает контекст (форварды, заметки, ссылки, фото), генерирует через NVIDIA NIM три варианта поста, выбирает один, ставит на расписание или публикует немедленно.
Главные свойства:
- Однопользовательский. Авторизация делается через
OWNER_TELEGRAM_ID— посторонние не пройдут даже в браузере. - Хранилище и расписание держим на одной машине: SQLite + APScheduler в том же процессе, что и FastAPI.
- Генератор реализован как LangGraph-машина состояний из четырёх узлов с устойчивым парсером JSON для ответов NIM.
┌──────────────────────┐ ┌─────────────────────────────────────────────┐
│ Telegram-клиент │ │ FastAPI backend (single process) │
│ (телефон / десктоп) │ │ │
│ │ │ ┌──────────────┐ ┌──────────────────┐ │
│ ┌────────────────┐ │ │ │ /api/* │ │ APScheduler │ │
│ │ aiogram bot │◀─┼─────┼──┤ routers │ │ (interval job) │ │
│ │ /start │ │ TG │ │ + auth dep │ │ publish_due_* │ │
│ │ forwarded msg │ │ Bot │ └──────┬───────┘ └────────┬─────────┘ │
│ └────────────────┘ │ API │ │ services │ │
│ ▲ │ │ ┌──────▼───────────┐ ┌─────▼──────────┐ │
│ │ webhook │ │ │ PostDraftAgent │ │ TelegramPub- │ │
│ │ / │ │ │ (LangGraph) │ │ lisher │ │
│ ┌────────┴───────┐ │ │ │ ├ retrieve_ctx │ │ (aiogram Bot) │ │
│ │ Mini App │◀─┼─────┼──┤ ├ summarize_img │ └────────┬───────┘ │
│ │ (React + Vite) │ │ HTTP│ │ ├ generate_var │ │ │
│ └────────────────┘ │ │ │ └ review_var │ ▼ │
│ │ │ └──────┬──────────┘ ┌───────────────┐ │
└──────────────────────┘ │ │ │ Telegram │ │
│ ┌──────▼──────────┐ │ channel │ │
│ │ SQLAlchemy ORM │ └───────────────┘ │
│ │ → SQLite file │ │
│ └─────────────────┘ │
│ │
│ Outbound: NVIDIA NIM (OpenAI-compatible) │
└─────────────────────────────────────────────┘
User в Mini App ──► POST /api/drafts (status=draft)
──► POST /api/drafts/{id}/media * N (вложения)
──► POST /api/drafts/{id}/generate (status=generating)
│
▼
PostDraftAgent (LangGraph)
retrieve_context ─► summarize_images
─► generate_variants ─► review_variants
│
▼ (3 GeneratedVariant в БД, status=generated)
──► POST /api/drafts/{id}/approve (scheduled / approved)
──► POST /api/drafts/{id}/publish-now (через TelegramPublisher)
ИЛИ APScheduler сам опубликует, когда наступит scheduled_at
Владелец пересылает / пишет боту ─► aiogram router save_context
─► INSERT в context_items (kind=forwarded|manual)
─► Mini App видит чекбокс при создании черновика
TelegramPostWriter/
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI factory, lifespan, статика фронта
│ │ ├── config.py # pydantic Settings → переменные из .env
│ │ ├── database.py # SQLAlchemy engine + get_db dependency
│ │ ├── models.py # ORM-модели: Channel, ContextItem, Draft,
│ │ │ # DraftMedia, GeneratedVariant, Publication
│ │ ├── schemas.py # Pydantic-схемы запроса/ответа API
│ │ ├── auth.py # Проверка Telegram WebApp initData (HMAC)
│ │ ├── auth_api.py # /api/auth/debug — диагностика 401
│ │ ├── api.py # REST-эндпоинты /api/* для Mini App
│ │ ├── services.py # Бизнес-логика: approve / publish / cancel
│ │ ├── agent.py # LangGraph-агент генерации + JSON-repair
│ │ ├── nvidia_client.py # Обёртка над OpenAI-SDK c NVIDIA NIM endpoint
│ │ ├── telegram_publisher.py# Публикация в канал (текст / фото / group)
│ │ ├── storage.py # Сохранение загружаемых файлов
│ │ ├── scheduler.py # APScheduler-джоба process_due_publications
│ │ └── bot.py # aiogram polling bot (/start + сохранение
│ │ # контекста)
│ ├── tests/ # pytest: API, agent, scheduler, auth, bot
│ ├── data/ # SQLite + загруженные вложения (gitignored)
│ └── pyproject.toml
└── frontend/
├── src/
│ ├── main.tsx # bootstrap React, инициализация WebApp SDK
│ ├── App.tsx # Единый экран Mini App
│ ├── api.ts # fetch-обёртка над /api/* + 401-debug
│ ├── telegram.ts # Заголовки initData + получение пользователя
│ ├── types.ts # Типы доменных сущностей
│ └── styles.css
├── index.html # подключает telegram-web-app.js
├── vite.config.ts # дев-прокси, VITE_ALLOWED_HOSTS из env
└── package.json
| Сущность | Назначение |
|---|---|
Channel |
Куда публикуем. telegram_chat_id — @username или числовой chat id. |
ContextItem |
Любой материал для контекста: manual, forwarded, link, db_post. |
Draft |
Запрос на пост: тема, ноты, целевая длина/тон/аудитория, выбранные context ids. |
DraftMedia |
Файлы, прикрепленные к черновику (картинки идут в LLM как image_url data-URL). |
GeneratedVariant |
Один из трёх результатов LangGraph-агента, плюс review-комментарии. |
Publication |
Запланированная или уже опубликованная публикация (есть status и error). |
Связи: Draft → Channel, Draft → DraftMedia (1:N),
Draft → GeneratedVariant (1:N, approved_variant_id указывает на выбранный),
Draft → Publication (1:N, обычно 1).
Статусы черновика: draft → generating → generated → approved | scheduled → published,
плюс терминалы cancelled и failed.
Файл: backend/app/agent.py. Граф собирается в
PostDraftAgent._build_graph() и состоит из четырёх узлов:
retrieve_context— забирает выбранныеContextItemпо id и склеивает в один строковый блок для промпта.summarize_images— для каждого вложения cmime_typeimage/*отправляет в NIM одно vision-сообщение и собирает короткое текстовое описание.generate_variants— рассылает в NIM длинный промпт-шаблон (_GENERATION_PROMPT_TEMPLATE) и ждёт строгий JSON с тремя вариантами. Ответ проходит через цепочку JSON-repair: снять markdown-fence → отрезать до{...}→ заэкранировать управляющие символы внутри строк → если всё плохо, поштучно восстановить целые объекты из массиваvariants.review_variants— отдельный запрос в NIM с задачей вычитать варианты. Результат прикладывается к каждой записиGeneratedVariant.review_notes.
После графа _save_variants удаляет старые варианты у этого черновика,
сохраняет новые и переводит Draft.status в generated.
JSON-repair специально вынесен набор маленьких чистых функций
(_strip_markdown_json_fence, _json_bounds,
_escape_control_chars_inside_json_strings, _extract_json,
_extract_complete_variant_objects, _parse_variants_response,
_normalize_variants) — каждая отдельно покрыта unit-тестами в
tests/test_agent.py.
require_owner в backend/app/auth.py — единая
зависимость, которая повешена на роутер /api/* (backend/app/api.py:32).
Логика:
- Если
OWNER_TELEGRAM_IDне задан — дев-режим, всех пускаем. - Если в заголовке
x-telegram-user-idлежит число, совпадающее с владельцем, — пускаем (использование для отладки из браузера). - Если есть Telegram-WebApp initData (заголовок
x-telegram-init-dataилиAuthorization: tma <init-data>), валидируем подпись HMAC-SHA-256 с секретом изTELEGRAM_BOT_TOKENи сверяемuser.idс владельцем. - Иначе — 401 с понятным сообщением.
Для отладки на проде есть открытый эндпоинт /api/auth/debug — возвращает
снимок того, какие именно проверки прошли/не прошли (без секретов).
Все эндпоинты префиксированы /api. Авторизация — описана выше.
| Метод | Путь | Что делает |
|---|---|---|
| GET | /api/channels |
Список каналов. |
| POST | /api/channels |
Добавить канал. |
| GET | /api/context?kind= |
Список контекст-карточек (последние 100). |
| POST | /api/context |
Создать контекст-карточку вручную (или из ссылки). |
| GET | /api/drafts |
Последние 50 черновиков с вариантами и медиа. |
| POST | /api/drafts |
Создать черновик (без генерации). |
| GET | /api/drafts/{id} |
Детально один черновик. |
| POST | /api/drafts/{id}/media |
Загрузить фото к черновику (multipart). |
| POST | /api/drafts/{id}/generate |
Запустить LangGraph-агента → три варианта. |
| POST | /api/drafts/{id}/approve |
Подтвердить вариант ± задать scheduled_at. |
| POST | /api/drafts/{id}/publish-now |
Опубликовать утверждённый вариант немедленно. |
| POST | /api/drafts/{id}/cancel |
Отменить черновик и связанные публикации. |
| GET | /api/auth/debug |
Диагностика авторизации (без 401). |
| GET | /health |
Проверка живости. |
telegram_publisher.py умеет отправлять:
- только текст;
- одно фото с подписью (если длина ≤ 1024 символов) либо фото + отдельное сообщение с текстом;
- группу до 10 фото (media group) + отдельное текстовое сообщение.
scheduler.py запускает APScheduler с одним
interval-джобом (SCHEDULER_INTERVAL_SECONDS, по умолчанию 30с). Каждое
срабатывание process_due_publications берёт все Publication.status == "scheduled" AND scheduled_at <= now() и публикует их через тот же
publish_draft_now.
Заметка: время хранится наивно (без таймзоны). Это сознательное упрощение для локального MVP, в проде стоит перейти на UTC-aware datetime.
bot.py — отдельный процесс aiogram polling.
/startдля владельца показывает inline-кнопку сWebAppInfo, ведущим наPUBLIC_WEBAPP_URL.- Любое сообщение от владельца (текст, подпись, фото, документ) сохраняется
в
context_itemsсо ссылкой на исходное telegram message id и метадатой. - Любой
channel_postлогируется (полезно, чтобы быстро узнать numericchat_idканала, в котором бот сидит админом).
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp ../.env.example .env # затем заполнить значения
uvicorn app.main:app --reloadОбязательные переменные для боевого запуска:
| Переменная | Зачем |
|---|---|
NVIDIA_API_KEY |
Ключ NVIDIA NIM (нужен для генерации). |
TELEGRAM_BOT_TOKEN |
Токен бота для авторизации Mini App и публикации. |
OWNER_TELEGRAM_ID |
Числовой Telegram id владельца — единственный авторизованный. |
PUBLIC_WEBAPP_URL |
HTTPS-URL, по которому Telegram откроет Mini App в WebView. |
Дополнительно:
| Переменная | По умолчанию |
|---|---|
NVIDIA_BASE_URL |
https://integrate.api.nvidia.com/v1 |
NVIDIA_MODEL |
mistralai/mistral-large-3-675b-instruct-2512 |
DATABASE_URL |
sqlite:///./data/telegram_post_writer.db |
ATTACHMENTS_DIR |
./data/attachments |
SCHEDULER_ENABLED |
true |
SCHEDULER_INTERVAL_SECONDS |
30 |
cd backend
source .venv/bin/activate
python -m app.botБот должен быть админом в каждом канале, куда планируется публиковать.
cd frontend
npm install
npm run dev # http://localhost:5173Локальная разработка без Telegram-клиента: оставьте OWNER_TELEGRAM_ID
пустым в .env, либо задайте в браузере
localStorage["telegram-post-writer:user-id"] равным реальному
OWNER_TELEGRAM_ID — этого хватит для прохождения require_owner.
Чтобы открыть как настоящий Mini App, прокиньте фронт через HTTPS-тоннель
(cloudflared / ngrok), укажите этот хост в VITE_ALLOWED_HOSTS (через
запятую) и поставьте PUBLIC_WEBAPP_URL равным той же ссылке.
cd frontend && npm run build # → frontend/dist/
cd ../backend && uvicorn app.main:appmain.py при старте проверяет frontend_dist_dir; если папка есть, статика
монтируется на /assets, а на все остальные пути отдаётся index.html (SPA
fallback). Полезно для деплоя одним процессом.
cd backend
source .venv/bin/activate
pytest # API + agent + scheduler + auth + botcd frontend
npm run build # tsc + vite build, без отдельного теста-сьюитаВ тестах:
tests/conftest.pyподнимает свежий SQLite вtmp_pathи подменяетget_chat_clientнаFakeChatClient, который возвращает предсказуемый JSON с тремя вариантами и фейковое summary для изображений.tests/test_agent.pyотдельно покрывает JSON-repair: фенсированный JSON c сырыми переводами строк и обрыв ответа посреди массива.tests/test_scheduler.pyгоняетprocess_due_publicationsсFakePublisherи проверяет, что просроченныеPublicationдействительно переходят вpublished.tests/test_auth.pyподписывает фейковый initData и убеждается, что Mini App auth работает.
- Нет вычитки реальной истории канала по ссылке — только то, что владелец переслал боту или вручную ввёл как контекст.
scheduled_atхранится без таймзоны; интерпретируется как локальное время на сервере.- Один пользователь = один владелец. Многопользовательский режим вне скоупа.
- Расписание делается через APScheduler в том же процессе uvicorn — при падении процесса джобы не выполнятся, пока бэкенд не поднимется.
См. LICENSE.