393 lines
47 KiB
Markdown
393 lines
47 KiB
Markdown
# 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`.
|
||
|
||
---
|
||
|
||
- **Статистика бота:** команда `/stats` доступна в топике «Статистика» группы «Мастер постинга: Админка» (`chat_id=-1008001427913`, `message_thread_id=5`). Отчёт отправляется Rich Message таблицами с колонками «Сегодня», «7 дней», «30 дней», «Всё время». События хранятся по Telegram user id в отдельной SQLite `/app/data/analytics.sqlite3` на persistent volume; текст/медиа пользователей не сохраняются. В режиме генерации учитываются генерации постов, идеи, правки и типы сообщений; в обучении — только типы сообщений. Групповой роутер ограничен аналитическим топиком; доступ к отчёту ограничивается `OWNER_USER_ID`, если переменная задана.
|
||
|
||
## Архитектура кода
|
||
|
||
```
|
||
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-логи, подписки
|
||
analytics.py — отдельная SQLite-аналитика по Telegram user id; события активности и отчёты
|
||
raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update (работает и в production)
|
||
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) — бизнес-данные пользователей, подписки и платежи.
|
||
- SQLite `/app/data/analytics.sqlite3` (тот же persistent volume) — append-only события по `telegram_user_id`, режиму, действию и типу входящего сообщения; без пользовательского текста и медиа. Общий отчёт агрегируется запросами, отдельные дублирующие сводки не хранятся.
|
||
- Команда `/stats` работает в форуме группы «Мастер постинга: Админка», топик `5`, и показывает таблицы за сегодня, последние 7 дней, последние 30 дней и всё время. Счётчики генерации: посты, идеи, правки, сообщения и типы сообщений; обучение — типы входящих сообщений.
|
||
- **Постоянное хранилище 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 (все окружения, включая production):** отдельная БД `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; хранится JSON входящего Update, `update_id`, тип события и время получения. Поиск по `update_id`: `SELECT payload FROM raw_updates WHERE update_id = ?`. Записи старше 30 дней удаляются при старте бота и при сохранении новых апдейтов. Чувствительные значения маскируются; Update могут содержать тексты и персональные данные пользователей. Middleware подключается всегда: в production это единственный способ посмотреть события из групп администрирования и поддержки.
|
||
- **Обращения в поддержку:** таблица `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-логи сохраняются**.
|
||
|
||
---
|
||
|
||
## Статистика и аналитика
|
||
|
||
- `/stats` доступна в админском форуме, топик «Статистика» (`chat_id=-1008001427913`, `message_thread_id=5`). Отчёт уходит в этот же топик Rich Message-таблицами.
|
||
- По умолчанию `/stats` показывает данные за всё время; отчёт содержит сопоставимые колонки «Сегодня», «7 дней», «30 дней», «Всё время».
|
||
- Таблицы: активные пользователи; генерации постов, идеи, правки и сообщения; распределение типов сообщений в генерации; сообщения и типы сообщений в обучении.
|
||
- Периоды рассчитываются как текущие 24 часа, последние 7×24 часа, последние 30×24 часа и весь срок хранения соответственно.
|
||
- События хранятся в `/app/data/analytics.sqlite3`, отдельной SQLite на persistent volume. Запись привязана к `telegram_user_id`; общие значения агрегируются запросом без дублирующих таблиц.
|
||
- Сохраняются только режим, тип действия, тип входящего сообщения и время. Содержимое сообщений, изображений и аудио аналитикой не сохраняется.
|
||
- В генерации учитываются действие генерации поста, идеи, правки и входящие сообщения по типам. В обучении — только входящие сообщения по типам.
|
||
- Вход в отчёт проверяет точные `chat_id` и `message_thread_id`; при заданном `OWNER_USER_ID` команда доступна только этому Telegram-пользователю.
|
||
|
||
## Команды
|
||
|
||
| Команда | Что делает | Режим |
|
||
|---|---|---|
|
||
| `/start` | Возврат в главное меню | Любой |
|
||
| `/training`, `/generate`, `/subscription`, `/help` | Переключение режима | Любой |
|
||
| `/style`, `/clear` | Профиль аккаунта; сброс накопленного обучения | Обучение |
|
||
| `/post`, `/idea` | Самостоятельный пост по обученному профилю; идея | Генерация; для генерации нужна подписка |
|
||
| `/balance`, `/subscribe` | Статус или оплата Stars | Подписка |
|
||
| `/support` | Написать в поддержку | Любой |
|
||
| `/stats` | Отчёт аналитики в админском топике | Топик «Статистика» |
|
||
|
||
В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/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`, `ANALYTICS_DB_PATH` (optional; default `/app/data/analytics.sqlite3`), `ANALYTICS_CHAT_ID` (default `-1008001427913`), `ANALYTICS_TOPIC_ID` (default `5`), `OWNER_USER_ID` (optional; when set, `/stats` is restricted to this Telegram user), `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 — предохранитель от бесконечной ротации),
|
||
`ADMIN_GROUP_ID` (id админской супергруппы с темами; production: `-1004459624166` — «Писатель постов: Админка»),
|
||
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; production: `-1003957663233` — «Писатель постов: Поддержка»; без неё поддержка выключена),
|
||
`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` может быть не установлен).
|