feat: подписка 100 звёзд, триал 3 дня, /balance, /subscribe
This commit is contained in:
1 parent
7c5b28233b
commit
78d57fe36b
4 files changed
+440
-3
No files matched your search
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Деплой приложения в Coolify v4 (ставит деплой в очередь).
|
||||||
|
# Coolify API не имеет endpoint /applications/{uuid}/deploy —
|
||||||
|
# деплой запускается через POST /api/v1/deploy?uuid=<app-uuid>&force=false
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# ./deploy.sh # деплой по умолчанию (social-post-writer-bot-development)
|
||||||
|
# APP_UUID=<uuid> ./deploy.sh # деплой другого приложения
|
||||||
|
# APP_UUID=<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
|
||||||
@@ -14,21 +14,29 @@ from aiogram.client.telegram import TEST
|
|||||||
from aiogram.filters import CommandStart
|
from aiogram.filters import CommandStart
|
||||||
from aiogram.types import Message
|
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 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 help_message import HELP_RICH_HTML, rich_to_plain
|
||||||
from user_store import (
|
from user_store import (
|
||||||
add_post_samples,
|
add_post_samples,
|
||||||
clear_user_data,
|
clear_user_data,
|
||||||
|
deactivate_subscription,
|
||||||
get_deploy_notice_version,
|
get_deploy_notice_version,
|
||||||
get_idea_history,
|
get_idea_history,
|
||||||
get_post_history,
|
get_post_history,
|
||||||
get_recent_post_history_text,
|
get_recent_post_history_text,
|
||||||
get_tone_of_voice,
|
get_tone_of_voice,
|
||||||
init_db,
|
init_db,
|
||||||
|
is_subscribed,
|
||||||
save_idea,
|
save_idea,
|
||||||
save_generated_posts,
|
save_generated_posts,
|
||||||
save_tone_of_voice,
|
save_tone_of_voice,
|
||||||
set_deploy_notice_version,
|
set_deploy_notice_version,
|
||||||
|
SUBSCRIPTION_PRICE_STARS,
|
||||||
|
ensure_subscription,
|
||||||
|
subscription_row,
|
||||||
|
activate_subscription,
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -131,7 +139,11 @@ async def send_rich_help(message: Message) -> bool:
|
|||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
async def start(message: Message) -> None:
|
async def start(message: Message) -> None:
|
||||||
|
if message.from_user is None:
|
||||||
|
await message.answer("Не удалось определить пользователя.")
|
||||||
|
return
|
||||||
await notify_development_deploy(message.bot)
|
await notify_development_deploy(message.bot)
|
||||||
|
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||||||
await message.answer(
|
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:
|
async def clear_command(message: Message) -> None:
|
||||||
if message.from_user is None:
|
if message.from_user is None:
|
||||||
await message.answer("Не удалось определить пользователя.")
|
await message.answer("Не удалось определить пользователя.")
|
||||||
@@ -159,6 +197,107 @@ async def clear_command(message: Message) -> None:
|
|||||||
"Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. "
|
"Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. "
|
||||||
"API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново."
|
"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:
|
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))
|
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:
|
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)
|
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
|
@asynccontextmanager
|
||||||
async def typing_status(bot: Bot, chat_id: int):
|
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:
|
if message.from_user is None:
|
||||||
await message.answer("Не удалось определить пользователя.")
|
await message.answer("Не удалось определить пользователя.")
|
||||||
return
|
return
|
||||||
|
# Если подписка не активна — блокируем генерацию, но пропускаем /help
|
||||||
|
if text.strip() != "/help":
|
||||||
|
if not await _check_subscription(message):
|
||||||
|
return
|
||||||
reply_message = message.reply_to_message
|
reply_message = message.reply_to_message
|
||||||
original_post = ""
|
original_post = ""
|
||||||
if reply_message and reply_message.from_user and reply_message.from_user.is_bot:
|
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)
|
await clear_command(message)
|
||||||
elif command == "/idea":
|
elif command == "/idea":
|
||||||
await _classify_and_respond(message, "предложи идею для нового поста")
|
await _classify_and_respond(message, "предложи идею для нового поста")
|
||||||
|
elif command == "/balance":
|
||||||
|
await balance_command(message)
|
||||||
|
elif command == "/subscribe":
|
||||||
|
await subscribe_command(message)
|
||||||
else:
|
else:
|
||||||
await message.answer("Неизвестная команда. Используй /help.")
|
await message.answer("Неизвестная команда. Используй /help.")
|
||||||
|
|
||||||
|
|||||||
@@ -470,5 +470,124 @@ class TelegramUtilsTests(unittest.TestCase):
|
|||||||
self.assertEqual(" ".join(chunks).split(), ("слово " * 2000).split())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+106
@@ -1,4 +1,5 @@
|
|||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -88,6 +89,111 @@ def init_db(db_path: str | None = None) -> None:
|
|||||||
connection.execute(
|
connection.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_api_call_logs_user_stage ON api_call_logs(telegram_user_id, stage)"
|
"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]:
|
def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]:
|
||||||
|
|||||||
Reference in new issue
Block a user