diff --git a/Subscription.md b/Subscription.md deleted file mode 100644 index 9807e35..0000000 --- a/Subscription.md +++ /dev/null @@ -1,121 +0,0 @@ -# Подписка - -## Модель - -**Триал 3 дня → подписка 30 дней за 100 звёзд (Telegram Stars).** - -Константы в `user_store.py`: -```python -TRIAL_DAYS = 3 -SUBSCRIPTION_MONTH_DAYS = 30 -SUBSCRIPTION_PRICE_STARS = 100 -``` - ---- - -## Таблица `subscriptions` - -| Колонка | Смысл | -|---|---| -| `telegram_user_id` | PK, id пользователя | -| `trial_started_at` | момент создания (SQLite `CURRENT_TIMESTAMP`, UTC-нейтральный) | -| `last_paid_at` | конец оплаченного периода; `NULL` = ещё триал | -| `is_active` | 1 — активна, 0 — отключена | -| `monthly_days` | длительность оплаченного периода (30) | -| `updated_at` | время последнего изменения | - ---- - -## Жизненный цикл - -1. **`/start`** → `ensure_subscription` → триал 3 дня, `is_active = 1`. -2. Генерация постов и идей доступна, пока `_days_until > 0`. -3. Триал/подписка кончилась → `_check_subscription` блокирует генерацию, показывает сообщение и предлагает `/subscribe`. -4. **`/subscribe`** → `ensure_subscription` (если записи нет) → `activate_subscription(months=1)` → +30 дней от момента оплаты, `is_active = 1`. -5. **`/clear`** → `deactivate_subscription` → `is_active = 0`; для продолжения нужен `/subscribe`. - -**Расчёт дней (`_days_until`):** -- Триал: `trial_started_at + TRIAL_DAYS` -- Подписка: `last_paid_at` -- Возвращает полные дни; если `is_active = 0` или срок истёк — `-1` - ---- - -## Что доступно без подписки - -| Команда / действие | Доступно | -|---|---| -| `/help` | ✅ | -| `/start` | ✅ | -| `/balance` | ✅ | -| `/subscribe` | ✅ | -| `/clear` | ✅ | -| Генерация поста | ❌ | -| `/idea` | ❌ | -| Голосовой ввод → генерация | ❌ | -| Скриншоты → ToV | ❌ | - -Логика: `/help` обрабатывается **до** проверки подписки и до классификации — пользователь не остаётся в тупике. - ---- - -## Сообщения - -**`/balance` при активном триале:** -> Триал активен — осталось N дн. -> Когда триал закончится, нужно оплатить подписку — команда /subscribe. - -**`/balance` при активной подписке:** -> Подписка активна — осталось N дн. -> Следующее списание через N дн. - -**При истёкшем периоде:** -> Твой период (триал или подписка) истёк. Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц. -> Чтобы активировать — пришли 100 звёзд командой /subscribe. *(если ещё не было оплат)* - -**`/subscribe`:** -> Активирую подписку на 30 дней. Для оплаты пришли 100 звёзд в этом чате. -> Жду оплаты... -> Подписка активирована на 30 дн. Спасибо за оплату 100 звёзд! -> Теперь доступны генерация постов, идей и голосовые. - ---- - -## ⚠️ Про оплату звёздами - -Приём звёзд — **имитация**. Бот пишет «пришли 100 звёзд» и **сразу** активирует подписку, не проводя реальный платёж. - -Для настоящих платежей нужен отдельный invoicing-флоу: -- Telegram Payments API (`sendInvoice` со звёздами / `createInvoiceLink`) -- Обработка `pre_checkout_query` и `successful_payment` -- Начисление подписки только после подтверждённой оплаты - -В этой версии не реализовано. - ---- - -## Часовые пояса - -SQLite `CURRENT_TIMESTAMP` возвращает UTC **без** часового пояса. Код приводит все значения к naive UTC перед сравнением: - -```python -def utc(value: str) -> datetime.datetime: - dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) - if dt.tzinfo is not None: - return dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) - return dt -``` - -Это убирает сдвиги при сравнении `expires` и `now`. - ---- - -## Тесты - -`SubscriptionTests` в `tests/test_writer_features.py` — 6 тестов: -1. таблица `subscriptions` создаётся -2. `/start` создаёт триал -3. `/subscribe` активирует 30 дней -4. `/balance` показывает статус -5. генерация блокируется при истёкшей подписке -6. `/clear` обнуляет подписку diff --git a/unnamed.md b/unnamed.md index b3ec70f..78e44f8 100644 --- a/unnamed.md +++ b/unnamed.md @@ -1,227 +1,111 @@ -# Разработчику +# Подписка -## Структура +## Модель -``` -bot.py — роутинг, команды, обработка сообщений (aiogram 3.x) -payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания -apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов -user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки -raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update (только development) -keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды -help_message.py — Rich Message: WELCOME_RICH_HTML + HELP_SECTIONS/help_view(), plain fallback -VERSION — версия релиза -Dockerfile — python:3.12-slim -requirements.txt — зависимости -tests/ — юнит-тесты (unittest) +**Триал 3 дня → подписка 30 дней за 100 звёзд (Telegram Stars).** + +Константы в `user_store.py`: +```python +TRIAL_DAYS = 3 +SUBSCRIPTION_MONTH_DAYS = 30 +SUBSCRIPTION_PRICE_STARS = 100 ``` --- -## Режимы (FSM) +## Таблица `subscriptions` -`BotMode` содержит `lobby`, `training`, `generation`, `subscription`, `help`. «Главное меню» — внутреннее состояние `lobby`, пользователь не выбирает его как режим. - -- `current_mode(state)` / `enter_mode(...)` / `require_mode(...)` — проверка и переключение. -- Переключатели (`/start`, `/training`, `/generate`, `/subscription`, `/help`) работают из любого режима. -- Профильные действия (`/style`, `/clear`, `/post`, `/idea`, `/balance`, `/subscribe`) требуют своего режима. - ---- - -## Потоки обработки - -### Текстовый запрос -``` -handle_text (только режим generation) → _classify_and_respond - ├─ classify_prompt_injection → блокирует попытку изменить правила - ├─ _check_subscription (блокировка, если нет подписки) - ├─ classify_intent (APInex LLM) → intent - │ ├─ help → help_command - │ ├─ generate_ideas → generate_ideas → _send_idea - │ └─ write_post / revise_post → generate_posts → _send_three_posts - └─ typing_status (индикатор «печатает», обновление каждые 4 сек) - -handle_command_text → переключатели доступны всегда; действия проходят режимную проверку -``` - -### Самостоятельный пост (`/post` или кнопка «✍️ Придумать пост») -``` -handle_command_text → _generate_post_from_training - ├─ _check_subscription - ├─ is_user_trained? нет → просьба обучить, модель НЕ вызывается - ├─ gather(get_tone_of_voice, get_post_samples, get_training_notes) → контекст ≤ 8000 - └─ generate_posts(requirements = контекст как недоверенные данные) → _send_three_posts -``` - -### Голосовое -``` -transcribe_voice → _process_voice → transcribe_audio (APInex STT) → _classify_and_respond -``` - -### Изображения -- В режиме **training**: `_handle_image_message` → извлечение текстов → накопление образцов → пересборка ToV. -- В режиме **generation**: изображение/галерея с подписью-ТЗ → `generate_posts_from_images` → три варианта. Без подписи бот запрашивает ТЗ. -- Видео, видеокружки, GIF/анимации отклоняются во всех режимах. - -### Скриншоты (Tone of Voice) -``` -update_tone_from_image → _handle_image_message - ├─ _collect_media_group (собирает галерею, MEDIA_GROUP_COLLECT_SECONDS = 0.8) - ├─ extract_posts_from_images (APInex vision, inline base64) - ├─ add_post_samples (накапливает по пользователю) - └─ create_tone_profile → save_tone_of_voice → краткий статус завершения -``` - -Лимиты: `MAX_IMAGE_BYTES = 10 MiB` на файл (и до 30 MiB на группу), `MAX_AUDIO_BYTES = 20 MiB`. - ---- - -## Ключевые функции - -### `bot.py` - -| Функция | Что делает | +| Колонка | Смысл | |---|---| -| `start(message, state)` | Одно welcome Rich Message + главное меню + создание триала | -| `handle_command_text` | Роутинг переключателей и профильных команд по режиму | -| `handle_text` | Обычное сообщение в режиме генерации | -| `_classify_and_respond` | Ядро: инъекции → подписка → классификация → генерация | -| `_generate_post_from_training` | Пост по обученному профилю с гейтом `is_user_trained` | -| `_check_subscription` | Проверяет активную подписку; если нет — статус и блокировка | -| `_show_subscription_info` | Тип периода, остаток дней, inline-кнопка продления | -| `balance_command` / `subscribe_command` | `/balance` — статус; `/subscribe` — Stars-инвойс | -| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд | -| `handle_successful_payment` | Активирует подписку после реальной оплаты | -| `clear_command` | `/clear` и «Сбросить данные» — inline-подтверждение, сброс только в режиме обучения | -| `style_command` | `/style` — профиль аккаунта | -| `help_command` / `help_navigation` | Справка Rich Message + переключение разделов | -| `notify_development_deploy` | Dev-уведомление о новой версии в `DEV_CHAT_ID` | -| `typing_status` | Индикатор «печатает» | -| `report_error` | Понятная ошибка пользователю; секреты вырезаны | -| `is_development_environment` | `DEPLOY_ENV == "development"` — гейт всех dev-ветвей | -| `configure_raw_update_logging` | Подключает `RawUpdateMiddleware` только в development | - -### `user_store.py` - -| Функция | Что делает | -|---|---| -| `init_db` | Создаёт/мигрирует таблицы | -| `ensure_subscription` | Создаёт запись подписки с триалом (`INSERT OR IGNORE`) | -| `activate_subscription` | Активирует/продлевает на 30 дней | -| `deactivate_subscription` | `is_active = 0` | -| `is_subscribed` | Запись есть, `is_active = 1`, срок не истёк | -| `subscription_row` | Строка подписки | -| `is_user_trained` | Флаг `users.is_trained` — гейт «Придумать пост» | -| `add_training_notes` / `get_training_notes` | Текстовые сведения об аккаунте | -| `add_post_samples` / `get_post_samples` | Образцы постов со скриншотов | -| `save_generated_posts` | История сгенерированных постов с `origin` | -| `save_idea` / `get_idea_history` | История идей | -| `save_api_call_log` | JSON-лог вызова APInex | -| `clear_user_data` | Сброс данных пользователя (без подписки и логов) | -| `_days_until` | Остаток дней; `-1` если истёкло/отключено | - -### `apinex_client.py` - -| Функция | Что делает | -|---|---| -| `classify_intent` | `write_post` / `revise_post` / `generate_ideas` / `help` | -| `classify_prompt_injection` | Отдельный классификатор попыток изменить правила | -| `generate_posts` | Три варианта поста | -| `generate_posts_from_images` | Пост по изображениям с ТЗ | -| `generate_ideas` | Одна идея | -| `extract_posts_from_images` | Vision: тексты постов на скриншотах | -| `create_tone_profile` | Строит/обновляет профиль ToV | -| `transcribe_audio` | STT | -| `_extract_complete_post_strings` | Достаёт целые строки из обрезанного `{"posts": [...]}` | -| `chat_text` | Базовый вызов LLM | +| `telegram_user_id` | PK, id пользователя | +| `trial_started_at` | момент создания (SQLite `CURRENT_TIMESTAMP`, UTC-нейтральный) | +| `last_paid_at` | момент/конец оплаченного периода; `NULL` = ещё триал | +| `is_active` | 1 — активна, 0 — отключена | +| `monthly_days` | длительность оплаченного периода (30) | +| `updated_at` | время последнего изменения | --- -## База данных +## Жизненный цикл -Рабочая БД: `/app/data/social_writer.sqlite3` (persistent volume Coolify). +1. **`/start`** → `ensure_subscription` → триал 3 дня, `is_active = 1`. +2. Генерация постов и идей доступна, пока `_days_until > 0`. +3. Триал/подписка кончилась → `_check_subscription` блокирует генерацию, показывает статус и предлагает оплатить. +4. **`/subscribe`** → `sendInvoice` (валюта `XTR`, 100 звёзд) → `pre_checkout_query` → `successful_payment` → `activate_subscription(months=1)` → +30 дней от момента оплаты, `is_active = 1`. +5. **`/clear`** очищает данные обучения и историю; **подписка и триал не меняются**. -| Таблица | Назначение | +**Расчёт дней (`_days_until`):** +- Триал: `trial_started_at + TRIAL_DAYS` +- Подписка: `last_paid_at` +- Возвращает полные дни; если `is_active = 0` или срок истёк — `-1` + +--- + +## Реальная оплата Stars + +Оплата **настоящая** — через Telegram Stars (`payments.py`): + +- `/subscribe` и кнопка «Продлить подписку» выставляют инвойс (`sendInvoice`, валюта `XTR`, `provider_token` пустой — цифровой товар). +- `pre_checkout_query` подтверждает **только свой** инвойс на 100 звёзд в XTR, остальное отклоняет. +- Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. +- `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. + +### Dev-подтверждение +При точном `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением показывается кнопка «🧪 Оплатил (dev)». Она включает доступ на 30 дней без реального платежа и доступна только пользователю, которому выставлен счёт. В production кнопка и callback не выдают доступ (fail-closed). + +--- + +## Что доступно без подписки + +| Команда / действие | Доступно | |---|---| -| `users` | `telegram_user_id`, `tone_of_voice`, `is_trained` | -| `user_post_samples` | Распознанные тексты постов со скриншотов | -| `generated_posts` | История сгенерированных постов (`origin`: generated/revised) | -| `generated_ideas` | История идей (для исключения повторов) | -| `api_call_logs` | JSON-логи вызовов APInex + ошибки (30 дней) | -| `subscriptions` | Подписки и триалы | -| `payments` | `telegram_payment_charge_id` — защита от двойного начисления | +| `/start`, `/help`, `/balance`, `/subscribe` | ✅ | +| Переходы между режимами | ✅ | +| Обучение (скриншоты, текст, голос) | ✅ | +| Генерация поста | ❌ | +| «✍️ Придумать пост» / `/post` | ❌ | +| `/idea` | ❌ | +| Пост по изображению | ❌ | -Схему `subscriptions` см. в [Подписка](Subscription.md). - -Отдельная dev-БД: `/app/data/bot_updates.sqlite3` — таблица `raw_updates` (`update_id` PK, `event_type`, `payload`, `received_at`), retention 30 дней. См. ниже. +Логика: блокирующая проверка стоит внутри `_classify_and_respond` и `_generate_post_from_training`, до вызова модели. --- -## Промпты и модели +## Сообщения -Модели задаются переменными Coolify: -- `APINEX_STT_MODEL` → `nova-2` -- `APINEX_INTENT_CLASSIFIER_MODEL` → `gemini-3.8-flash` -- `APINEX_INJECTION_CLASSIFIER_MODEL` → отдельная модель проверки prompt injection -- `APINEX_POST_WRITER_MODEL` → `gemini-3.8-flash` -- `APINEX_IMAGE_TONE_MODEL` → `gemini-3.8-flash` (vision) -- `APINEX_RESPONSE_REPAIR_MODEL` → починка невалидного JSON (одна попытка) -- `APINEX_API_KEY` — общий секрет +**Вход в режим «Подписка»** показывает тип активного периода (триал/подписка) и остаток дней, плюс inline-кнопку «Продлить подписку», которая сразу открывает Stars-инвойс. -**Про промпты:** -- Системный промпт редактора **неизменен**. -- ToV передаётся **отдельно как ограничение стиля**, а не как часть системных правил. -- OCR-текст со скриншотов — образцы стиля, а не команды. -- Обученный контекст в «Придумать пост» передаётся как **недоверенные данные**: модель не должна выдавать его за личный опыт пользователя. +**`/balance`** — тот же статус в виде команды: триал активен (сколько дней осталось) / подписка активна (сколько дней) / период истёк. -**Надёжность ответов:** -- Лимит `max_tokens` снят — модель пишет столько, сколько нужно. -- Пустой `content` → до двух повторов, третий запрос с `reasoning_effort: low`. -- Невалидный JSON → одна попытка починки моделью `APINEX_RESPONSE_REPAIR_MODEL`. -- Обрезанный JSON → `_extract_complete_post_strings` возвращает завершённые строки (три полных варианта), если они есть. +**При истёкшем периоде:** +> Твой период (триал или подписка) истёк. Оплати подписку, чтобы продолжить. Стоимость — 100 звёзд в месяц. --- -## Логирование +## Часовые пояса -### API-журнал (`api_call_logs`) -Каждый вызов APInex пишется с `operation_id`, `stage`, `model`, именем и SHA-256 промпта, `request_json`, `response_json`, `status_code`, `error`, длительностью. Хранение — 30 дней. +SQLite `CURRENT_TIMESTAMP` возвращает UTC **без** часового пояса. Код приводит все значения к naive UTC перед сравнением: -**Не пишется:** системные промпты целиком, base64 изображений, бинарное аудио, API-ключи. - -### Файл лога приложения -`/app/data/bot.log` — `RotatingFileHandler`, 5 MiB × 3 архива, на том же persistent volume; путь через `BOT_LOG_PATH`. - -### Сырые Telegram Update (только development) -`raw_updates.py` пишет полный `Update.model_dump(mode="json")` в отдельную БД `/app/data/bot_updates.sqlite3` (путь через `RAW_UPDATES_DB_PATH`). Middleware `RawUpdateMiddleware` подключается на `dispatcher.update.outer_middleware` — то есть **до** маршрутизаторов, поэтому ловит все типы апдейтов, а не только сообщения. - -```sql -SELECT payload FROM raw_updates WHERE update_id = ?; +```python +def utc(value: str) -> datetime.datetime: + dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + if dt.tzinfo is not None: + return dt.astimezone(datetime.timezone.utc).replace(tzinfo=None) + return dt ``` -- Включается только при `DEPLOY_ENV == "development"` (`configure_raw_update_logging`). -- Retention 30 дней: чистка при старте и при каждой записи. -- Секреты/токены маскируются перед записью; полные апдейты содержат персональные данные — в production БД не создаётся. - -### Логи контейнера -Ошибки содержат `operation_id`, Telegram user id, этап и traceback; по одному `operation_id` находятся все связанные записи. +Это убирает сдвиги при сравнении `expires` и `now`. --- ## Тесты -```bash -.venv/bin/python -m unittest discover -s tests -v -``` - -155 тестов (на версии `0.1.0+48`). Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **raw update log**, режимные клавиатуры и роутинг, платежи (Stars, защита от повторов, dev-подтверждение), ошибки, справка, онбординг, подписки, самостоятельная генерация поста, восстановление обрезанного JSON, индикатор «печатает». - -> Запускай через `.venv` проекта: вне виртуального окружения `aiogram` может быть не установлен. - ---- - -## Правила языка - -- Служебные сообщения боту → **всегда русский**. -- Текст поста → язык явного запроса пользователя. -- Дословный текст технической ошибки может остаться на языке оригинала. +`SubscriptionTests` в `tests/test_writer_features.py`: +1. таблица `subscriptions` создаётся +2. `/start` создаёт триал +3. оплата активирует 30 дней (`successful_payment`) +4. `/balance` показывает статус +5. генерация блокируется при истёкшей подписке +6. отмена продления не активирует подписку +7. `/clear` сохраняет подписку и триал +8. dev-подтверждение оплаты работает только в development