Files
social-post-writer-bot/README.md
T

375 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Social Post Writer Bot — development
Telegram-бот для генерации постов для соцсетей. Готовит текст по явной просьбе пользователя, адаптируя под его Tone of Voice, и работает по модели подписки (триал 3 дня, затем 100 звёзд в месяц).
---
## Содержание
- [Как работает бот (UX)](#как-работает-бот-ux)
- [Архитектура кода](#архитектура-кода)
- [Поддержка](#поддержка-supportpy)
- [Подписка и монетизация](#подписка-и-монетизация)
- [Данные и хранилище](#данные-и-хранилище)
- [Команды](#команды)
- [Деплой и конфигурация](#деплой-и-конфигурация)
- [Тесты](#тесты)
Версия разработки: `0.1.0+64`.
---
## Синхронизация документации
`README.md` и `IDEA.MD` — синхронизируемые документы проекта. Если меняются сведения о функциях,
текущем поведении, статусе, версии, решениях или процессе разработки в одном файле, внеси те же
смысловые изменения во второй. Перед завершением задачи проверь оба файла и убедись, что общие
сведения совпадают; различаться могут только детали, специфичные для самого документа.
## Как работает бот (UX)
### Dev-подтверждение подписки
Только при точном `DEPLOY_ENV=development` после обычного сообщения о счёте отдельным сообщением
показывается кнопка «🧪 Оплатил (dev)». Она включает доступ на 30 дней без реального платежа и
доступна только пользователю, которому выставлен счёт. В production, при пустом или неизвестном
значении `DEPLOY_ENV` dev-кнопка и callback не выдают доступ.
### Reply-клавиатура
После `/start` отображается одно welcome-сообщение с нижним меню; справка автоматически не вызывается.
В главном меню доступны только пользовательские режимы:
```
🎓 Обучение ✍️ Генерация
💳 Подписка 📖 Помощь
```
«Главное меню» — внутренняя точка входа после `/start` и выхода из режима, а не отдельный режим.
Внутри любого режима reply-клавиатура содержит только «🏠 Главное меню». В «Генерации» добавляется
«✍️ Придумать пост», в «Обучении» — «🗑 Сбросить данные», в «Помощи» — «🆘 Поддержка». При входе показывается короткий
onboarding. В «Подписке» сразу отображаются тип активного периода и оставшиеся дни; inline-кнопка
«Продлить подписку» сразу открывает Stars-инвойс на оплату.
Команды перехода: `/start`, `/training`, `/generate`, `/subscription`, `/help`; `/start` возвращает
в главное меню. Команды `/style` и `/clear` доступны только в обучении; `/post` и `/idea` — в генерации;
`/balance` и `/subscribe` — в подписке. Все команды публикуются через `set_my_commands` при старте.
### Онбординг (`/start`)
`/start` создаёт запись триала, очищает старое состояние FSM и возвращает пользователя в главное меню:
1. Отправляет ровно одно `WELCOME_RICH_HTML`: приветствует пользователя, объясняет накопительное обучение профиля аккаунта и подготовку нескольких черновиков поста без автоматической публикации.
2. Показывает клавиатуру главного меню; справка открывается отдельно кнопкой или командой `/help`.
### Справка (`/help`)
Меню и страницы справки — Rich Message с обычной клавиатурой `InlineKeyboardMarkup` из `keyboards.py`.
По одной кнопке на строку; все разделы остаются доступны на каждой странице, включая текущую.
Выбор редактирует текст Rich Message без смены callback `help:<section>`.
### Поддержка (`support.py`)
Отдельного FSM-режима нет: обращение — расширение режима «Помощь». Пользователь нажимает
**«🆘 Поддержка»** (или `/support`), и до выхода в главное меню все его сообщения уходят оператору.
**Схема:**
```
личка пользователя ──copy_message──▶ топик в SUPPORT_GROUP_ID
▲ │
└────────────copy_message─────────────┘
(любое сообщение оператора)
```
- **Топик создаётся один на пользователя** и переиспользуется: `support_topics` хранит
`telegram_user_id → thread_id` и флаг активности. Название — «Имя · @username» (без username — «Имя · id»).
- Telegram не даёт поставить на топик произвольную картинку, только custom emoji
(`SUPPORT_TOPIC_EMOJI_ID`). Аватарка профиля уходит **первым сообщением-карточкой** в топик
(`get_user_profile_photos` → `send_photo` с подписью «Имя / @username / ID»).
- **Топик создаётся сразу при первом нажатии «🆘 Поддержка»**, а не при первом сообщении.
Карточка со сводкой профиля уходит **один раз** — при создании топика; повторные обращения
пишут в тот же топик и ничего не дублируют. Если топик удалили вручную, при следующем
сообщении он создаётся заново — и сводка уйдёт снова.
- **Сводка профиля в карточке** (`user_store.support_profile_summary`): ID пользователя;
сколько дней пользуется ботом; сколько раз продлевалась подписка; сколько дней осталось;
обучен бот или нет.
- **Разделение чатов по ролям.** Основной роутер (`bot.py`) отфильтрован на
`F.chat.type == "private"` — это роль «работник». Групповые сценарии (роль
«помощник поддержки») живут в `support.py` и регистрируются в диспетчере
**раньше** основного роутера: иначе catch-all `@router.message()` перехватывает
апдейты из групп и отвечает в группу «Выбери режим работы кнопкой ниже»
вместо пересылки сообщения оператору.
Порядок включения роутеров критичен — у aiogram побеждает первый подходящий.
Планируемые роли: **работник** (личка), **помощник поддержки** (группа),
**аналитик** (сбор аналитики), **почтальон** (рассылки).
- **Сообщение пользователя в топике подписывается**: «<Имя>:\n<текст>», а если имени нет —
«<id>:\n<текст>» (`user_signature()`). Caption медиа тоже уходит текстом с подписью,
само медиа копируется как есть.
- Первое сообщение — приветствие оператора. Бот не объясняет, что «передаёт» сообщение:
это разрушает ощущение живого диалога. Пользователь видит случайную фразу из пула
`SUPPORT_GREETINGS` (`activity_phrases.support_greeting()`), например
«Здравствуйте! На связи поддержка. Расскажите, что случилось — разберёмся.»
Формулировки безличные и без «я/бот/оператор», чтобы подходить любому оператору.
Пул можно переопределить без деплоя: `SUPPORT_GREETINGS` в Coolify, разделитель `|`.
- Ответ оператора — любое сообщение в топике от живого участника. Пользователю оно приходит
**с подписью**: «Оператор поддержки:\n<текст>». Подпись задаётся `SUPPORT_OPERATOR_SIGNATURE`
(по умолчанию «Оператор поддержки»). У медиа `copy_message` не позволяет подменить caption,
поэтому подпись уходит отдельным сообщением перед самим медиа.
Если обращение закрыто, к сообщению добавляется инлайн-кнопка **«↩️ Ответить»**: она снова
открывает обращение (`is_active = 1`) и показывает клавиатуру только с «Главное меню».
При активном обращении кнопки нет — пользователь и так пишет оператору.
Сообщение с префиксом `//` (`SUPPORT_NOTE_PREFIX`) остаётся заметкой внутри топика
и пользователю не уходит.
- **Завершение — уход из контекста поддержки.** Флаг `is_active` снимается при входе в
**любой** режим (`/training`, `/generate`, `/subscription`, `/help`), а также по
«🏠 Главное меню» и `/start` — общая точка `_close_support_case()`. Пока флаг стоит,
middleware отправляет любой текст из лички оператору, поэтому переключение в «Обучение»
без снятия флага приводило к тому, что сообщение уезжало не туда.
Топик при этом **не удаляется**: следующее «🆘 Поддержка» продолжает ту же переписку.
Повторное «Поддержка» продолжает переписку в том же топике с сохранённой историей.
- Пока обращение активно, клавиатура содержит только «Главное меню», а переключение в другие
режимы отклоняется — чтобы режимы и генерация не смешивались с обращением.
- **Команды и кнопки бота работают всегда**, даже при активном обращении:
`SupportRelayMiddleware` пропускает их через `_is_bot_control()` и не отправляет
оператору. Иначе «📖 Помощь» и «🎓 Обучение» уезжали оператору как обычный текст,
режим не переключался, и бот в личке молчал.
**Переменные Coolify:** `SUPPORT_GROUP_ID` (обязательно, без неё функция выключена),
`SUPPORT_NOTE_PREFIX` (default `//`), `SUPPORT_TOPIC_EMOJI_ID` (optional custom emoji id),
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`),
`SUPPORT_OPERATOR_SIGNATURE` (optional; подпись ответа оператора, default `Оператор поддержки`).
> Группа должна быть **супергруппой с включёнными темами** (`is_forum`), бот — её **администратором**
> с правом «Управление темами» (`create_forum_topic`). Обычного участника недостаточно: Telegram
> вернёт «not enough rights to create a topic», и топик не создастся.
> В `DEPLOY_ENV=development` бот покажет эту причину прямо в сообщении об ошибке,
> в production — только общую заглушку.
### Основные сценарии
| Что хочет пользователь | Что делает | Что видит |
|---|---|---|
| Написать пост | В режиме «Генерация» пишет «напиши пост про запуск продукта» | Индикатор «печатает», затем **3 отдельных сообщения** |
| Изменить пост | В режиме «Генерация» отвечает реплаем на вариант бота | Три новых варианта с учётом исходного текста |
| Придумать идею | В режиме «Генерация» пишет «предложи тему» или `/idea` | Одна идея одним сообщением |
| Поблагодарить или написать не по теме | В режиме «Генерация» отправляет обычное сообщение | Одна фраза из заготовленного набора; генератор постов/идей не вызывается |
| Пост по изображению | В режиме «Генерация» отправляет изображение/галерею с подписью-ТЗ | Vision изучает изображения и бот возвращает 3 варианта поста |
| Изображение без ТЗ | В режиме «Генерация» отправляет изображение/галерею без подписи | Бот спрашивает, о чём написать, затем принимает обычный текст или reply |
| Настроить стиль | В режиме «Обучение» присылает скриншоты, текст, голос или аудио | Бот накапливает сведения, обновляет профиль аккаунта и показывает только статус завершения |
| Пост по обученным данным | В режиме «Генерация» нажимает «✍️ Придумать пост» или вводит `/post` | Бот сам выбирает тему по накопленному профилю и возвращает 3 варианта; без обучения просит сначала обучить |
| Голосом | В режиме «Генерация» отправляет голосовое | STT расшифровывает → обычный текстовый запрос |
| Узнать статус / продлить | Входит в режим «Подписка» и нажимает «Продлить подписку» | Текущий тип периода, остаток дней и Stars-инвойс на 100 звёзд |
| Очистить данные | В режиме «Обучение» нажимает «Сбросить данные» или вводит `/clear`, затем подтверждает | Очищает стиль и историю, не меняя подписку или триал |
### Индикаторы и обратная связь
- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`, обновление каждые 4 секунды).
- **В приватном чате** статус операции стримится живым драфтом (`sendMessageDraft`): он не тратит лимит отправки в чат, не оставляет после себя сообщения и анимируется при смене текста. Пока операция длится, драфт каждые **3–8 секунд** (случайно) получает случайную фразу из пула, привязанного к типу операции, — видно, что работа не встала. Финальный результат уходит обычным сообщением.
- **В группах и топиках** драфты недоступны: там работает прежний механизм — отдельное сообщение-статус, которое редактируется по этапам; перед результатом оно меняется на ✅, при ошибке — на ❌. Ротация фраз в группах отключена.
- Пулы фраз живут в `activity_phrases.py` (`generation`, `training`, `images`, `voice`, `ideas`), интервал задаётся переменными `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS`.
- Драфт исчезает только когда бот отправляет реальное сообщение, поэтому **каждый путь завершения операции обязан закрывать статус** (`status.finish(...)` либо `finish_status(status, ...)`, который терпит `None`). Иначе последняя фраза висит в чате до конца TTL драфта. `finish_if_open()` — страховка от повторного закрытия.
- В режиме «Генерация» запрос сначала проверяет отдельный LLM-классификатор prompt injection, затем классификатор намерений. При обнаружении попытки изменить правила генератор не вызывается; ошибка проверки блокирует дальнейшую обработку. Модель проверки настраивается отдельно через `APINEX_INJECTION_CLASSIFIER_MODEL`.
- Благодарность и сообщения не по теме получают случайную заготовленную фразу и не запускают генерацию/идеи. Режим «Обучение» этим изменением не затронут.
- В режиме «Генерация» изображения и галереи используются для подготовки поста; подпись считается ТЗ. Без подписи бот сначала запрашивает ТЗ. Видео, видеокружки и GIF/анимации отклоняются во всех режимах.
- При **ошибке** пользователь получает общий безопасный ответ. Только в development отдельным сообщением дополнительно отправляется диагностика; production не раскрывает текст исключения.
- Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса.
### Блокировка по подписке
Если подписка не активна (триал/подписка истекли или `is_active = 0`), генерация блокируется. Переключатели режимов доступны всегда; для оплаты пользователь переходит в режим «Подписка».
### Самостоятельный пост по обученным данным
Кнопка «✍️ Придумать пост» (режим «Генерация») и команда `/post` идут не через классификатор намерений, а в
`_generate_post_from_training`: сначала проверяется подписка, затем флаг обучения `is_user_trained`
(`users.is_trained`). Если профиль не обучен, бот просит зайти в режим «Обучение» и прислать сведения об
аккаунте или примеры постов, не вызывая модель. Иначе собранный контекст (профиль аккаунта, образцы постов,
обучающие заметки, до 8000 символов) передаётся как **недоверенные данные**, и модель сама выбирает одну тему.
Обученный флаг выставляется при сохранении Tone of Voice или обучающих заметок и сбрасывается `/clear`.
---
## Архитектура кода
```
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
support.py — поддержка: личка ↔ персональный топик в группе операторов
keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
VERSION — версия релиза
```
### Основные потоки
**Текстовый запрос:**
```
handle_text (только в режиме генерации) → _classify_and_respond
├─ _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 (пока всё идёт)
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
```
**Скриншоты (ToV):**
```
update_tone_from_image → _handle_image_message
├─ _collect_media_group (собирает галерею, ждёт 1 сек)
├─ extract_posts_from_images (APInex vision, inline base64)
├─ add_post_samples (накапливает по пользователю)
└─ create_tone_profile → save_tone_of_voice → показывает стиль
```
### Ключевые функции (`bot.py`)
| Функция | Что делает |
|---|---|
| `start(message, state)` | Одно welcome-сообщение, главное меню и создание триала |
| `handle_command_text` | Роутинг глобальных переключателей и профильных команд по режиму |
| `_classify_and_respond` | Ядро: проверка подписки → классификация → генерация |
| `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует |
| `_generate_post_from_training` | `/post` и кнопка «✍️ Придумать пост»: пост по обученному профилю, с проверкой `is_user_trained` |
| `_show_subscription_info` | Показывает тип периода, остаток дней и inline-кнопку продления в режиме подписки |
| `balance_command` | `/balance` — статус подписки |
| `subscribe_command` | `/subscribe` — отправляет Stars-инвойс на 30 дней (подписка активируется только после оплаты) |
| `handle_pre_checkout` | Подтверждает только свой инвойс в XTR на 100 звёзд, всё остальное отклоняет |
| `handle_successful_payment` | Активирует подписку после реальной оплаты; `telegram_payment_charge_id` пишется в `payments` для защиты от повторного начисления |
| `clear_command` | `/clear` и кнопка «Сбросить данные» показывают inline-подтверждение; сброс только по confirm в режиме обучения |
| `help_command` | `/help` — меню справки (Rich Message с обычной inline-клавиатурой) + fallback на plain text |
| `help_navigation` | Callback `help:<section>` → `edit_message_text(rich_message=...)` — переключение разделов |
| `edit_rich_message` | Редактирование текущего rich-сообщения на новый раздел |
| `typing_status` | Индикатор «печатает» во время обработки |
| `OperationStatus` | Статус операции: в приватном чате — живой драфт с ротацией фраз, иначе — сообщение с `edit_text` |
| `_draft_supported` | Драфты доступны только в приватных чатах |
### Ключевые функции (`activity_phrases.py`)
| Функция | Что делает |
|---|---|
| `next_phrase(kind, exclude)` | Случайная фраза из пула операции, не повторяет только что показанную |
| `random_interval()` | Случайная пауза между сменами фраз, `ACTIVITY_MIN_SECONDS..ACTIVITY_MAX_SECONDS` |
| `report_error` | Понятная ошибка пользователю, секреты вырезаны |
### Ключевые функции (`user_store.py`)
| Функция | Что делает |
|---|---|
| `init_db` | Создаёт таблицы: `users`, `user_post_samples`, `generated_posts`, `generated_ideas`, `api_call_logs`, `subscriptions` |
| `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`: обучен ли профиль (гейт для «Придумать пост») |
| `_days_until` | Сколько дней осталось (триал — `trial_started_at + 3 дня`; подписка — `last_paid_at`); `-1` если истёкло/отключено |
---
## Подписка и монетизация
**Модель:** триал 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)
- `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` → `sendInvoice` (XTR, 100 звёзд) → `pre_checkout_query` (проверка суммы/валюты/payload) → `successful_payment` → `activate_subscription(months=1)` → +30 дней.
5. `/clear` очищает данные обучения и историю; подписка и триал не изменяются.
**Оплата реальная:** `payments.py` проводит настоящий платёж Telegram Stars. Подписка активируется **только** в `successful_payment` — сам `/subscribe` её не продлевает. `telegram_payment_charge_id` каждого платежа пишется в таблицу `payments`, поэтому один платёж не может активировать подписку дважды; по этому же id делается возврат через `refundStarPayment`. Платится валюта `XTR`, `provider_token` пустой (цифровой товар).
**Часовые пояса:** SQLite `CURRENT_TIMESTAMP` возвращает UTC без tz. Код аккуратно приводит все значения к naive UTC перед сравнением, чтобы не было сдвигов.
---
## Данные и хранилище
- **SQLite:** `/app/data/social_writer.sqlite3` (persistent Coolify volume).
- **Постоянное хранилище dev:** volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` смонтирован в `/app/data` контейнера на сервере Coolify `spb-easy-dale` (`31.129.103.242`). Сервер приложения и сервер управления Coolify могут быть разными: сначала смотри назначенный приложению Server в записи Coolify, затем проверяй контейнер именно на нём. Не ищи контейнер только на хосте Coolify.
- **Где смотреть ошибки:** подключись к `spb-easy-dale`, найди контейнер по имени `d32u5zcq2g4odmdpq9hw91yn-*` командой `docker ps`, затем смотри его stdout/stderr через `docker logs --since 24h <container>`. После деплоя ротационный файл приложения будет доступен как `/app/data/bot.log` (до 5 MiB и 3 архива); он сохраняется в указанном persistent volume при пересоздании контейнера. До деплоя изменения логирования ещё не действуют.
- **API-журнал ошибки:** открой `/app/data/social_writer.sqlite3`, таблицу `api_call_logs`; записи связываются по `operation_id` и времени. Файл/БД находятся в volume на сервере приложения, не в локальном checkout и не на сервере Coolify, если это другой хост.
- **Сырые Telegram Update (только dev):** отдельная БД `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; хранится JSON входящего Update, `update_id`, тип события и время получения. Поиск по `update_id`: `SELECT payload FROM raw_updates WHERE update_id = ?`. Записи старше 30 дней удаляются при старте dev-бота и при сохранении новых апдейтов. Чувствительные значения маскируются; Update могут содержать тексты и персональные данные пользователей. В production middleware не подключается и БД не создаётся.
- **Обращения в поддержку:** таблица `support_topics` в той же рабочей БД — `telegram_user_id` (PK), `thread_id` топика в группе операторов, `is_active`, время создания и обновления. Один пользователь — один топик; при завершении запись не удаляется, только снимается `is_active`, поэтому повторное обращение продолжается с сохранённой историей. Если топик удалили вручную в Telegram, при следующем сообщении он создаётся заново.
- **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**.
- **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели.
- **Починка формата JSON:** при синтаксически невалидном JSON выполняется не более одной отдельной попытки модели `APINEX_RESPONSE_REPAIR_MODEL`; затем исходные проверки структуры/схемы запускаются заново. Ошибки API, пустые ответы и JSON с неверной схемой автоматически не исправляются. В Coolify development задана `APINEX_RESPONSE_REPAIR_MODEL`.
- **Обрезанный JSON:** если модель вернула валидную схему, но текст поста обрывается на середине (незакрытая строка/массив), бот доотправки не требует: `_extract_complete_post_strings` достаёт из ответа завершённые строки и возвращает три полных варианта. Если целых строк меньше трёх или они не найдены — операция завершается ошибкой. Лимит `max_tokens` у вызовов LLM снят: модель пишет столько, сколько нужно, без искусственного обрезания.
- **API-логи:** JSON запросов/ответов APInex, тело ответа провайдера при ошибке и ошибки разбора хранятся в SQLite 30 дней. Записи связаны `operation_id` и содержат этап, модель, HTTP-статус и длительность. Системные промпты не дублируются — только имя и SHA-256; секреты и base64 изображений маскируются, бинарное аудио не пишется. Пользовательские тексты в запросах/ответах — приватные данные.
- **Логи контейнера:** ошибки операций содержат `operation_id`, Telegram user id, этап и полный traceback; ошибки APInex дополнительно показывают модель, статус, длительность и диагностическую структуру ответа без вывода текста пользовательского запроса. Ищи все связанные записи по одному `operation_id`.
- `/clear` (только режим «Обучение») удаляет пользовательские данные (ToV, скриншоты, посты, идеи, настройки), не меняя подписку и триал; **API-логи сохраняются**.
---
## Команды
| Команда | Что делает | Режим |
|---|---|---|
| `/start` | Возврат в главное меню | Любой |
| `/training`, `/generate`, `/subscription`, `/help` | Переключение режима | Любой |
| `/style`, `/clear` | Профиль аккаунта; сброс накопленного обучения | Обучение |
| `/post`, `/idea` | Самостоятельный пост по обученному профилю; идея | Генерация; для генерации нужна подписка |
| `/balance`, `/subscribe` | Статус или оплата Stars | Подписка |
| `/support` | Написать в поддержку | Любой |
В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/idea`.
---
## Деплой и конфигурация
- **Gitea:** `admin/social-post-writer-bot`, ветка `dev`. Push → автодеплой.
- **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- **Деплой:** `bash /root/.hermes/scripts/gitea_deploy.sh dev` (POST `/api/v1/deploy?uuid=...&force=false`).
- **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения.
- **VERSION:** файл `VERSION` (`0.1.0+64`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`.
- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации),
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; без неё поддержка выключена),
`SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю),
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`),
`SUPPORT_TOPIC_EMOJI_ID` (optional; custom emoji id на топике обращения).
- **Docker:** `python:3.12-slim`, копирует исходники бота, включая `raw_updates.py`, `support.py` и `activity_phrases.py`.
---
## Тесты
```bash
python -m unittest discover -s tests -v
```
Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **raw update log** (сохранение/чтение, выключение вне development, очистка по retention), **режимные клавиатуры и роутинг** (переключатели, возврат в главное меню), **платежи** (Stars, защита от повторов, dev-подтверждение), **ошибки**, **справка**, **onboarding**, **подписки** (статус при входе, продление, оплата, баланс, блокировка, сохранение подписки при `/clear`), **самостоятельная генерация поста** (гейт по флагу обучения, контекст из профиля), **восстановление обрезанного JSON**, **поддержка** (кнопка и клавиатура обращения, `support=True` оставляет только «Главное меню»).
Запускай через виртуальное окружение проекта: `.venv/bin/python -m unittest discover -s tests` (вне его `aiogram` может быть не установлен).