Compare commits

...
22 Commits
Author SHA1 Message Date
admin f61c144ea2 docs: add APINEX_TRAINING_SUMMARY_MODEL to Coolify env inventory in README/IDEA 2026-10-30 15:14:58 +02:00
admin 796a77d7f7 Revert "feat(posts): copy button on generated posts with copy-count analytics"
This reverts commit 88230c92b5.
2026-10-30 14:52:27 +02:00
admin 37d8f80236 Revert "fix(posts): keep copy button after press, count every tap"
This reverts commit 992d8645cf.
2026-10-30 14:52:27 +02:00
admin 3a6cc1067f Revert "fix(posts): split copy_text and callback_data into separate buttons per Bot API spec"
This reverts commit b96bffda37.
2026-10-30 14:52:27 +02:00
admin b96bffda37 fix(posts): split copy_text and callback_data into separate buttons per Bot API spec 2026-10-30 14:46:12 +02:00
admin 992d8645cf fix(posts): keep copy button after press, count every tap 2026-10-30 14:33:38 +02:00
admin 88230c92b5 feat(posts): copy button on generated posts with copy-count analytics 2026-10-30 14:29:36 +02:00
admin 57ccc3d70c fix(subscription): keep access until last second of the term
- is_subscribed used _days_until() (floor), blocking generation for the whole
  last day when <24h remained though subscription was still active; now checks
  absolute expiry timestamp (expires > now)
- covers all gated actions (post generation, ideas) via _check_subscription
2026-10-30 13:52:03 +02:00
admin a9e8c2e3ba chore(subscription): reword expiry to 'Закончится <дата> в <время> (МСК)' 2026-10-30 13:44:54 +02:00
admin 6f52599342 fix(subscription): extend by absolute expiry timestamp, not floored days
- activate_subscription_days now adds timedelta to the real expires_at
  (max(now, current_expires)) instead of _days_until() floor value;
  prevents losing fractional time on each renewal (10d + 1d -> was 10d)
- return ceil day count for display consistency
- keep _days_until only for is_subscribed gate and support stats
2026-10-30 13:37:17 +02:00
admin 261b2b2d02 fix(subscription): ceil day count so bought N days shows 'N дн.'
- seconds // 86400 floored to N-1 (bought 10 -> showed 9); use ceil instead
- concrete MSK expiry time stays only for the last day (< 24h remaining)
2026-10-30 13:30:27 +02:00
admin 9400050ade feat(subscription): return to main menu after payment/renewal
- handle_successful_payment: after status, reset FSM to lobby and send main
  menu keyboard (state injected by dispatcher)
- confirm_development_payment (dev path): same — edit status then reply with
  main menu; add state param
- tests: assert second message carries main-menu markup; _StubState clear/set_state
2026-10-30 13:24:53 +02:00
admin 87f22fb249 fix(subscription): show expiry clock time (MSK) when under a day
- subscription_time_left: replace floor-div '23 ч.'/'мин.' branches with the
  concrete expiry timestamp when less than a full day remains
- add MSK_TIMEZONE (UTC+3) as display default; label time as '(МСК)'
- add shared subscription_status_text helper, use it in status menu,
  post-payment reply and dev confirmation instead of floor-rounded days
- tests: cover under-a-day clock format; fix stale to_thread mock
2026-10-30 13:04:38 +02:00
admin 140f2f7263 fix(apinex): browser UA — Cloudflare 1010 blocks bot-like User-Agent 2026-10-30 00:33:07 +02:00
admin 376f26b2eb fix(llm): default model slug free/gemini-3.8-flash (billing) 2026-10-30 00:12:01 +02:00
admin 2b78351707 docs(support): require Manage Topics right for the support group 2026-10-29 23:57:49 +02:00
admin 48a82bd0b3 docs(groups): pin support group id 2026-10-29 23:22:44 +02:00
admin 741a27eb27 fix(admin): accept /stats with bot mention 2026-10-29 23:10:38 +02:00
admin f1712d6db0 docs(groups): pin admin group and topic ids 2026-10-29 22:54:48 +02:00
admin 089c59bc68 fix(logging): write raw updates in production too 2026-10-29 22:19:40 +02:00
admin de638bb4d0 docs: describe analytics storage and production raw update log 2026-10-29 22:19:40 +02:00
admin 57f4c6cb4f fix(subscription): show precise remaining time 2026-10-29 21:35:58 +02:00
11 changed files with 222 additions and 81 deletions

No files matched your search

