diff --git a/IDEA.MD b/IDEA.MD index 51e639d..b16e148 100644 --- a/IDEA.MD +++ b/IDEA.MD @@ -1,5 +1,11 @@ # Social Post Writer Bot +## Синхронизация документации +`README.md` и `IDEA.MD` — синхронизируемые документы проекта. Если меняются сведения о функциях, +текущем поведении, статусе, версии, решениях или процессе разработки в одном файле, внеси те же +смысловые изменения во второй. Перед завершением задачи проверь оба файла и убедись, что общие +сведения совпадают; различаться могут только детали, специфичные для самого документа. + ## Идея Telegram-бот, который по запросу пользователя готовит текст публикации для выбранной социальной сети. @@ -23,7 +29,7 @@ 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), изображения и аудио не записываются. -- Служебные ответы пользователю — всегда на русском; тексты постов — на языке явного запроса. Техническая ошибка может оставаться дословно. +- Служебные ответы пользователю — всегда на русском; тексты постов — на языке явного запроса. В production исключения не показываются; техническая диагностика приходит отдельным сообщением только в development. - Настройки пользователей и распознанные тексты публикаций для накопления Tone of Voice хранятся отдельно по Telegram user id; история диалогов и исходные изображения не сохраняются. - На явную просьбу поста и на реплай-правку бот возвращает 3 отдельных варианта. @@ -32,11 +38,11 @@ Telegram-бот, который по запросу пользователя г - Константы: `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`. +- `/subscribe` отправляет инвойс на 100 звёзд; подписка продлевается только обработчиком `successful_payment` после подтверждённой оплаты. - `/balance` показывает статус: триал активен (сколько дней осталось) / подписка активна (сколько дней, когда списание) / период истёк. - Генерация поста и идеи блокируется, если подписка не активна (`_check_subscription`). `/help`, `/start`, `/balance`, `/subscribe` доступны всегда. - `/clear` обнуляет подписку (`deactivate_subscription`) и данные пользователя. -- Оплата звёздами — имитация: бот не принимает реальные платежи, только симулирует активацию. +- Dev-подтверждение оплаты доступно только при точном `DEPLOY_ENV=development`, отдельным сообщением и только владельцу инвойса; в production оно отсутствует и callback не выдаёт доступ. ## Деплой и конфигурация - Gitea: `admin/social-post-writer-bot`, ветка `dev`. @@ -44,7 +50,7 @@ Telegram-бот, который по запросу пользователя г - 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+26`). +- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+28`). - Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`. ## Статус @@ -69,14 +75,14 @@ Telegram-бот, который по запросу пользователя г - [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя - [x] Справка `/help` разбита на разделы с навигацией инлайн-кнопками во всю ширину сообщения: из любого раздела доступна кнопка на любой другой, текущий раздел — `type="disabled"` (aiogram 3.31.0: `send_rich_message` + `edit_message_text(rich_message=…)`) - [x] Help command uses new Rich Message, with a plain-text fallback. -- [x] Development deployment/version notice and user-visible error details. +- [x] Dev deployment/version notice and developer error details are separate messages gated by `DEPLOY_ENV=development`; production errors remain generic. - [x] Онбординг после `/start`: welcome Rich Message (`WELCOME_RICH_HTML`) → пауза → вызов `help_command`; правки справки автоматически меняют онбординг - [x] Подписка: триал 3 дня, `/subscribe` за 100 звёзд (30 дней), `/balance`, блокировка генерации без активной подписки - [x] Reply-клавиатура (`keyboards.py`): основное меню при `/start`, отдельное меню с кнопкой оплаты при блокировке, кнопки мапятся на команды и запросы, slash-команды публикуются через `set_my_commands` - [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError` - [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления -- [x] В `DEPLOY_ENV=development` после инвойса доступна кнопка «🧪 Оплатил (dev)» для тестовой активации подписки без оплаты; callback ограничен пользователем инвойса, в production кнопка отсутствует -- Версия разработки: `0.1.0+27` +- [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development +- Версия разработки: `0.1.0+28` - [x] Справка бота (`/help`) описывает `/balance` и `/subscribe` - [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog) diff --git a/README.md b/README.md index bf38c00..df7d79e 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,21 @@ Telegram-бот для генерации постов для соцсетей. --- +## Синхронизация документации + +`README.md` и `IDEA.MD` — синхронизируемые документы проекта. Если меняются сведения о функциях, +текущем поведении, статусе, версии, решениях или процессе разработки в одном файле, внеси те же +смысловые изменения во второй. Перед завершением задачи проверь оба файла и убедись, что общие +сведения совпадают; различаться могут только детали, специфичные для самого документа. + ## Как работает бот (UX) ### Dev-подтверждение подписки -Только при `DEPLOY_ENV=development` после `/subscribe` показывается кнопка «🧪 Оплатил (dev)». -Она включает доступ на 30 дней без проведения реального платежа и доступна только пользователю, -которому выставлен счёт. В production эта кнопка не отображается. +Только при точном `DEPLOY_ENV=development` после обычного сообщения о счёте отдельным сообщением +показывается кнопка «🧪 Оплатил (dev)». Она включает доступ на 30 дней без реального платежа и +доступна только пользователю, которому выставлен счёт. В production, при пустом или неизвестном +значении `DEPLOY_ENV` dev-кнопка и callback не выдают доступ. ### Reply-клавиатура @@ -65,7 +73,7 @@ Slash-команды публикуются в Telegram через `set_my_comma ### Индикаторы и обратная связь - Пока **идёт обработка** (классификация, расшифровка, чтение скриншота, генерация) — бот показывает «печатает» (`typing_status`), обновляя каждые 4 секунды. -- При **ошибке** пользователю уходит понятное сообщение: «❌ Не получилось: {этап}. Причина: …» — секреты (ключи, токены) и URL вырезаются. +- При **ошибке** пользователь получает общий безопасный ответ. Только в development отдельным сообщением дополнительно отправляется диагностика; production не раскрывает текст исключения. - Служебные тексты — **всегда на русском**. Язык самого поста определяется языком запроса. ### Блокировка по подписке @@ -210,7 +218,8 @@ SUBSCRIPTION_PRICE_STARS = 100 - **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+27`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление «🚀 Бот обновлён / Версия: X» в `DEV_CHAT_ID`. +- **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения. +- **VERSION:** файл `VERSION` (`0.1.0+28`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `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"]`. @@ -222,4 +231,4 @@ SUBSCRIPTION_PRICE_STARS = 100 python -m unittest discover -s tests -v ``` -Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного начисления, dev-подтверждение оплаты и проверка владельца кнопки), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). +Покрытие: STT, LLM, vision, ToV, reply-поля, изоляция пользователей, API-логи, **reply-клавиатура** (маппинг кнопок, роутинг, `/style`), **платежи** (инвойс, pre_checkout, successful_payment, защита от двойного начисления, dev-подтверждение оплаты, проверка владельца кнопки и fail-closed отключение вне development), **ошибки** (общий ответ в production, отдельная диагностика только в development), **справка с кнопками** (меню, разделы, callback-навигация, plain fallback), **онбординг** (welcome → вызов `help_command`), **подписки** (триал после `/start`, оплата, баланс, блокировка, обнуление через `/clear`). diff --git a/VERSION b/VERSION index cf04071..19cd880 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+27 +0.1.0+28 diff --git a/bot.py b/bot.py index 17cc8dc..45875a4 100644 --- a/bot.py +++ b/bot.py @@ -54,7 +54,7 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip() BOT_ENV = os.getenv("BOT_ENV", "test").strip().lower() -DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production").strip().lower() +DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production") DEV_CHAT_ID = os.getenv("DEV_CHAT_ID", "").strip() HELP_COMMANDS = {"help", "помощь"} _deploy_notice_lock = asyncio.Lock() @@ -70,6 +70,11 @@ _deploy_notice_sent = False _deploy_notice_lock = asyncio.Lock() +def is_development_environment() -> bool: + """Enable developer-only behavior only for the explicit Coolify dev value.""" + return DEPLOY_ENV == "development" + + def _read_version() -> str: version_path = Path(__file__).with_name("VERSION") return version_path.read_text(encoding="utf-8").strip() @@ -95,14 +100,19 @@ async def report_error(message: Message, stage: str, exc: Exception) -> None: detail = re.sub(r"https?://\S+", "[внешний сервис]", detail) detail = detail or "нет подробностей" try: - await message.answer(f"❌ Не получилось: {stage}.\nПричина: {html.escape(detail[:240])}", parse_mode="HTML") + await message.answer(f"❌ Не получилось: {stage}. Попробуй ещё раз позже.") + if is_development_environment(): + await message.answer( + f"🧪 Dev-диагностика: {stage}. Причина: {html.escape(detail[:240])}", + parse_mode="HTML", + ) except Exception as send_exc: logger.error("Unable to report processing error: %s", type(send_exc).__name__) logger.error("User operation failed at %s (%s): %s", stage, type(exc).__name__, detail[:240]) async def notify_development_deploy(bot: Bot) -> None: - if DEPLOY_ENV != "development": + if not is_development_environment(): return chat_id = os.getenv("DEV_CHAT_ID", "").strip() if not chat_id: @@ -135,7 +145,7 @@ async def send_rich_message(message: Message, rich_html: str, stage: str) -> Mes return sent except Exception as exc: logger.warning("Rich %s send failed (%s): %s", stage, type(exc).__name__, str(exc)[:180]) - if DEPLOY_ENV == "development": + if is_development_environment(): await report_error(message, f"отправка Rich Message: {stage}", exc) await message.answer(help_plain_view(None) if stage == "помощи" else rich_to_plain(rich_html)) return None @@ -171,8 +181,8 @@ async def help_navigation(callback: CallbackQuery) -> None: @router.callback_query(F.data.startswith("dev_paid:")) async def confirm_development_payment(callback: CallbackQuery) -> None: - if DEPLOY_ENV != "development": - await callback.answer("Доступно только в dev-среде.", show_alert=True) + if not is_development_environment(): + await callback.answer() return try: user_id = int((callback.data or "").split(":", 1)[1]) @@ -184,7 +194,7 @@ async def confirm_development_payment(callback: CallbackQuery) -> None: return await asyncio.to_thread(ensure_subscription, user_id) days = await asyncio.to_thread(user_store.activate_subscription, user_id, months=1) - await callback.answer("Dev-доступ активирован на 30 дней.") + await callback.answer() if isinstance(callback.message, Message): await callback.message.edit_reply_markup(reply_markup=None) await callback.message.answer( @@ -256,17 +266,19 @@ async def subscribe_command(message: Message) -> None: ) except Exception as exc: logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180]) - if DEPLOY_ENV == "development": + if is_development_environment(): await report_error(message, "отправка счёта на оплату", exc) - await message.answer( - "Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту " - "или напиши поддержке." - ) + else: + await message.answer( + "Не получилось открыть счёт на оплату. Попробуй ещё раз через минуту " + "или напиши поддержке." + ) return - if DEPLOY_ENV == "development": + await message.answer(f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.") + if is_development_environment(): await message.answer( - f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n" - "Для разработки нажми кнопку ниже: она включит dev-доступ без реального платежа.", + "🧪 Dev-режим: для продолжения разработки нажми кнопку ниже. " + "Она включит доступ без реального платежа.", reply_markup=InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton( text="🧪 Оплатил (dev)", @@ -276,7 +288,6 @@ async def subscribe_command(message: Message) -> None: ) else: await message.answer( - f"Счёт на {SUBSCRIPTION_PRICE_STARS} звёзд на 30 дней выше.\n\n" "Подписка активируется сразу после оплаты — отдельного подтверждения не нужно." ) @@ -311,7 +322,7 @@ async def clear_command(message: Message) -> None: deactivate_subscription(message.from_user.id) await message.answer( "Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. " - "API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново." + "Служебные API-логи сохраняются по установленному сроку хранения. Пришли новые скриншоты, чтобы настроить стиль заново." ) await asyncio.sleep(0.5) await message.answer( diff --git a/tests/test_payments.py b/tests/test_payments.py index 83c96d7..1d39fc1 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -201,12 +201,16 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): message = self._message() with ( + patch.object(bot, "DEPLOY_ENV", "production"), patch.object(bot.asyncio, "to_thread", new=AsyncMock()), patch.object(bot, "send_subscription_invoice", new=AsyncMock()) as invoice, ): await bot.subscribe_command(message) invoice.assert_awaited_once_with(message.bot, 555, None) self.assertIn("Счёт", message.answers[0]) + self.assertEqual(len(message.answers), 2) + self.assertNotIn("dev", " ".join(message.answers).lower()) + self.assertNotIn("reply_markup", message.answer_kwargs[-1]) async def test_dev_subscribe_shows_manual_confirmation_button(self): message = self._message() @@ -217,6 +221,8 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): ): await bot.subscribe_command(message) self.assertIn("без реального платежа", message.answers[-1]) + self.assertEqual(len(message.answers), 2) + self.assertIn("Счёт", message.answers[0]) markup = message.answer_kwargs[-1]["reply_markup"] self.assertEqual(markup.inline_keyboard[0][0].text, "🧪 Оплатил (dev)") @@ -231,7 +237,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): ): await bot.confirm_development_payment(callback) self.assertEqual(to_thread.await_args.args[0], user_store.activate_subscription) - callback.answer.assert_awaited_once_with("Dev-доступ активирован на 30 дней.") + callback.answer.assert_awaited_once_with() async def test_dev_confirmation_rejects_other_user(self): callback = AsyncMock() @@ -245,6 +251,30 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase): to_thread.assert_not_awaited() callback.answer.assert_awaited_once() + async def test_prod_confirmation_does_not_grant_subscription(self): + callback = AsyncMock() + callback.data = "dev_paid:42" + callback.from_user = SimpleNamespace(id=42) + with ( + patch.object(bot, "DEPLOY_ENV", "production"), + patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, + ): + await bot.confirm_development_payment(callback) + to_thread.assert_not_awaited() + callback.answer.assert_awaited_once_with() + + async def test_unknown_environment_disables_dev_payment(self): + callback = AsyncMock() + callback.data = "dev_paid:42" + callback.from_user = SimpleNamespace(id=42) + with ( + patch.object(bot, "DEPLOY_ENV", "staging"), + patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread, + ): + await bot.confirm_development_payment(callback) + to_thread.assert_not_awaited() + callback.answer.assert_awaited_once_with() + async def test_subscribe_reports_failure_when_invoice_fails(self): message = self._message() with ( diff --git a/tests/test_release_help.py b/tests/test_release_help.py index fc498ff..f66887f 100644 --- a/tests/test_release_help.py +++ b/tests/test_release_help.py @@ -27,8 +27,9 @@ class DeployNoticeTests(unittest.IsolatedAsyncioTestCase): async def test_does_not_send_outside_development(self): fake_bot = AsyncMock() - with patch.object(bot, "DEPLOY_ENV", "production"): - await bot.notify_development_deploy(fake_bot) + for env in ("production", "", "staging", "Development", " development"): + with self.subTest(env=env), patch.object(bot, "DEPLOY_ENV", env): + await bot.notify_development_deploy(fake_bot) fake_bot.send_message.assert_not_awaited() async def test_deploy_notice_does_not_block_if_test_api_hangs(self): @@ -43,6 +44,39 @@ class DeployNoticeTests(unittest.IsolatedAsyncioTestCase): fake_bot.send_message.assert_awaited_once() +class UserFacingErrorTests(unittest.IsolatedAsyncioTestCase): + async def test_production_error_is_generic_without_exception_details(self): + message = AsyncMock() + with patch.object(bot, "DEPLOY_ENV", "production"): + await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) + message.answer.assert_awaited_once() + self.assertNotIn("internal detail", message.answer.await_args.args[0]) + + async def test_development_error_details_are_a_separate_message(self): + message = AsyncMock() + with patch.object(bot, "DEPLOY_ENV", "development"): + await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) + self.assertEqual(message.answer.await_count, 2) + self.assertNotIn("internal detail", message.answer.await_args_list[0].args[0]) + diagnostic = message.answer.await_args_list[1] + self.assertIn("Dev-диагностика", diagnostic.args[0]) + self.assertIn("internal detail", diagnostic.args[0]) + + async def test_unknown_environment_does_not_send_developer_diagnostics(self): + message = AsyncMock() + with patch.object(bot, "DEPLOY_ENV", "staging"): + await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) + message.answer.assert_awaited_once() + self.assertNotIn("internal detail", message.answer.await_args.args[0]) + + async def test_missing_environment_disables_developer_diagnostics(self): + message = AsyncMock() + with patch.object(bot, "DEPLOY_ENV", ""): + await bot.report_error(message, "генерация поста", RuntimeError("internal detail")) + message.answer.assert_awaited_once() + self.assertNotIn("internal detail", message.answer.await_args.args[0]) + + class HelpRoutingTests(unittest.TestCase): def test_commands_are_routed_in_command_handler(self):