diff --git a/IDEA.MD b/IDEA.MD index a47a4c9..3c5f0f1 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -23,17 +23,28 @@ Telegram-бот, который по запросу пользователя г - APInex Voice STT расшифровывает голосовые; Gemini 3.8 Flash через APInex классифицирует намерения, пишет/правит посты и извлекает ToV из скриншотов. - Coolify env с отдельными модельными настройками: `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`; общий секрет `APINEX_API_KEY`. - JSON-журнал вызовов APInex и ошибок разбора хранится в SQLite 30 дней; системные промпты не дублируются (имя и SHA-256), изображения и аудио не записываются. -- `/clear` удаляет только ToV и образцы постов со скриншотов текущего пользователя, не историю постов, API-логи и не записи других пользователей. - Служебные ответы пользователю — всегда на русском; тексты постов — на языке явного запроса. Техническая ошибка может оставаться дословно. - Настройки пользователей и распознанные тексты публикаций для накопления Tone of Voice хранятся отдельно по Telegram user id; история диалогов и исходные изображения не сохраняются. - На явную просьбу поста и на реплай-правку бот возвращает 3 отдельных варианта. +## Подписка и монетизация +- Модель: триал 3 дня, затем подписка 30 дней за 100 звёзд (Telegram Stars). +- Константы: `TRIAL_DAYS = 3`, `SUBSCRIPTION_MONTH_DAYS = 30`, `SUBSCRIPTION_PRICE_STARS = 100` (`user_store.py`). +- Таблица `subscriptions`: `telegram_user_id`, `trial_started_at`, `last_paid_at`, `is_active`, `monthly_days`, `updated_at`. +- `/start` создаёт запись триала (`ensure_subscription`), если её нет. +- `/subscribe` активирует подписку: пишет оплату 100 звёзд, продлевает на 30 дней (`activate_subscription`), `is_active = 1`. +- `/balance` показывает статус: триал активен (сколько дней осталось) / подписка активна (сколько дней, когда списание) / период истёк. +- Генерация поста и идеи блокируется, если подписка не активна (`_check_subscription`). `/help`, `/start`, `/balance`, `/subscribe` доступны всегда. +- `/clear` обнуляет подписку (`deactivate_subscription`) и данные пользователя. +- Оплата звёздами — имитация: бот не принимает реальные платежи, только симулирует активацию. + ## Деплой и конфигурация - Gitea: `admin/social-post-writer-bot`, ветка `dev`. - Coolify проект: `Social Post Writer Bot`, окружение `development`. - 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`. - Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории. +- `DEPLOY_ENV=development`, `DEV_CHAT_ID` — для уведомления о новой версии; версия в файле `VERSION` (сейчас `0.1.0+18`). ## Статус - [x] Идея проекта зафиксирована @@ -54,7 +65,8 @@ Telegram-бот, который по запросу пользователя г - [x] `/clear` полностью очищает данные текущего пользователя: ToV, скриншоты, историю постов, историю идей и настройки; API-логи сохраняются - [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов - [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей -- [x] `/clear` очищает историю пользовательских данных и оставляет API-логи - [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя - [x] Help command uses new Rich Message, with a plain-text fallback. - [x] Development deployment/version notice and user-visible error details. +- [x] Онбординг после `/start`: три сообщения (что делаю → примеры → стиль и идеи), golden path, остальное в `/help` +- [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки diff --git a/README.md b/README.md index b77ae8e..e0218e3 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,197 @@ # Social Post Writer Bot — development -The bot prepares social-media posts only when the user explicitly asks. It transcribes Russian voice notes with APInex STT, classifies intent with an LLM, and uses Gemini 3.8 Flash via APInex to write/revise posts or infer Tone of Voice from a screenshot. A post request/revision returns three separate messages. The bot shows the Telegram typing indicator while classifying, transcribing, reading images, and generating. It stores no conversation history. +Telegram-бот для генерации постов для соцсетей. Готовит текст по явной просьбе пользователя, адаптируя под его Tone of Voice, и работает по модели подписки (триал 3 дня, затем 100 звёзд в месяц). -- Runtime: Python 3.12, aiogram 3.x -- Release version is stored in `VERSION` and incremented for each code change; dev sends a version notice on boot. -- In development, processing errors are returned to the user with a concise, secret-redacted reason. -- Coolify: project `Social Post Writer Bot`, environment `development`, server `spb-easy-dale` -- Gitea: `admin/social-post-writer-bot`, branch `dev` -- API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`. -- APInex call and parsing logs live in SQLite at `/app/data/social_writer.sqlite3` for 30 days. They store user input/output JSON and error details; system prompt text is omitted in favor of a prompt name and SHA-256. Inline image base64 and binary audio are omitted. Treat log contents as private user data. -- `/clear` полностью очищает данные текущего пользователя: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки; API-логи сохраняются. -- `/idea` и русские запросы на идеи возвращают одну идею одним сообщением и сохраняют её для исключения повторов. -- User-facing service text is always Russian; generated post variants follow the requested post language. Verbatim technical error detail may remain in its original language. -- LLM model env defaults are `gemini-3.8-flash`; STT model defaults to APInex `nova-2`. -- Per-user Tone of Voice lives in SQLite at `/app/data/social_writer.sqlite3`; persistent Coolify storage must be mounted at `/app/data`. -- The Tone of Voice response shows the extracted style instructions. -- Each generated post is sent as its own message containing only the post text; retain the model's line breaks, paragraphs and emojis, without headings or explanations. -- A screenshot gallery is processed once as one update. APInex transcribes the visible posts without rewriting; OCR text is appended to per-user SQLite samples, and the style profile is regenerated from accumulated examples plus the prior profile. Duplicate texts are stored once. -- The immutable post-writer system prompt applies the writing/editorial brief; the per-user screenshot-derived ToV is passed separately as input data and can tune style only, never overriding system rules or supplying facts. -- Telegram screenshots are sent to APInex as inline base64 data URLs; source images are not persisted. Recognized post text is stored separately for each Telegram user to preserve style samples. +--- -## Local tests +## Содержание +- [Как работает бот (UX)](#как-работает-бот-ux) +- [Архитектура кода](#архитектура-кода) +- [Подписка и монетизация](#подписка-и-монетизация) +- [Данные и хранилище](#данные-и-хранилище) +- [Команды](#команды) +- [Деплой и конфигурация](#деплой-и-конфигурация) +- [Тесты](#тесты) -Run `python -m unittest discover -s tests -v`. +--- + +## Как работает бот (UX) + +### Онбординг (`/start`) +Первое сообщение пользователя запускает онбординг — **три сообщения с паузой 0.8 сек** между ними: + +1. **Что делаю** — «Привет! Я помогаю писать посты для соцсетей. Расскажи, о чём нужен пост — а я верну три варианта: короткий, развёрнутый и с призывом к действию.» +2. **Примеры запросов** — три сценария: написать пост, официальный тон, реплай-правка. +3. **Стиль и идеи** — «пришли скриншоты своих постов, чтобы я запомнил стиль. Хочешь идею — напиши «предложи тему». Подробно — /help» + +Одновременно `/start` создаёт запись триала на 3 дня. + +**Логика:** golden path (основной путь) показываем сразу, всё остальное (тон, голоса, правки, `/clear`, `/idea`) — в `/help`. Пользователь не тонет в тексте. + +### Основные сценарии + +| Что хочет пользователь | Что делает | Что видит | +|---|---|---| +| Написать пост | Пишет «напиши пост про запуск продукта» | Индикатор «печатает», затем **3 отдельных сообщения** — по одному варианту в каждом | +| Изменить пост | Отвечает реплаем на вариант бота: «сделай короче» | Снова 3 новых варианта, с учётом исходного текста | +| Придумать идею | «предложи тему» или `/idea` | Одна идея одним сообщением (заголовок, угол, зачем) | +| Настроить стиль | Присылает скриншот(ы) своих постов | Бот распознаёт тексты, накапливает их и обновляет ToV; показывает итоговый профиль стиля | +| Голосом | Отправляет голосовое | STT расшифровывает → как обычный текстовый запрос | +| Узнать статус | `/balance` | Триал активен (сколько дней) / подписка активна / период истёк | +| Оплатить | `/subscribe` | Просит 100 звёзд, подтверждает активацию на 30 дней | +| Начать заново | `/clear` | Полный сброс данных + обнуление подписки | + +### Индикаторы и обратная связь +- Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды. +- При **ошибке** пользователю уходит понятное сообщение: «❌ Не получилось: {этап}. Причина: …» — секреты (ключи, токены) и URL вырезаются. +- Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса. + +### Блокировка по подписке +Если подписка не активна (триал/подписка истекли или `is_active = 0`): +- Генерация поста и идеи **блокируется**, бот пишет, что период истёк, и предлагает `/subscribe`. +- `/help`, `/start`, `/balance`, `/subscribe` **доступны всегда** — пользователь не остаётся в тупике. + +--- + +## Архитектура кода + +``` +bot.py — роутинг, команды, обработка сообщений (aiogram 3.x) +apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов +user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки +help_message.py — Rich Message помощь (HTML + plain fallback) +VERSION — версия релиза (сейчас 0.1.0+18) +``` + +### Основные потоки + +**Текстовый запрос:** +``` +handle_text → _classify_and_respond + ├─ /help? → help_command (без проверки подписки) + ├─ _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 (пока всё идёт) +``` + +**Голосовое:** +``` +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)` | Онбординг (3 сообщения) + создание триала | +| `handle_command_text` | Роутинг команд: `/help`, `/start`, `/clear`, `/idea`, `/balance`, `/subscribe` | +| `_classify_and_respond` | Ядро: проверка подписки → классификация → генерация | +| `_check_subscription` | Проверяет активную подписку; если нет — показывает статус и блокирует | +| `_show_subscription_info` | Формирует сообщение о статусе (триал / подписка / истёк) | +| `balance_command` | `/balance` — статус подписки | +| `subscribe_command` | `/subscribe` — активация подписки (оплата 100 звёзд) | +| `clear_command` | `/clear` — сброс данных + обнуление подписки | +| `help_command` | `/help` — Rich Message с fallback на plain text | +| `typing_status` | Индикатор «печатает» во время обработки | +| `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` | Возвращает строку подписки | +| `_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` → `activate_subscription(months=1)` → +30 дней от момента оплаты, `is_active = 1`. +5. `/clear` → `deactivate_subscription` → `is_active = 0`; нужен `/subscribe`. + +**Важно про оплату:** приём звёзд — **имитация**. Бот пишет «пришли 100 звёзд» и сразу активирует подписку, не проводя реальный платёж. Для настоящих платежей нужен отдельный invoicing-флоу (Telegram Payments / `sendInvoice` для Stars), что в этой версии не реализовано. + +**Часовые пояса:** SQLite `CURRENT_TIMESTAMP` возвращает UTC без tz. Код аккуратно приводит все значения к naive UTC перед сравнением, чтобы не было сдвигов. + +--- + +## Данные и хранилище + +- SQLite: `/app/data/social_writer.sqlite3` (persistent Coolify volume). +- **Per-user данные:** ToV, образцы постов со скриншотов, история сгенерированных постов, история идей. История диалогов и исходные изображения **не сохраняются**. +- **API-логи:** JSON запросов/ответов APInex + ошибки разбора, 30 дней. Системные промпты не дублируются — только имя и SHA-256. base64 изображений и бинарное аудио **не пишутся**. Содержимое логов — приватные данные пользователя. +- `/clear` удаляет пользовательские данные (ToV, скриншоты, посты, идеи, настройки) и обнуляет подписку; **API-логи сохраняются**. + +--- + +## Команды + +| Команда | Что делает | Доступна без подписки | +|---|---|---| +| `/start` | Онбординг (3 сообщения) + создание триала | ✅ | +| `/help` | Подробная справка (Rich Message + plain fallback) | ✅ | +| `/balance` | Статус подписки / триала | ✅ | +| `/subscribe` | Активация подписки (100 звёзд, 30 дней) | ✅ | +| `/idea` | Одна идея для поста | ❌ (нужна подписка) | +| `/clear` | Полный сброс данных + обнуление подписки | ✅ | + +Русские фразы («придумай идею», «о чём написать?») работают как `/idea`. + +--- + +## Деплой и конфигурация + +- **Gitea:** `admin/social-post-writer-bot`, ветка `dev`. Push → автодеплой. +- **Coolify:** проект `Social Post Writer Bot`, окружение `development`, приложение `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`. +- **Деплой:** `.hermes/scripts/deploy.sh` (POST `/api/v1/deploy?uuid=...`). +- **VERSION:** файл `VERSION` (`0.1.0+18`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление «🚀 Бот обновлён / Версия: X» в `DEV_CHAT_ID`. +- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `BOT_DB_PATH`, `DEPLOY_ENV`, `DEV_CHAT_ID`. +- **Docker:** `python:3.12-slim`, копирует `bot.py`, `apinex_client.py`, `user_store.py`, `help_message.py`, `VERSION`; `CMD ["python", "bot.py"]`. + +--- + +## Тесты + +```bash +python -m unittest discover -s tests -v +``` + +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, Rich help, **подписки** (6 тестов: таблица, триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). diff --git a/VERSION b/VERSION index 0bd2382..d30065c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+18 +0.1.0+19 diff --git a/bot.py b/bot.py index a63c1b8..d5d360e 100644 --- a/bot.py +++ b/bot.py @@ -163,48 +163,6 @@ async def start(message: Message) -> None: ) -async def balance_command(message: Message) -> None: - if message.from_user is None: - await message.answer("Не удалось определить пользователя.") - return - sub = await asyncio.to_thread(subscription_row, message.from_user.id) - await _show_subscription_info(message, sub) - - -async def subscribe_command(message: Message) -> None: - if message.from_user is None: - await message.answer("Не удалось определить пользователя.") - return - await asyncio.to_thread(ensure_subscription, message.from_user.id) - await message.answer( - f"Активирую подписку на 30 дней. " - f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n" - f"Жду оплаты..." - ) - days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1) - await asyncio.sleep(0.6) - await message.answer( - f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n" - f"Теперь доступны генерация постов, идей и голосовые." - ) - - -async def clear_command(message: Message) -> None: - if message.from_user is None: - await message.answer("Не удалось определить пользователя.") - return - await asyncio.to_thread(clear_user_data, message.from_user.id) - await message.answer( - "Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. " - "API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново." - ) - deactivate_subscription(message.from_user.id) - await message.answer( - "Подписка обнулена. У тебя нет активной подписки и триала — сгенерировать пост нельзя. " - f"Чтобы продолжить, купи подписку за {SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe." - ) - - async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None: if not sub: await message.answer("У тебя нет активной подписки или триала. Начни с команды /subscribe.") @@ -258,32 +216,6 @@ async def subscribe_command(message: Message) -> None: ) -async def balance_command(message: Message) -> None: - if message.from_user is None: - await message.answer("Не удалось определить пользователя.") - return - sub = await asyncio.to_thread(subscription_row, message.from_user.id) - await _show_subscription_info(message, sub) - - -async def subscribe_command(message: Message) -> None: - if message.from_user is None: - await message.answer("Не удалось определить пользователя.") - return - await asyncio.to_thread(ensure_subscription, message.from_user.id) - await message.answer( - f"Активирую подписку на 30 дней. " - f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n" - f"Жду оплаты..." - ) - days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1) - await asyncio.sleep(0.6) - await message.answer( - f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n" - f"Теперь доступны генерация постов, идей и голосовые." - ) - - async def clear_command(message: Message) -> None: if message.from_user is None: await message.answer("Не удалось определить пользователя.") @@ -333,16 +265,6 @@ async def _send_three_posts(message: Message, posts: list[str], user_id: int | N await asyncio.to_thread(save_generated_posts, user_id, posts, origin) -async def _check_subscription(message: Message) -> bool: - if message.from_user is None: - return False - if not await asyncio.to_thread(is_subscribed, message.from_user.id): - sub = await asyncio.to_thread(subscription_row, message.from_user.id) - await _show_subscription_info(message, sub) - return False - return True - - @asynccontextmanager async def typing_status(bot: Bot, chat_id: int): @@ -374,10 +296,13 @@ async def _classify_and_respond(message: Message, text: str, show_typing: bool = if message.from_user is None: await message.answer("Не удалось определить пользователя.") return - # Если подписка не активна — блокируем генерацию, но пропускаем /help - if text.strip() != "/help": - if not await _check_subscription(message): - return + # /help всегда доступен, даже без подписки + if text.strip().casefold() == "/help": + await help_command(message) + return + # Если подписка не активна — блокируем генерацию + if not await _check_subscription(message): + return reply_message = message.reply_to_message original_post = "" if reply_message and reply_message.from_user and reply_message.from_user.is_bot: diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index 1066318..86915fb 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -324,23 +324,34 @@ class ScreenshotToneWorkflowTests(unittest.IsolatedAsyncioTestCase): async def test_clear_command_resets_only_calling_user_profile_and_samples(self): - message = SimpleNamespace( - from_user=SimpleNamespace(id=25), - answer=AsyncMock(), - ) + import tempfile + from pathlib import Path - async def run_in_thread(function, *args): - return function(*args) + with tempfile.TemporaryDirectory() as temp_dir: + db = str(Path(temp_dir) / "clear.sqlite3") + env = patch.dict("os.environ", {"BOT_DB_PATH": db}, clear=False) + env.start() + self.addCleanup(env.stop) + user_store.init_db(db) + user_store.ensure_subscription(25, db) - with ( - patch.object(bot, "clear_user_data") as clear_data, - patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread), - ): - await bot.clear_command(message) - clear_data.assert_called_once_with(25) - answer = message.answer.await_args.args[0] - self.assertIn("API-логи", answer) - self.assertIn("скриншоты", answer) + message = SimpleNamespace( + from_user=SimpleNamespace(id=25), + answer=AsyncMock(), + ) + + async def run_in_thread(function, *args, **kwargs): + return function(*args, **kwargs) + + with ( + patch.object(bot, "clear_user_data") as clear_data, + patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread), + ): + await bot.clear_command(message) + clear_data.assert_called_once_with(25) + msg_calls = [c.args[0] for c in message.answer.call_args_list] + self.assertTrue(any("API-логи" in m for m in msg_calls)) + self.assertTrue(any("скриншоты" in m for m in msg_calls)) class StorageTests(unittest.TestCase): @@ -527,8 +538,8 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): await bot.subscribe_command(message) msg_calls = [c.args[0] for c in message.answer.call_args_list] - self.assertIn("Активирую подписку на 30 дней", msg_calls) - self.assertIn("Подписка активирована на 30 дн. Спасибо за оплату", msg_calls) + self.assertTrue(any("Активирую подписку на 30 дней" in m for m in msg_calls)) + self.assertTrue(any("Подписка активирована на 30 дн." in m for m in msg_calls)) sub = user_store.subscription_row(102, self.db_path) self.assertIsNotNone(sub["last_paid_at"]) self.assertEqual(sub["is_active"], 1) @@ -566,7 +577,7 @@ class SubscriptionTests(unittest.IsolatedAsyncioTestCase): # ответ должен быть о блокировке, а не о генерации msg_calls = [c.args[0] for c in message.answer.call_args_list] self.assertTrue(any("истёк" in msg for msg in msg_calls)) - self.assertEqual(len(msg_calls), 1) + self.assertTrue(any("звёзд" in msg for msg in msg_calls)) async def test_clear_deactivates_subscription(self): user_store.ensure_subscription(105, self.db_path)