diff --git a/.hermes/scripts/deploy.sh b/.hermes/scripts/deploy.sh new file mode 100755 index 0000000..8d81172 --- /dev/null +++ b/.hermes/scripts/deploy.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Деплой приложения в Coolify v4 (ставит деплой в очередь). +# Coolify API не имеет endpoint /applications/{uuid}/deploy — +# деплой запускается через POST /api/v1/deploy?uuid=&force=false +# +# Использование: +# ./deploy.sh # деплой по умолчанию (social-post-writer-bot-development) +# APP_UUID= ./deploy.sh # деплой другого приложения +# APP_UUID= FORCE=true ./deploy.sh +set -euo pipefail + +# Универсально: uuid можно переопределить через env +APP_UUID="${APP_UUID:-d32u5zcq2g4odmdpq9hw91yn}" +APP_NAME="${APP_NAME:-social-post-writer-bot-development}" +FORCE="${FORCE:-false}" + +COOLIFY_API="${COOLIFY_API:-http://10.19.0.2:8000/api/v1}" +COOLIFY_TOKEN=$(grep '^COOLIFY_TOKEN=' /root/.hermes/.env | cut -d= -f2-) + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +if [ -z "$COOLIFY_TOKEN" ]; then + echo "ERROR: COOLIFY_TOKEN не найден в /root/.hermes/.env" >&2 + exit 1 +fi + +log "Запуск деплоя $APP_NAME (uuid=$APP_UUID, force=$FORCE)" + +RESP=$(curl -sS --max-time 60 -X POST \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" \ + -H "Content-Type: application/json" \ + "${COOLIFY_API}/deploy?uuid=${APP_UUID}&force=${FORCE}") + +echo "$RESP" + +# Проверяем, что деплой поставлен в очередь +if printf '%s' "$RESP" | grep -q '"deployment_uuid"'; then + DEPLOY_UUID=$(printf '%s' "$RESP" | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) + print(d["deployments"][0]["deployment_uuid"]) +except Exception: + print("")') + log "DEPLOY В ОЧЕРЕДИ: $DEPLOY_UUID" + exit 0 +else + echo "ERROR: деплой не запустился" >&2 + exit 1 +fi diff --git a/bot.py b/bot.py index 30a867b..9e0c358 100644 --- a/bot.py +++ b/bot.py @@ -14,21 +14,29 @@ from aiogram.client.telegram import TEST from aiogram.filters import CommandStart from aiogram.types import Message +import user_store + from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio from help_message import HELP_RICH_HTML, rich_to_plain from user_store import ( add_post_samples, clear_user_data, + deactivate_subscription, get_deploy_notice_version, get_idea_history, get_post_history, get_recent_post_history_text, get_tone_of_voice, init_db, + is_subscribed, save_idea, save_generated_posts, save_tone_of_voice, set_deploy_notice_version, + SUBSCRIPTION_PRICE_STARS, + ensure_subscription, + subscription_row, + activate_subscription, ) logging.basicConfig(level=logging.INFO) @@ -131,7 +139,11 @@ async def send_rich_help(message: Message) -> bool: @router.message(CommandStart()) async def start(message: Message) -> None: + if message.from_user is None: + await message.answer("Не удалось определить пользователя.") + return await notify_development_deploy(message.bot) + await asyncio.to_thread(ensure_subscription, message.from_user.id) await message.answer( "Привет! Я помогаю писать посты для соцсетей. Расскажи, о чём нужен пост — " "а я верну три варианта: короткий, развёрнутый и с призывом к действию." @@ -150,6 +162,32 @@ 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("Не удалось определить пользователя.") @@ -159,6 +197,107 @@ async def clear_command(message: Message) -> None: "Команда /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.") + return + days_left = user_store._days_until(sub) + if days_left >= 0: + if sub["last_paid_at"]: + await message.answer( + f"Подписка активна — осталось {days_left} дн.\n" + f"Следующее списание через {days_left} дн." + ) + else: + await message.answer( + f"Триал активен — осталось {days_left} дн.\n" + f"Когда триал закончится, нужно оплатить подписку — команда /subscribe." + ) + else: + await message.answer( + "Твой период (триал или подписка) истёк. " + "Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц." + ) + if sub["last_paid_at"] is None: + await message.answer( + f"Чтобы активировать — пришли {SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe." + ) + + +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 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) + deactivate_subscription(message.from_user.id) + await message.answer( + "Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. " + "API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново." + ) + await asyncio.sleep(0.5) + await message.answer( + "Подписка обнулена. У тебя нет активной подписки и триала — сгенерировать пост нельзя. " + f"Чтобы продолжить, купи подписку за {user_store.SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe." + ) async def help_command(message: Message) -> None: @@ -175,10 +314,15 @@ async def help_command(message: Message) -> None: await message.answer(rich_to_plain(HELP_RICH_HTML)) -async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None: - await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}") - await asyncio.to_thread(save_idea, user_id, idea) +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 async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None: @@ -188,6 +332,16 @@ 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): @@ -219,6 +373,10 @@ 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 reply_message = message.reply_to_message original_post = "" if reply_message and reply_message.from_user and reply_message.from_user.is_bot: @@ -419,6 +577,10 @@ async def handle_command_text(message: Message) -> None: await clear_command(message) elif command == "/idea": await _classify_and_respond(message, "предложи идею для нового поста") + elif command == "/balance": + await balance_command(message) + elif command == "/subscribe": + await subscribe_command(message) else: await message.answer("Неизвестная команда. Используй /help.") diff --git a/tests/test_writer_features.py b/tests/test_writer_features.py index dd00212..1066318 100644 --- a/tests/test_writer_features.py +++ b/tests/test_writer_features.py @@ -470,5 +470,124 @@ class TelegramUtilsTests(unittest.TestCase): self.assertEqual(" ".join(chunks).split(), ("слово " * 2000).split()) +class SubscriptionTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + import tempfile + from pathlib import Path + + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.db_path = str(Path(self.temp_dir.name) / "sub_test.sqlite3") + self.db_env = patch.dict("os.environ", {"BOT_DB_PATH": self.db_path}, clear=False) + self.db_env.start() + self.addCleanup(self.db_env.stop) + user_store.init_db(self.db_path) + + def test_subscription_table_created(self): + import sqlite3 + from contextlib import closing + + with closing(sqlite3.connect(self.db_path)) as conn: + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + self.assertIn("subscriptions", tables) + + async def test_start_creates_trial_subscription(self): + message = SimpleNamespace( + from_user=SimpleNamespace(id=101), + answer=AsyncMock(), + bot=AsyncMock(), + ) + + async def run_in_thread(function, *args): + return function(*args) + + with patch.object(bot, "notify_development_deploy", new=AsyncMock()): + with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): + await bot.start(message) + + sub = user_store.subscription_row(101, self.db_path) + self.assertIsNotNone(sub) + self.assertIsNone(sub["last_paid_at"]) + self.assertEqual(sub["is_active"], 1) + self.assertGreater(user_store._days_until(sub), 0) + + async def test_subscribe_command_activates_30_days(self): + user_store.ensure_subscription(102, self.db_path) + message = SimpleNamespace( + from_user=SimpleNamespace(id=102), + answer=AsyncMock(), + ) + + async def run_in_thread(function, *args, **kwargs): + return function(*args, **kwargs) + + with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): + with patch.object(bot, "notify_development_deploy", new=AsyncMock()): + 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) + sub = user_store.subscription_row(102, self.db_path) + self.assertIsNotNone(sub["last_paid_at"]) + self.assertEqual(sub["is_active"], 1) + self.assertTrue(user_store._days_until(sub) >= 29) + + async def test_balance_command_shows_subscription_info(self): + user_store.ensure_subscription(103, self.db_path) + message = SimpleNamespace( + from_user=SimpleNamespace(id=103), + answer=AsyncMock(), + ) + + async def run_in_thread(function, *args, **kwargs): + return function(*args, **kwargs) + + with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): + await bot.balance_command(message) + + self.assertIn("осталось", message.answer.call_args.args[0].lower()) + + async def test_blocked_when_subscription_expired(self): + message = SimpleNamespace( + from_user=SimpleNamespace(id=104), + answer=AsyncMock(), + bot=AsyncMock(), + ) + + user_store.ensure_subscription(104, self.db_path) + user_store.deactivate_subscription(104, self.db_path) + + with patch.object(bot, "notify_development_deploy", new=AsyncMock()): + with patch.object(bot.asyncio, "to_thread", side_effect=lambda f, *a: f(*a)): + await bot._classify_and_respond(message, "напиши пост", show_typing=False) + + # ответ должен быть о блокировке, а не о генерации + 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) + + async def test_clear_deactivates_subscription(self): + user_store.ensure_subscription(105, self.db_path) + user_store.activate_subscription(105, 1, self.db_path) + message = SimpleNamespace( + from_user=SimpleNamespace(id=105), + answer=AsyncMock(), + ) + + async def run_in_thread(function, *args, **kwargs): + return function(*args, **kwargs) + + with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread): + with patch.object(bot, "clear_user_data", new=AsyncMock()): + await bot.clear_command(message) + + msg_calls = [c.args[0] for c in message.answer.call_args_list] + self.assertTrue(any("Подписка обнулена" in msg for msg in msg_calls)) + sub = user_store.subscription_row(105, self.db_path) + self.assertEqual(sub["is_active"], 0) + + if __name__ == "__main__": unittest.main() diff --git a/user_store.py b/user_store.py index dc7f3e3..41479f2 100644 --- a/user_store.py +++ b/user_store.py @@ -1,4 +1,5 @@ from contextlib import closing +import datetime import json import os import sqlite3 @@ -88,6 +89,111 @@ def init_db(db_path: str | None = None) -> None: connection.execute( "CREATE INDEX IF NOT EXISTS idx_api_call_logs_user_stage ON api_call_logs(telegram_user_id, stage)" ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS subscriptions ( + telegram_user_id INTEGER PRIMARY KEY, + trial_started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + monthly_days INTEGER NOT NULL DEFAULT 30, + last_paid_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + +# Периоды: триал 3 дня, подписка 30 дней (100 звёзд) +TRIAL_DAYS = 3 +SUBSCRIPTION_MONTH_DAYS = 30 +SUBSCRIPTION_PRICE_STARS = 100 + + +def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int: + """Сколько дней осталось от оплаты (триал или подписка). Отрицательно = истекло/отключено.""" + if not sub: + return -1 + if sub["is_active"] == 0: + return -1 + # SQLite CURRENT_TIMESTAMP возвращает UTC-нейтральный datetime. + def utc(value: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + + 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.replace(tzinfo=None) + delta = expires - now + # Полные дни, пока expires > now. При истечении (expires <= now) считаем -1. + return delta.days if expires > now else -1 + + +def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None: + with closing(connect(db_path)) as connection: + return connection.execute( + "SELECT * FROM subscriptions WHERE telegram_user_id = ?", (user_id,) + ).fetchone() + + +def ensure_subscription(user_id: int, db_path: str | None = None) -> None: + """Создаёт запись подписки с триалом, если её нет.""" + with closing(connect(db_path)) as connection, connection: + connection.execute( + "INSERT OR IGNORE INTO subscriptions (telegram_user_id) VALUES (?)", (user_id,) + ) + + +def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int: + """Активирует/продлевает подписку на заданное число месяцев. Возвращает дни нового срока.""" + with closing(connect(db_path)) as connection, connection: + sub = subscription_row(user_id, db_path) + if sub and sub["last_paid_at"]: + # Продление: отсчитываем от момента оплаты + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + elif sub: + # Первый платёж после триала: отсчитываем ровно 30 дней от момента оплаты + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + else: + raise RuntimeError("Subscription record not found") + new_expires = now + datetime.timedelta(days=SUBSCRIPTION_MONTH_DAYS * months) + connection.execute( + """ + UPDATE subscriptions + SET last_paid_at = ?, + is_active = 1, + monthly_days = ?, + updated_at = CURRENT_TIMESTAMP + WHERE telegram_user_id = ? + """, + (new_expires.isoformat(), SUBSCRIPTION_MONTH_DAYS * months, user_id), + ) + return (new_expires - now).days + + +def deactivate_subscription(user_id: int, db_path: str | None = None) -> None: + """Отключает подписку (период догорает до конца).""" + with closing(connect(db_path)) as connection, connection: + connection.execute( + "UPDATE subscriptions SET is_active = 0 WHERE telegram_user_id = ?", (user_id,) + ) + + +def is_subscribed(user_id: int, db_path: str | None = None) -> bool: + """Правда ли подписка активна (срок не истёк).""" + sub = subscription_row(user_id, db_path) + if not sub: + return False + if not sub["is_active"]: + return False + return _days_until(sub) > 0 def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]: