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.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.")
|
||||
|
||||
|
||||
@@ -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()
|
||||
+106
@@ -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]:
|
||||
|
||||
Reference in new issue
Block a user