fix: isolate dev messages from production

This commit is contained in:
admin committed 2026-10-08 14:04:51 +02:00
1 parent 45b1c5b468
commit c02db5ada9
6 files changed
+124 -34

No files matched your search

+13 -7
View File
@@ -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)
+15 -6
View File
@@ -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`).
+1 -1
View File
@@ -1 +1 @@
0.1.0+27
0.1.0+28
+28 -17
View File
@@ -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Причина: <code>{html.escape(detail[:240])}</code>", parse_mode="HTML")
await message.answer(f"❌ Не получилось: {stage}. Попробуй ещё раз позже.")
if is_development_environment():
await message.answer(
f"🧪 Dev-диагностика: {stage}. Причина: <code>{html.escape(detail[:240])}</code>",
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(
+31 -1
View File
@@ -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 (
+36 -2
View File
@@ -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):