+10 -6
View File
@@ -35,8 +35,9 @@ Telegram-бот, который по запросу пользователя г
## Технические заметки ## Технические заметки
- Формат: Telegram-бот. - Формат: Telegram-бот.
- Стек: Python 3.12, aiogram 3.x, aiohttp, SQLite. - Стек: Python 3.12, aiogram 3.x, aiohttp, SQLite.
- Аналитика: отдельная SQLite `/app/data/analytics.sqlite3`, события на пользователя; команда `/stats` в админском топике выводит таблицы по периодам «Сегодня», «7 дней», «30 дней», «Всё время».
- APInex Voice STT расшифровывает голосовые; Gemini 3.8 Flash через APInex классифицирует намерения, пишет/правит посты и извлекает ToV из скриншотов. - APInex Voice STT расшифровывает голосовые; Gemini 3.8 Flash через APInex классифицирует намерения, пишет/правит посты и извлекает ToV из скриншотов.
- Coolify env с отдельными модельными настройками: `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL`; общий секрет `APINEX_API_KEY`. При невалидном JSON допускается одна попытка исправления форматирования до валидации схемы. Обрезанный на середине строки ответ не требует доотправки: `_extract_complete_post_strings` достаёт целые строки и возвращает три полных варианта. Лимит `max_tokens` у вызовов LLM снят. - Coolify env с отдельными модельными настройками: `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL`, `APINEX_TRAINING_SUMMARY_MODEL`; общий секрет `APINEX_API_KEY`. При невалидном JSON допускается одна попытка исправления форматирования до валидации схемы. Обрезанный на середине строки ответ не требует доотправки: `_extract_complete_post_strings` достаёт целые строки и возвращает три полных варианта. Лимит `max_tokens` у вызовов LLM снят.
- В режиме «Обучение» текстовые заметки, голосовые/аудио и примеры постов дополняют единый накопительный профиль аккаунта; сброс обучения удаляет и заметки, и образцы. Флаг `users.is_trained` включает самостоятельную генерацию поста. - В режиме «Обучение» текстовые заметки, голосовые/аудио и примеры постов дополняют единый накопительный профиль аккаунта; сброс обучения удаляет и заметки, и образцы. Флаг `users.is_trained` включает самостоятельную генерацию поста.
- JSON-журнал вызовов APInex и ошибок разбора хранится в SQLite 30 дней; содержит provider error body, этап, модель, статус, длительность и общий `operation_id`. Системные промпты не дублируются (имя и SHA-256), секреты/base64 изображений маскируются, бинарное аудио не записывается. - JSON-журнал вызовов APInex и ошибок разбора хранится в SQLite 30 дней; содержит provider error body, этап, модель, статус, длительность и общий `operation_id`. Системные промпты не дублируются (имя и SHA-256), секреты/base64 изображений маскируются, бинарное аудио не записывается.
- Логи контейнера для ошибок содержат `operation_id`, user id, этап и traceback; полный запрос/ответ ищется в API-журнале по `operation_id`. Текст пользовательских запросов и ответов — приватные данные. - Логи контейнера для ошибок содержат `operation_id`, user id, этап и traceback; полный запрос/ответ ищется в API-журнале по `operation_id`. Текст пользовательских запросов и ответов — приватные данные.
@@ -65,13 +66,14 @@ Telegram-бот, который по запросу пользователя г
- Gitea: `admin/social-post-writer-bot`, ветка `dev`. - Gitea: `admin/social-post-writer-bot`, ветка `dev`.
- Coolify проект: `Social Post Writer Bot`, окружение `development`. - Coolify проект: `Social Post Writer Bot`, окружение `development`.
- Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`. - Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`. - SQLite: `/app/data/social_writer.sqlite3`; analytics: `/app/data/analytics.sqlite3` на том же persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`.
- Важно при поиске логов: приложение запущено на сервере `spb-easy-dale` (`31.129.103.242`), который может отличаться от хоста управления Coolify. Ориентируйся на поле Server у приложения; `docker ps` и `docker logs --since 24h <container>` выполняй на сервере приложения, имя контейнера начинается с `d32u5zcq2g4odmdpq9hw91yn-`. Не делай вывод об отсутствии контейнера по проверке только хоста Coolify. - Важно при поиске логов: приложение запущено на сервере `spb-easy-dale` (`31.129.103.242`), который может отличаться от хоста управления Coolify. Ориентируйся на поле Server у приложения; `docker ps` и `docker logs --since 24h <container>` выполняй на сервере приложения, имя контейнера начинается с `d32u5zcq2g4odmdpq9hw91yn-`. Не делай вывод об отсутствии контейнера по проверке только хоста Coolify.
- После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume. - После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume.
- Dev-only raw update log: отдельная SQLite `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; поиск по Telegram `update_id` (`SELECT payload FROM raw_updates WHERE update_id = ?`). Middleware подключается только при `DEPLOY_ENV=development`; записи старше 30 дней удаляются при запуске и записи новых событий. Секретные поля/токены маскируются. JSON включает приватный текст и metadata апдейта; production его не пишет. - Raw update log (все окружения, включая production): отдельная SQLite `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; поиск по Telegram `update_id` (`SELECT payload FROM raw_updates WHERE update_id = ?`). Middleware подключается всегда — в проде это единственный способ увидеть события из групп администрирования и поддержки; записи старше 30 дней удаляются при запуске и записи новых событий. Секретные поля/токены маскируются. JSON включает приватный текст и metadata апдейта.
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории. - Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1008000863273`), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_OPERATOR_SIGNATURE` (по умолчанию `Оператор поддержки`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. Подробнее — раздел «Поддержка». - Админская группа «Писатель постов: Админка»: `chat_id = -1004459624166` (supergroup, forum). Топики: `4` — Статистика, `5` — Расходы, `6` — Рассылка, `7` — Ошибки. В production эти значения заданы env (`ADMIN_GROUP_ID`, `ANALYTICS_CHAT_ID`, `ANALYTICS_TOPIC_ID`, `TOPIC_STATS_ID`, `TOPIC_EXPENSES_ID`, `TOPIC_BROADCAST_ID`, `TOPIC_ERRORS_ID`); в коде оставлены дефолты для dev. Реализован только топик «Статистика» (`/stats`), остальные зарезервированы.
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+64`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`). - Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1003957663233`, «Писатель постов: Поддержка», forum; production), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_OPERATOR_SIGNATURE` (по умолчанию `Оператор поддержки`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. **Требование к правам:** бот в этой группе должен быть админом с включённым «Manage Topics» (`can_manage_topics=true`) — иначе создание топика падает с `not enough rights to create a topic`, а пользователь видит «Поддержка временно недоступна». Проверка: `get_chat_member(SUPPORT_GROUP_ID, bot_id)`.
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+67`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`).
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
## Статус ## Статус
@@ -80,6 +82,7 @@ Telegram-бот, который по запросу пользователя г
- [x] Классификатор намерений и Tone of Voice из скриншота - [x] Классификатор намерений и Tone of Voice из скриншота
- [x] SQLite-настройки пользователей и reply-правки без хранения диалога - [x] SQLite-настройки пользователей и reply-правки без хранения диалога
- [x] Development-развёртывание в Coolify: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) - [x] Development-развёртывание в Coolify: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`)
- [x] Аналитика пользователей: отдельная SQLite, отчёт `/stats` с таблицами и периодами
- [x] Подключён persistent Coolify volume `/app/data` для SQLite - [x] Подключён persistent Coolify volume `/app/data` для SQLite
- [x] Проверены на моках STT, LLM, vision, ToV, reply-поля и изоляция пользователей - [x] Проверены на моках STT, LLM, vision, ToV, reply-поля и изоляция пользователей
- [x] Выполнен live smoke test: классификатор вернул `write_post`, генератор — 3 разных варианта; vision распознал тестовый скриншот - [x] Выполнен live smoke test: классификатор вернул `write_post`, генератор — 3 разных варианта; vision распознал тестовый скриншот
@@ -104,7 +107,8 @@ Telegram-бот, который по запросу пользователя г
- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError` - [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError`
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления - [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
- [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development - [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development
- Версия разработки: `0.1.0+64` - [x] Статистика активности по пользователям хранится в отдельной SQLite `/app/data/analytics.sqlite3`; `/stats` в админском топике показывает таблицы с колонками за сегодня, 7 дней, 30 дней и всё время. Генерация считает посты, идеи, правки и типы сообщений; обучение — только типы сообщений.
- Версия разработки: `0.1.0+67`
- [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text` - [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text`
- [x] Справка `/help` отправляется как Rich Message с обычной inline-клавиатурой; каждый callback `help:<section>` открывает раздел, кнопки доступны на всех страницах. - [x] Справка `/help` отправляется как Rich Message с обычной inline-клавиатурой; каждый callback `help:<section>` открывает раздел, кнопки доступны на всех страницах.
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
+23 -5
View File
@@ -185,6 +185,8 @@ onboarding. В «Подписке» сразу отображаются тип
--- ---
- **Статистика бота:** команда `/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`, если переменная задана.
## Архитектура кода ## Архитектура кода
``` ```
@@ -192,7 +194,8 @@ bot.py — роутинг, команды, обработка со
payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update для development analytics.py — отдельная SQLite-аналитика по Telegram user id; события активности и отчёты
raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update (работает и в production)
support.py — поддержка: личка ↔ персональный топик в группе операторов support.py — поддержка: личка ↔ персональный топик в группе операторов
keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
@@ -316,11 +319,13 @@ SUBSCRIPTION_PRICE_STARS = 100
## Данные и хранилище ## Данные и хранилище
- **SQLite:** `/app/data/social_writer.sqlite3` (persistent Coolify volume). - 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. - **Постоянное хранилище 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 при пересоздании контейнера. До деплоя изменения логирования ещё не действуют. - **Где смотреть ошибки:** подключись к `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, если это другой хост. - **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 не подключается и БД не создаётся. - **Сырые 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, при следующем сообщении он создаётся заново. - **Обращения в поддержку:** таблица `support_topics` в той же рабочей БД — `telegram_user_id` (PK), `thread_id` топика в группе операторов, `is_active`, время создания и обновления. Один пользователь — один топик; при завершении запись не удаляется, только снимается `is_active`, поэтому повторное обращение продолжается с сохранённой историей. Если топик удалили вручную в Telegram, при следующем сообщении он создаётся заново.
- **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**. - **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**.
- **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели. - **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели.
@@ -332,6 +337,17 @@ SUBSCRIPTION_PRICE_STARS = 100
--- ---
## Статистика и аналитика
- `/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-пользователю.
## Команды ## Команды
| Команда | Что делает | Режим | | Команда | Что делает | Режим |
@@ -342,6 +358,7 @@ SUBSCRIPTION_PRICE_STARS = 100
| `/post`, `/idea` | Самостоятельный пост по обученному профилю; идея | Генерация; для генерации нужна подписка | | `/post`, `/idea` | Самостоятельный пост по обученному профилю; идея | Генерация; для генерации нужна подписка |
| `/balance`, `/subscribe` | Статус или оплата Stars | Подписка | | `/balance`, `/subscribe` | Статус или оплата Stars | Подписка |
| `/support` | Написать в поддержку | Любой | | `/support` | Написать в поддержку | Любой |
| `/stats` | Отчёт аналитики в админском топике | Топик «Статистика» |
В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/idea`. В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/idea`.
@@ -354,8 +371,9 @@ SUBSCRIPTION_PRICE_STARS = 100
- **Деплой:** `bash /root/.hermes/scripts/gitea_deploy.sh dev` (POST `/api/v1/deploy?uuid=...&force=false`). - **Деплой:** `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-поведения. - **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения.
- **VERSION:** файл `VERSION` (`0.1.0+64`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`. - **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 — предохранитель от бесконечной ротации), - **Переменные 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_TRAINING_SUMMARY_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 — предохранитель от бесконечной ротации),
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; без неё поддержка выключена), `ADMIN_GROUP_ID` (id админской супергруппы с темами; production: `-1004459624166` — «Писатель постов: Админка»),
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; production: `-1003957663233` — «Писатель постов: Поддержка»; без неё поддержка выключена),
`SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю), `SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю),
`SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`), `SUPPORT_GREETINGS` (optional; пул приветствий оператора, разделитель `|`),
`SUPPORT_TOPIC_EMOJI_ID` (optional; custom emoji id на топике обращения). `SUPPORT_TOPIC_EMOJI_ID` (optional; custom emoji id на топике обращения).
+1 -1
View File
@@ -1 +1 @@
0.1.0+75 0.1.0+84
+2 -2
View File
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
_operation_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("operation_id", default=None) _operation_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("operation_id", default=None)
APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt" APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt"
APINEX_CHAT_URL = "https://api.apinex.bond/v1/chat/completions" APINEX_CHAT_URL = "https://api.apinex.bond/v1/chat/completions"
USER_AGENT = "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)" USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129 Safari/537.36"
POSTS_SYSTEM_PROMPT = """ВСЕ служебные сообщения пользователю — на русском языке. Исключение — дословно приводимый технический текст ошибки. Сам текст публикации пиши на языке, который пользователь явно запросил; при отсутствии явного языкового требования используй язык исходного запроса. POSTS_SYSTEM_PROMPT = """ВСЕ служебные сообщения пользователю — на русском языке. Исключение — дословно приводимый технический текст ошибки. Сам текст публикации пиши на языке, который пользователь явно запросил; при отсутствии явного языкового требования используй язык исходного запроса.
Ты — профессиональный редактор и автор текстов для социальных сетей. Помогай превращать тему, идею, черновик или факты в понятную и полезную публикацию, соответствующую цели аккаунта, аудитории и выбранной площадке. Ты — профессиональный редактор и автор текстов для социальных сетей. Помогай превращать тему, идею, черновик или факты в понятную и полезную публикацию, соответствующую цели аккаунта, аудитории и выбранной площадке.
@@ -96,7 +96,7 @@ TONE_PROFILE_SYSTEM_PROMPT = """Составь или обнови единый
""" """
def _setting(name: str, default: str = "gemini-3.8-flash") -> str: def _setting(name: str, default: str = "free/gemini-3.8-flash") -> str:
return os.getenv(name, "").strip() or default return os.getenv(name, "").strip() or default
+22 -13
View File
@@ -255,8 +255,11 @@ def is_development_environment() -> bool:
def configure_raw_update_logging(dispatcher: Dispatcher) -> bool: def configure_raw_update_logging(dispatcher: Dispatcher) -> bool:
if not is_development_environment(): """Persist incoming updates for diagnostics in every deploy environment.
return False
Групповые события (администрирование и поддержка) видно только через
мидлваре — без него прод-бот остаётся «чёрным ящиком».
"""
dispatcher.update.outer_middleware(RawUpdateMiddleware()) dispatcher.update.outer_middleware(RawUpdateMiddleware())
return True return True
@@ -412,7 +415,7 @@ async def help_navigation(callback: CallbackQuery, state: FSMContext) -> None:
@router.callback_query(F.data.startswith("dev_paid:")) @router.callback_query(F.data.startswith("dev_paid:"))
async def confirm_development_payment(callback: CallbackQuery) -> None: async def confirm_development_payment(callback: CallbackQuery, state: FSMContext) -> None:
if not is_development_environment(): if not is_development_environment():
await callback.answer() await callback.answer()
return return
@@ -433,11 +436,17 @@ async def confirm_development_payment(callback: CallbackQuery) -> None:
await callback.answer("Dev-оплата подтверждена. Срок подписки не изменён.") await callback.answer("Dev-оплата подтверждена. Срок подписки не изменён.")
return return
await asyncio.to_thread(ensure_subscription, user_id) await asyncio.to_thread(ensure_subscription, user_id)
days_left = await asyncio.to_thread(user_store.activate_subscription_days, user_id, days) await asyncio.to_thread(user_store.activate_subscription_days, user_id, days)
sub = await asyncio.to_thread(subscription_row, user_id)
await callback.answer("Dev-оплата подтверждена.") await callback.answer("Dev-оплата подтверждена.")
if isinstance(callback.message, Message): if isinstance(callback.message, Message):
await callback.message.edit_reply_markup(reply_markup=None) await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.edit_text(f"Подписка активна\nЗакончится через {days_left} дн.") await callback.message.edit_text(
user_store.subscription_status_text(sub)
)
await callback.message.answer("Выбери действие.", reply_markup=main_menu_keyboard())
await state.clear()
await state.set_state(BotMode.lobby)
@router.message(CommandStart()) @router.message(CommandStart())
@@ -467,11 +476,7 @@ async def _show_subscription_info(
if not sub: if not sub:
text = "Подписка закончилась" text = "Подписка закончилась"
else: else:
days_left = user_store._days_until(sub) text = user_store.subscription_status_text(sub)
text = (
f"Подписка активна\nЗакончится через {days_left} дн."
if days_left > 0 else "Подписка закончилась"
)
if mode == "subscription": if mode == "subscription":
markup = mode_keyboard("subscription", subscription_purchased=bool(sub and sub["last_paid_at"])) markup = mode_keyboard("subscription", subscription_purchased=bool(sub and sub["last_paid_at"]))
@@ -1618,7 +1623,10 @@ async def send_owner_stats(bot: Bot, period: str = "day") -> None:
await bot.send_rich_message(chat_id=ANALYTICS_CHAT_ID, message_thread_id=ANALYTICS_TOPIC_ID, rich_message=InputRichMessage(html=report)) await bot.send_rich_message(chat_id=ANALYTICS_CHAT_ID, message_thread_id=ANALYTICS_TOPIC_ID, rich_message=InputRichMessage(html=report))
@admin_router.message(F.text.regexp(r"^/stats(?:\s+(day|week|month|all))?$") ) STATS_COMMAND_PATTERN = r"^/stats(?:@\w+)?(?:\s+(day|week|month|all))?$"
@admin_router.message(F.text.regexp(STATS_COMMAND_PATTERN))
async def stats_command(message: Message, state: FSMContext) -> None: async def stats_command(message: Message, state: FSMContext) -> None:
if message.chat.id != ANALYTICS_CHAT_ID or message.message_thread_id != ANALYTICS_TOPIC_ID: if message.chat.id != ANALYTICS_CHAT_ID or message.message_thread_id != ANALYTICS_TOPIC_ID:
return return
@@ -1660,8 +1668,9 @@ async def main() -> None:
analytics.init_db() analytics.init_db()
payments.init_payments_table() payments.init_payments_table()
await asyncio.to_thread(support.init_support_table) await asyncio.to_thread(support.init_support_table)
if is_development_environment(): # Инициализируем БД сырых апдейтов в любом окружении: в проде она нужна,
await asyncio.to_thread(init_raw_updates_db) # чтобы видеть события из групп администрирования и поддержки.
await asyncio.to_thread(init_raw_updates_db)
token = os.environ.get("BOT_TOKEN", "").strip() token = os.environ.get("BOT_TOKEN", "").strip()
if not token: if not token:
raise RuntimeError("BOT_TOKEN is required") raise RuntimeError("BOT_TOKEN is required")
+18 -5
View File
@@ -15,14 +15,19 @@ import os
from contextlib import closing from contextlib import closing
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
from keyboards import main_menu_keyboard
from user_store import ( from user_store import (
SUBSCRIPTION_PLANS, SUBSCRIPTION_PLANS,
activate_subscription_days, activate_subscription_days,
activate_subscription, activate_subscription,
connect, connect,
ensure_subscription, ensure_subscription,
subscription_row,
subscription_status_text,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -133,8 +138,16 @@ async def handle_pre_checkout(query: PreCheckoutQuery) -> None:
await query.answer(ok=True) await query.answer(ok=True)
async def _return_to_main_menu(message: Message, state: FSMContext | None) -> None:
"""После успешной оплаты/продления возвращает пользователя в главное меню."""
if state is not None:
await state.clear()
await state.set_state("BotMode:lobby")
await message.answer("Оплата принята.", reply_markup=main_menu_keyboard())
@router.message(F.successful_payment) @router.message(F.successful_payment)
async def handle_successful_payment(message: Message) -> None: async def handle_successful_payment(message: Message, state: FSMContext | None = None) -> None:
payment = message.successful_payment payment = message.successful_payment
if payment is None or message.from_user is None: if payment is None or message.from_user is None:
return return
@@ -155,7 +168,8 @@ async def handle_successful_payment(message: Message) -> None:
days = 1 days = 1
await asyncio.to_thread(ensure_subscription, message.from_user.id) await asyncio.to_thread(ensure_subscription, message.from_user.id)
days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days) await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
sub = await asyncio.to_thread(subscription_row, message.from_user.id, db_path)
await asyncio.to_thread( await asyncio.to_thread(
save_payment, save_payment,
charge_id, charge_id,
@@ -165,9 +179,8 @@ async def handle_successful_payment(message: Message) -> None:
payment.total_amount, payment.total_amount,
db_path, db_path,
) )
await message.answer( await message.answer(subscription_status_text(sub))
f"Подписка активна\nЗакончится через {days_left} дн." await _return_to_main_menu(message, state)
)
async def _reject(message: Message, charge_id: str, reason: str) -> None: async def _reject(message: Message, charge_id: str, reason: str) -> None:
+27
View File
@@ -0,0 +1,27 @@
import re
import unittest
from bot import STATS_COMMAND_PATTERN
class StatsCommandPatternTests(unittest.TestCase):
def test_matches_plain_and_mentioned_forms(self):
# В группе команду часто отправляют с явным обращением к боту.
for text in ("/stats", "/stats@pisatel_posts_bot", "/stats all", "/stats@pisatel_posts_bot week"):
with self.subTest(text=text):
self.assertIsNotNone(re.match(STATS_COMMAND_PATTERN, text))
def test_rejects_unknown_arguments(self):
for text in ("/stats yearly", "/stats@bot unknown", "stats", "/statsical"):
with self.subTest(text=text):
self.assertIsNone(re.match(STATS_COMMAND_PATTERN, text))
def test_period_argument_is_extractable(self):
match = re.match(STATS_COMMAND_PATTERN, "/stats@pisatel_posts_bot month")
self.assertIsNotNone(match)
assert match is not None
self.assertEqual(match.group(1), "month")
if __name__ == "__main__":
unittest.main()
+13 -1
View File
@@ -244,12 +244,24 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
async def test_subscription_status_uses_renew_button_after_previous_payment(self): async def test_subscription_status_uses_renew_button_after_previous_payment(self):
message = _StubMessage() message = _StubMessage()
sub = {"last_paid_at": "2026-01-01T00:00:00", "expires_at": "2026-02-01T00:00:00", "is_active": 1} sub = {"last_paid_at": "2026-01-01T00:00:00", "expires_at": "2026-02-01T00:00:00", "is_active": 1}
with patch.object(bot.user_store, "_days_until", return_value=2): with patch.object(bot.user_store, "subscription_time_left", return_value=(True, 2, "дн.")):
await bot._show_subscription_info(cast(Any, message), cast(Any, sub)) await bot._show_subscription_info(cast(Any, message), cast(Any, sub))
markup = message.answers[0][1]["reply_markup"] markup = message.answers[0][1]["reply_markup"]
self.assertIn("Подписка активна", message.answers[0][0]) self.assertIn("Подписка активна", message.answers[0][0])
self.assertIsNotNone(markup) self.assertIsNotNone(markup)
async def test_subscription_status_shows_clock_time_when_under_a_day(self):
message = _StubMessage()
sub = {"last_paid_at": "2026-01-01T00:00:00", "is_active": 1}
with patch.object(
bot.user_store, "subscription_time_left", return_value=(True, "31.10.2026 12:51 (МСК)", "")
):
await bot._show_subscription_info(cast(Any, message), cast(Any, sub))
text = message.answers[0][0]
self.assertIn("Подписка активна", text)
self.assertIn("Закончится 31.10.2026 в 12:51 (МСК)", text)
self.assertNotIn("ч.", text)
async def test_subscription_action_button_shows_inline_plan_choices(self): async def test_subscription_action_button_shows_inline_plan_choices(self):
message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON) message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON)
with patch.object(bot, "notify_development_deploy", new=AsyncMock()): with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
+15 -7
View File
@@ -20,6 +20,12 @@ class _StubState:
async def get_state(self): async def get_state(self):
return self.value return self.value
async def clear(self):
self.value = None
async def set_state(self, state):
self.value = getattr(state, "state", state)
class PaymentStoreTests(unittest.TestCase): class PaymentStoreTests(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -166,8 +172,10 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
user_store.ensure_subscription(42, self.db) user_store.ensure_subscription(42, self.db)
with patch.dict(os.environ, {"BOT_DB_PATH": self.db}): with patch.dict(os.environ, {"BOT_DB_PATH": self.db}):
await payments.handle_successful_payment(message) await payments.handle_successful_payment(message)
self.assertEqual(len(message.answers), 1) self.assertEqual(len(message.answers), 2)
self.assertIn("Подписка активна", message.answers[0]) self.assertIn("Подписка активна", message.answers[0])
# Второе сообщение — возврат в главное меню с его клавиатурой.
self.assertIsNotNone(message.answer_kwargs[1].get("reply_markup"))
self.assertTrue(payments.payment_recorded("charge1", self.db)) self.assertTrue(payments.payment_recorded("charge1", self.db))
row = user_store.subscription_row(42, self.db) row = user_store.subscription_row(42, self.db)
self.assertIsNotNone(row["last_paid_at"]) self.assertIsNotNone(row["last_paid_at"])
@@ -257,7 +265,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot, "DEPLOY_ENV", "development"),
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
): ):
await bot.confirm_development_payment(callback) await bot.confirm_development_payment(callback, _StubState())
to_thread.assert_not_awaited() to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with("Dev-оплата подтверждена. Срок подписки не изменён.") callback.answer.assert_awaited_once_with("Dev-оплата подтверждена. Срок подписки не изменён.")
@@ -287,9 +295,9 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot, "DEPLOY_ENV", "development"),
patch.dict(os.environ, {"BOT_DB_PATH": db}), patch.dict(os.environ, {"BOT_DB_PATH": db}),
patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=32)) as to_thread, patch.object(bot.asyncio, "to_thread", new=_direct_thread_mock()) as to_thread,
): ):
await bot.confirm_development_payment(callback) await bot.confirm_development_payment(callback, _StubState())
db_row = user_store.subscription_row(42, db) db_row = user_store.subscription_row(42, db)
patch_calls = [call for call in to_thread.await_args_list if call.args[0] is user_store.activate_subscription_days] patch_calls = [call for call in to_thread.await_args_list if call.args[0] is user_store.activate_subscription_days]
self.assertEqual(patch_calls[0].args[1:], (42, 10)) self.assertEqual(patch_calls[0].args[1:], (42, 10))
@@ -304,7 +312,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
patch.object(bot, "DEPLOY_ENV", "development"), patch.object(bot, "DEPLOY_ENV", "development"),
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
): ):
await bot.confirm_development_payment(callback) await bot.confirm_development_payment(callback, _StubState())
to_thread.assert_not_awaited() to_thread.assert_not_awaited()
callback.answer.assert_awaited_once() callback.answer.assert_awaited_once()
@@ -316,7 +324,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
patch.object(bot, "DEPLOY_ENV", "production"), patch.object(bot, "DEPLOY_ENV", "production"),
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
): ):
await bot.confirm_development_payment(callback) await bot.confirm_development_payment(callback, _StubState())
to_thread.assert_not_awaited() to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with() callback.answer.assert_awaited_once_with()
@@ -328,7 +336,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
patch.object(bot, "DEPLOY_ENV", "staging"), patch.object(bot, "DEPLOY_ENV", "staging"),
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
): ):
await bot.confirm_development_payment(callback) await bot.confirm_development_payment(callback, _StubState())
to_thread.assert_not_awaited() to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with() callback.answer.assert_awaited_once_with()
+10 -12
View File
@@ -92,15 +92,13 @@ class RawUpdateMiddlewareTests(unittest.IsolatedAsyncioTestCase):
class RawUpdateModeTests(unittest.TestCase): class RawUpdateModeTests(unittest.TestCase):
def test_middleware_is_registered_only_for_development(self): def test_middleware_is_registered_in_every_environment(self):
dispatcher = Dispatcher() """Прод-бот тоже пишет сырые апдейты: групповые события видны только так."""
with patch.object(bot, "DEPLOY_ENV", "production"), patch.object( for deploy_env in ("production", "development"):
dispatcher.update, "outer_middleware" with self.subTest(deploy_env=deploy_env):
) as register: dispatcher = Dispatcher()
self.assertFalse(bot.configure_raw_update_logging(dispatcher)) with patch.object(bot, "DEPLOY_ENV", deploy_env), patch.object(
register.assert_not_called() dispatcher.update, "outer_middleware"
with patch.object(bot, "DEPLOY_ENV", "development"), patch.object( ) as register:
dispatcher.update, "outer_middleware" self.assertTrue(bot.configure_raw_update_logging(dispatcher))
) as register: register.assert_called_once()
self.assertTrue(bot.configure_raw_update_logging(dispatcher))
register.assert_called_once()
+81 -29
View File
@@ -145,6 +145,8 @@ def init_db(db_path: str | None = None) -> None:
TRIAL_DAYS = 3 TRIAL_DAYS = 3
SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием. SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием.
SUBSCRIPTION_PRICE_STARS = 450 SUBSCRIPTION_PRICE_STARS = 450
# Время окончания подписки показываем по Москве (MSK = фиксированный UTC+3).
MSK_TIMEZONE = datetime.timezone(datetime.timedelta(hours=3))
SUBSCRIPTION_PLANS = { SUBSCRIPTION_PLANS = {
0: 1, 0: 1,
1: 15, 1: 15,
@@ -153,33 +155,75 @@ SUBSCRIPTION_PLANS = {
} }
def _subscription_expires_at(sub: sqlite3.Row) -> datetime.datetime | None:
if not sub or sub["is_active"] == 0:
return None
def utc(value: str) -> datetime.datetime:
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
if sub["last_paid_at"] is None:
return utc(sub["trial_started_at"]) + datetime.timedelta(days=TRIAL_DAYS)
return utc(sub["last_paid_at"])
def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int: def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int:
"""Сколько дней осталось от оплаты (триал или подписка). Отрицательно = истекло/отключено.""" """Сколько дней осталось от оплаты (триал или подписка). Отрицательно = истекло/отключено."""
if not sub: if not sub:
return -1 return -1
if sub["is_active"] == 0: if sub["is_active"] == 0:
return -1 return -1
# SQLite CURRENT_TIMESTAMP возвращает UTC-нейтральный datetime. expires = _subscription_expires_at(sub)
def utc(value: str) -> datetime.datetime: if expires is None:
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) return -1
if sub["last_paid_at"] is None:
# Триал: trial_started_at + 3 дня
trial_start = utc(sub["trial_started_at"])
if trial_start.tzinfo is not None:
trial_start = trial_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
expires = trial_start + datetime.timedelta(days=TRIAL_DAYS)
else:
expires = utc(sub["last_paid_at"])
if expires.tzinfo is not None:
expires = expires.astimezone(datetime.timezone.utc).replace(tzinfo=None)
now = now or datetime.datetime.now(datetime.timezone.utc) now = now or datetime.datetime.now(datetime.timezone.utc)
now = now.replace(tzinfo=None) now = now.astimezone(datetime.timezone.utc).replace(tzinfo=None) if now.tzinfo else now
delta = expires - now delta = expires - now
# Полные дни, пока expires > now. При истечении (expires <= now) считаем -1. return delta.days if delta.total_seconds() > 0 else -1
return delta.days if expires > now else -1
def subscription_time_left(
sub: sqlite3.Row,
now: datetime.datetime | None = None,
display_tz: datetime.timezone | None = None,
) -> tuple[bool, int | str, str]:
expires = _subscription_expires_at(sub)
if expires is None:
return False, 0, ""
now = now or datetime.datetime.now(datetime.timezone.utc)
now = now.astimezone(datetime.timezone.utc).replace(tzinfo=None) if now.tzinfo else now
seconds = int((expires - now).total_seconds())
if seconds <= 0:
return False, 0, ""
if seconds >= 86400:
# Округляем вверх: купленные N дней показываем как "N дн.", а не "N-1".
return True, -(-seconds // 86400), "дн."
# Меньше суток (последний день) — показываем конкретное время окончания вместо "N ч.".
display_tz = display_tz or MSK_TIMEZONE
stamp = (expires + datetime.timedelta(microseconds=1)).replace(tzinfo=datetime.timezone.utc)
return True, stamp.astimezone(display_tz).strftime("%d.%m.%Y %H:%M") + " (МСК)", ""
def subscription_status_text(
sub: sqlite3.Row | None,
now: datetime.datetime | None = None,
display_tz: datetime.timezone | None = None,
) -> str:
"""Единый текст статуса подписки: дни (>= суток) или конкретное время (< суток)."""
if not sub:
return "Подписка закончилась"
active, value, unit = subscription_time_left(sub, now=now, display_tz=display_tz)
if not active:
return "Подписка закончилась"
if unit == "":
# value = "31.10.2026 13:32 (МСК)" -> "Закончится 31.10.2026 в 13:32 (МСК)".
date_part, _, rest = str(value).partition(" ")
return f"Подписка активна\nЗакончится {date_part} в {rest}"
return f"Подписка активна\nЗакончится через {value} {unit}"
def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None: def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None:
with closing(connect(db_path)) as connection: with closing(connect(db_path)) as connection:
return connection.execute( return connection.execute(
@@ -196,7 +240,11 @@ def ensure_subscription(user_id: int, db_path: str | None = None) -> None:
def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int: def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int:
"""Добавляет срок к текущей подписке и возвращает оставшиеся дни.""" """Добавляет срок к подписке и возвращает оставшиеся дни (ceil).
Сложение ведётся по абсолютному UTC-моменту истечения, без округления дней:
это исключает потерю дроби при каждой продлировке (было 10д + 1д -> 10д).
"""
if days <= 0: if days <= 0:
raise ValueError("Subscription days must be positive") raise ValueError("Subscription days must be positive")
with closing(connect(db_path)) as connection, connection: with closing(connect(db_path)) as connection, connection:
@@ -204,12 +252,9 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No
if not sub: if not sub:
raise RuntimeError("Subscription record not found") raise RuntimeError("Subscription record not found")
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
current_days = _days_until(sub, now) current_expires = _subscription_expires_at(sub)
if current_days > 0: base = max(now, current_expires) if current_expires is not None else now
current_expires = now + datetime.timedelta(days=current_days, seconds=1) new_expires = base + datetime.timedelta(days=days)
new_expires = current_expires + datetime.timedelta(days=days)
else:
new_expires = now + datetime.timedelta(days=days)
connection.execute( connection.execute(
""" """
UPDATE subscriptions UPDATE subscriptions
@@ -218,7 +263,8 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No
""", """,
(new_expires.isoformat(), days, user_id), (new_expires.isoformat(), days, user_id),
) )
return max(0, (new_expires - now).days) remaining_seconds = int((new_expires - now).total_seconds())
return -(-remaining_seconds // 86400) if remaining_seconds > 0 else 0
def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int: def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int:
@@ -235,13 +281,19 @@ def deactivate_subscription(user_id: int, db_path: str | None = None) -> None:
def is_subscribed(user_id: int, db_path: str | None = None) -> bool: def is_subscribed(user_id: int, db_path: str | None = None) -> bool:
"""Правда ли подписка активна (срок не истёк).""" """Правда ли подписка активна (срок не истёк).
Проверяем по абсолютному UTC-моменту истечения, а не по целым оставшимся
дням: доступ действует до последней секунды срока, весь «последний день».
"""
sub = subscription_row(user_id, db_path) sub = subscription_row(user_id, db_path)
if not sub: if not sub or not sub["is_active"]:
return False return False
if not sub["is_active"]: expires = _subscription_expires_at(sub)
if expires is None:
return False return False
return _days_until(sub) > 0 now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
return expires > now
def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]: def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]: