diff --git a/Dockerfile b/Dockerfile index c0ef563..2d22f8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py support.py activity_phrases.py VERSION ./ +COPY bot.py analytics.py apinex_client.py user_store.py help_message.py payments.py keyboards.py raw_updates.py support.py activity_phrases.py VERSION ./ CMD ["python", "bot.py"] diff --git a/VERSION b/VERSION index 3b3f8ce..b51d2a4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+64 \ No newline at end of file +0.1.0+65 diff --git a/analytics.py b/analytics.py new file mode 100644 index 0000000..faa868c --- /dev/null +++ b/analytics.py @@ -0,0 +1,138 @@ +"""Per-user product analytics stored in a separate SQLite database.""" +from __future__ import annotations + +from contextlib import closing +import datetime as dt +import os +import sqlite3 +from pathlib import Path + +DEFAULT_DB_PATH = "/app/data/analytics.sqlite3" + + +def connect(db_path: str | None = None) -> sqlite3.Connection: + path = Path(db_path or os.getenv("ANALYTICS_DB_PATH", DEFAULT_DB_PATH)) + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path, timeout=10) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 10000") + connection.execute("PRAGMA journal_mode = WAL") + return connection + + +def init_db(db_path: str | None = None) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_user_id INTEGER NOT NULL, + mode TEXT NOT NULL, + event_type TEXT NOT NULL, + message_type TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_events_user_time ON events(telegram_user_id, created_at)" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, created_at)" + ) + + +def record_event( + user_id: int, + mode: str, + event_type: str, + message_type: str | None = None, + db_path: str | None = None, +) -> None: + with closing(connect(db_path)) as connection, connection: + connection.execute( + "CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, telegram_user_id INTEGER NOT NULL, mode TEXT NOT NULL, event_type TEXT NOT NULL, message_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)" + ) + connection.execute( + "INSERT INTO events (telegram_user_id, mode, event_type, message_type) VALUES (?, ?, ?, ?)", + (int(user_id), mode[:30], event_type[:50], message_type[:20] if message_type else None), + ) + + +def _period_start(period: str, now: dt.datetime | None = None) -> str: + now = now or dt.datetime.now(dt.timezone.utc) + days = {"day": 1, "week": 7, "month": 30}.get(period, 0) + return (now - dt.timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + + +def user_metrics(user_id: int, period: str = "all", db_path: str | None = None) -> dict[str, object]: + where = "telegram_user_id = ?" + args: list[object] = [int(user_id)] + if period != "all": + where += " AND created_at >= ?" + args.append(_period_start(period)) + with closing(connect(db_path)) as connection: + rows = connection.execute( + f"SELECT mode, event_type, message_type, COUNT(*) AS total FROM events WHERE {where} GROUP BY mode, event_type, message_type", + args, + ).fetchall() + metrics: dict[str, object] = { + "user_id": int(user_id), "period": period, + "post_generations": 0, "idea_generations": 0, "post_revisions": 0, + "generation_messages": 0, "training_messages": 0, + "generation_message_types": {}, "training_message_types": {}, + } + generation_types: dict[str, int] = {} + training_types: dict[str, int] = {} + for row in rows: + event_type, message_type, total = row["event_type"], row["message_type"], int(row["total"]) + if event_type == "post_generation": metrics["post_generations"] = int(metrics["post_generations"]) + total + elif event_type == "idea_generation": metrics["idea_generations"] = int(metrics["idea_generations"]) + total + elif event_type == "post_revision": metrics["post_revisions"] = int(metrics["post_revisions"]) + total + elif event_type == "user_message": + target = generation_types if row["mode"] == "generation" else training_types + if row["mode"] == "generation": metrics["generation_messages"] = int(metrics["generation_messages"]) + total + elif row["mode"] == "training": metrics["training_messages"] = int(metrics["training_messages"]) + total + if message_type: target[str(message_type)] = target.get(str(message_type), 0) + total + metrics["generation_message_types"] = generation_types + metrics["training_message_types"] = training_types + return metrics + + +def all_metrics(period: str = "all", db_path: str | None = None) -> dict[str, object]: + """Return owner-facing aggregate metrics from per-user events.""" + where = "" + args: list[object] = [] + if period != "all": + where = " WHERE created_at >= ?" + args.append(_period_start(period)) + with closing(connect(db_path)) as connection: + row = connection.execute( + f"""SELECT COUNT(DISTINCT telegram_user_id) AS users, + COUNT(DISTINCT CASE WHEN event_type = 'user_message' THEN telegram_user_id END) AS active_users, + SUM(CASE WHEN event_type = 'post_generation' THEN 1 ELSE 0 END) AS post_generations, + SUM(CASE WHEN event_type = 'idea_generation' THEN 1 ELSE 0 END) AS idea_generations, + SUM(CASE WHEN event_type = 'post_revision' THEN 1 ELSE 0 END) AS post_revisions, + SUM(CASE WHEN event_type = 'user_message' AND mode = 'generation' THEN 1 ELSE 0 END) AS generation_messages, + SUM(CASE WHEN event_type = 'user_message' AND mode = 'training' THEN 1 ELSE 0 END) AS training_messages + FROM events{where}""", args + ).fetchone() + types = connection.execute( + f"SELECT mode, message_type, COUNT(*) AS total FROM events WHERE event_type='user_message' AND message_type IS NOT NULL{(' AND created_at >= ?' if period != 'all' else '')} GROUP BY mode, message_type", + args, + ).fetchall() + result = {key: int(row[key] or 0) for key in ("users", "active_users", "post_generations", "idea_generations", "post_revisions", "generation_messages", "training_messages")} + result["message_types"] = {(str(r["mode"]), str(r["message_type"])): int(r["total"]) for r in types} + result["period"] = period + return result + + +def render_report(period: str = "day", db_path: str | None = None) -> str: + metrics = all_metrics(period, db_path) + types = metrics["message_types"] + generation_types = {k[1]: v for k, v in types.items() if k[0] == "generation"} + training_types = {k[1]: v for k, v in types.items() if k[0] == "training"} + def line(label: str, value: object) -> str: + return f"{label}: {value}" + rows = ["

📊 Статистика бота

", f"

Период: {period}

", "

Пользователи

", line("Пользователей", metrics["users"]), line("Активных", metrics["active_users"]), "

Генерация

", line("Постов", metrics["post_generations"]), line("Идей", metrics["idea_generations"]), line("Правок", metrics["post_revisions"]), line("Сообщений", metrics["generation_messages"]), line("Типы", ", ".join(f"{k}: {v}" for k, v in generation_types.items()) or "нет"), "

Обучение

", line("Сообщений", metrics["training_messages"]), line("Типы", ", ".join(f"{k}: {v}" for k, v in training_types.items()) or "нет")] + return "

" + "
".join(rows) + "

" diff --git a/bot.py b/bot.py index 55fe91c..5f5f75c 100644 --- a/bot.py +++ b/bot.py @@ -39,9 +39,10 @@ from raw_updates import RawUpdateMiddleware, init_raw_updates_db from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, classify_prompt_injection, create_tone_profile, current_operation_id, diagnostic_operation, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, generate_posts_from_images, transcribe_audio from help_message import WELCOME_RICH_HTML, help_plain_view, help_view, rich_to_plain -from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text +from keyboards import MAIN_MENU_VALUE, SUBSCRIPTION_ACTION_VALUE, SUPPORT_VALUE, TRAINING_RESET_VALUE, help_inline_keyboard, main_menu_keyboard, mode_keyboard, resolve_button_text, subscription_plans_keyboard from payments import send_subscription_invoice import support +import analytics from user_store import ( add_post_samples, clear_user_data, @@ -88,6 +89,7 @@ DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production") _deploy_notice_lock = asyncio.Lock() router = Router() +admin_router = Router() # Роль «работник»: основной сценарий работает только в личке бота. # Групповые чаты (поддержка и будущие роли) обслуживаются своими роутерами — # иначе catch-all @router.message() перехватывает апдейты из групп и отвечает @@ -108,6 +110,9 @@ OFF_TOPIC_REPLIES = ( "Я работаю с публикациями для соцсетей. Сформулируй запрос на пост или идею.", ) INJECTION_BLOCK_REPLY = "Не могу выполнить инструкции, которые пытаются изменить правила работы бота. Могу помочь с постом или идеей для соцсетей." +ANALYTICS_CHAT_ID = int(os.getenv("ANALYTICS_CHAT_ID", "-1008001427913")) +ANALYTICS_TOPIC_ID = int(os.getenv("ANALYTICS_TOPIC_ID", "5")) +OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "0") or 0) MEDIA_GROUP_COLLECT_SECONDS = 0.8 _media_groups: dict[tuple[int, str], dict[str, object]] = {} _media_groups_lock = asyncio.Lock() @@ -458,28 +463,14 @@ async def _show_subscription_info( ) else: days_left = user_store._days_until(sub) - if sub["last_paid_at"]: - if days_left >= 0: - text = f"Активирована платная подписка. Осталось {days_left} дн." - else: - text = ( - f"Срок платной подписки истёк. Продление — " - f"{SUBSCRIPTION_PRICE_STARS} звёзд за 30 дней." - ) - elif days_left >= 0: - text = f"Активирован бесплатный пробный период. Осталось {days_left} дн." - else: - text = ( - f"Бесплатный пробный период истёк. Подписка — " - f"{SUBSCRIPTION_PRICE_STARS} звёзд за 30 дней." - ) + text = ( + f"Подписка активна\nЗакончится через {days_left} дн." + if days_left > 0 else "Подписка закончилась" + ) if mode == "subscription": - text += f"\n\nПродление на 30 дней — {SUBSCRIPTION_PRICE_STARS} ⭐." - if message.from_user is not None: - text += f"\nID пользователя: {message.from_user.id}" markup = mode_keyboard( - "subscription", subscription_purchased=bool(sub["last_paid_at"]) + "subscription", subscription_purchased=bool(sub and sub["last_paid_at"]) ) if sub else mode_keyboard("subscription") else: markup = mode_keyboard(mode) @@ -505,12 +496,15 @@ async def subscribe_command(message: Message, state: FSMContext) -> None: await _send_subscription_invoice(message, message.from_user.id) -async def _send_subscription_invoice(message: Message, user_id: int) -> None: +async def _send_subscription_invoice(message: Message, user_id: int, days: int = 30) -> None: await asyncio.to_thread(ensure_subscription, user_id) try: - await send_subscription_invoice( - message.bot, message.chat.id, message.message_thread_id - ) + if days == 30: + await send_subscription_invoice(message.bot, message.chat.id, message.message_thread_id) + else: + await send_subscription_invoice( + message.bot, message.chat.id, message.message_thread_id, days=days + ) except Exception as exc: logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180]) if is_development_environment(): @@ -523,7 +517,7 @@ async def _send_subscription_invoice(message: Message, user_id: int) -> None: return await message.answer( f"🧾 Счёт на подписку\n" - f"Сумма: {SUBSCRIPTION_PRICE_STARS} ⭐ · Срок: 30 дней\n" + f"Срок: {days} дней\n" f"ID пользователя: {user_id}", parse_mode="HTML", ) @@ -544,17 +538,30 @@ async def _send_subscription_invoice(message: Message, user_id: int) -> None: ) -@router.callback_query(F.data == "subscription:renew") -async def subscription_renew(callback: CallbackQuery, state: FSMContext) -> None: - if await current_mode(state) != "subscription": +@router.callback_query(F.data.startswith("subscription:plan:")) +async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None: + if await current_mode(state) != "subscription" or not isinstance(callback.message, Message): await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True) return - message = callback.message - if not isinstance(message, Message) or callback.from_user is None: - await callback.answer("Не удалось открыть оплату. Попробуй ещё раз.", show_alert=True) + try: + days = int((callback.data or "").rsplit(":", 1)[1]) + except ValueError: + await callback.answer("Некорректный срок.", show_alert=True) + return + if days not in user_store.SUBSCRIPTION_PLANS: + await callback.answer("Такой тариф недоступен.", show_alert=True) return await callback.answer() - await _send_subscription_invoice(message, callback.from_user.id) + await _send_subscription_invoice(callback.message, callback.from_user.id, days) + + +@router.callback_query(F.data == "subscription:renew") +async def subscription_renew(callback: CallbackQuery, state: FSMContext) -> None: + if await current_mode(state) != "subscription" or not isinstance(callback.message, Message): + await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True) + return + await callback.answer() + await callback.message.answer("Выберите срок подписки", reply_markup=subscription_plans_keyboard()) async def style_command(message: Message, state: FSMContext) -> None: @@ -778,6 +785,7 @@ async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> No parse_mode="HTML", ) await asyncio.to_thread(save_idea, user_id, idea) + await asyncio.to_thread(analytics.record_event, user_id, "generation", "idea_generation") async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None: @@ -785,7 +793,8 @@ async def _send_three_posts(message: Message, posts: list[str], user_id: int | N await message.answer(post) if user_id is not None: await asyncio.to_thread(save_generated_posts, user_id, posts, origin) - + event_type = "post_revision" if origin == "revised" else "post_generation" + await asyncio.to_thread(analytics.record_event, user_id, "generation", event_type) @asynccontextmanager @@ -1242,6 +1251,9 @@ async def _generate_posts_from_images(message: Message, images, request: str, st async def _handle_image_message(message: Message, bot: Bot, state: FSMContext) -> None: + mode = await current_mode(state) + if message.from_user is not None and mode in {"generation", "training"}: + await asyncio.to_thread(analytics.record_event, message.from_user.id, mode, "user_message", "image") if (await notify_development_deploy(bot, state, message.from_user.id if message.from_user else None)) is True: return with diagnostic_operation(): @@ -1339,7 +1351,16 @@ async def _process_voice(message: Message, bot: Bot, state: FSMContext, status=N await status.finish("⚠️ Не удалось распознать речь") await message.answer("Не удалось распознать речь в аудио.") return - if await current_mode(state) == "training": + current_mode_result = await current_mode(state) + if message.from_user is not None: + await asyncio.to_thread( + analytics.record_event, + message.from_user.id, + current_mode_result, + "user_message", + "voice" if message.voice else "audio", + ) + if current_mode_result == "training": if message.from_user is None: if status: await status.finish("❌ Не удалось определить пользователя") @@ -1414,6 +1435,8 @@ async def handle_command_text(message: Message, state: FSMContext) -> None: await subscribe_command(message, state) elif command == "/support": await open_support(message, state) + elif command == "/stats": + await stats_command(message, state) else: await message.answer("Неизвестная команда. Открой /help, чтобы посмотреть доступные команды.") @@ -1436,7 +1459,7 @@ async def handle_text(message: Message, state: FSMContext) -> None: return if button == SUBSCRIPTION_ACTION_VALUE: if await require_mode(message, state, "subscription") and message.from_user is not None: - await _send_subscription_invoice(message, message.from_user.id) + await message.answer("Выберите срок подписки", reply_markup=subscription_plans_keyboard()) return if button == TRAINING_RESET_VALUE: await clear_command(message, state) @@ -1446,6 +1469,8 @@ async def handle_text(message: Message, state: FSMContext) -> None: return mode = await current_mode(state) + if message.from_user is not None and mode in {"generation", "training"}: + await asyncio.to_thread(analytics.record_event, message.from_user.id, mode, "user_message", "text") pending_image = (await state.get_data()).get("pending_image_request") if mode == "generation" and pending_image: await state.update_data(pending_image_request=None) @@ -1491,6 +1516,26 @@ async def handle_other_message(message: Message, state: FSMContext) -> None: await message.answer(text, reply_markup=keyboard_for_mode(mode)) +async def send_owner_stats(bot: Bot, period: str = "day") -> None: + if not OWNER_USER_ID: + return + report = await asyncio.to_thread(analytics.render_report, period) + await bot.send_rich_message(chat_id=ANALYTICS_CHAT_ID, message_thread_id=ANALYTICS_TOPIC_ID, rich_message=InputRichMessage(html=report)) + + +@admin_router.message(F.text.regexp(r"^/stats(?:\s+(day|week|month|all))?$") ) +async def stats_command(message: Message, state: FSMContext) -> None: + if message.chat.id != ANALYTICS_CHAT_ID or message.message_thread_id != ANALYTICS_TOPIC_ID: + return + if OWNER_USER_ID and (not message.from_user or message.from_user.id != OWNER_USER_ID): + return + period = ((message.text or "").split(maxsplit=1)[1] if len((message.text or "").split()) > 1 else "day") + report = await asyncio.to_thread(analytics.render_report, period) + if message.bot is None: + return + await message.bot.send_rich_message(chat_id=ANALYTICS_CHAT_ID, message_thread_id=ANALYTICS_TOPIC_ID, rich_message=InputRichMessage(html=report)) + + async def set_main_menu(bot: Bot) -> None: """Публикует команды переключения режимов и действия текущих режимов.""" try: @@ -1508,6 +1553,7 @@ async def set_main_menu(bot: Bot) -> None: BotCommand(command="balance", description="Статус подписки"), BotCommand(command="subscribe", description="Оплатить подписку"), BotCommand(command="support", description="Написать в поддержку"), + BotCommand(command="stats", description="Статистика бота"), ] ) except Exception as exc: @@ -1516,6 +1562,7 @@ async def set_main_menu(bot: Bot) -> None: async def main() -> None: init_db() + analytics.init_db() payments.init_payments_table() await asyncio.to_thread(support.init_support_table) if is_development_environment(): @@ -1534,6 +1581,7 @@ async def main() -> None: # Роли: групповые сценарии регистрируются ДО основного роутера, иначе # catch-all @router.message() основного роутера перехватывает их апдейты. dp.include_router(support.router) + dp.include_router(admin_router) dp.include_router(router) dp.message.outer_middleware(support.SupportRelayMiddleware()) # Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту. diff --git a/keyboards.py b/keyboards.py index 15a6270..bc6dc12 100644 --- a/keyboards.py +++ b/keyboards.py @@ -88,6 +88,14 @@ def mode_keyboard( ) +def subscription_plans_keyboard() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="1 день — 15 ⭐", callback_data="subscription:plan:1")], + [InlineKeyboardButton(text="10 дней — 150 ⭐", callback_data="subscription:plan:10")], + [InlineKeyboardButton(text="30 дней — 450 ⭐", callback_data="subscription:plan:30")], + ]) + + def help_inline_keyboard() -> InlineKeyboardMarkup: """Стандартная inline-клавиатура для навигации по разделам справки.""" return InlineKeyboardMarkup( diff --git a/payments.py b/payments.py index db6d4d5..26f241c 100644 --- a/payments.py +++ b/payments.py @@ -18,7 +18,8 @@ from aiogram import F, Router from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment from user_store import ( - SUBSCRIPTION_PRICE_STARS, + SUBSCRIPTION_PLANS, + activate_subscription_days, activate_subscription, connect, ensure_subscription, @@ -28,7 +29,18 @@ logger = logging.getLogger(__name__) router = Router(name="payments") STARS_CURRENCY = "XTR" -INVOICE_PREFIX = "sub30:" +INVOICE_PREFIX = "sub:" + + +def plan_for_payload(payload: str) -> tuple[int, int] | None: + try: + days = int(payload.split(":", 2)[1]) + except (IndexError, ValueError): + return None + if days == 30 and payload.startswith("sub30:"): + return (30, SUBSCRIPTION_PLANS[30]) + price = SUBSCRIPTION_PLANS.get(days) + return (days, price) if price is not None else None def _db_path(db_path: str | None = None) -> str | None: @@ -81,16 +93,19 @@ def save_payment( ) -async def send_subscription_invoice(bot, chat_id: int, thread_id: int | None = None) -> None: - """Отправляет инвойс на 30 дней подписки за Stars.""" - payload = f"{INVOICE_PREFIX}{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}" +async def send_subscription_invoice( + bot, chat_id: int, thread_id: int | None = None, days: int = 30 +) -> None: + """Отправляет выбранный инвойс на разовую подписку в Stars.""" + price = SUBSCRIPTION_PLANS[days] + payload = f"{INVOICE_PREFIX}{days}:{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}" kwargs: dict[str, object] = { "chat_id": chat_id, - "title": "Подписка на 30 дней", - "description": "Доступ к генерации постов, идей и голосовым на 30 дней.", + "title": f"Подписка на {days} дней", + "description": "Доступ к боту по генерации текста постов", "payload": payload, "currency": STARS_CURRENCY, - "prices": [LabeledPrice(label="30 дней", amount=SUBSCRIPTION_PRICE_STARS)], + "prices": [LabeledPrice(label=f"{days} дней", amount=price)], "provider_token": "", } if thread_id is not None: @@ -101,12 +116,10 @@ async def send_subscription_invoice(bot, chat_id: int, thread_id: int | None = N @router.pre_checkout_query() async def handle_pre_checkout(query: PreCheckoutQuery) -> None: """Подтверждаем только свой инвойс, в правильной валюте и по правильной цене.""" - if query.currency != STARS_CURRENCY or query.total_amount != SUBSCRIPTION_PRICE_STARS: + plan = plan_for_payload(query.invoice_payload) + if plan is None or query.currency != STARS_CURRENCY or query.total_amount != plan[1]: await query.answer(ok=False, error_message="Некорректная сумма или валюта платежа.") return - if not query.invoice_payload.startswith(INVOICE_PREFIX): - await query.answer(ok=False, error_message="Этот счёт не принадлежит подписке.") - return await query.answer(ok=True) @@ -117,12 +130,11 @@ async def handle_successful_payment(message: Message) -> None: return charge_id = payment.telegram_payment_charge_id - if payment.currency != STARS_CURRENCY or payment.total_amount != SUBSCRIPTION_PRICE_STARS: + plan = plan_for_payload(payment.invoice_payload) + if plan is None or payment.currency != STARS_CURRENCY or payment.total_amount != plan[1]: await _reject(message, charge_id, "неверная сумма или валюта") return - if not payment.invoice_payload.startswith(INVOICE_PREFIX): - await _reject(message, charge_id, "неизвестный invoice_payload") - return + days, _price = plan db_path = _db_path() if await asyncio.to_thread(payment_recorded, charge_id, db_path): @@ -130,7 +142,7 @@ async def handle_successful_payment(message: Message) -> None: return await asyncio.to_thread(ensure_subscription, message.from_user.id) - days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1) + days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days) await asyncio.to_thread( save_payment, charge_id, @@ -141,9 +153,7 @@ async def handle_successful_payment(message: Message) -> None: db_path, ) await message.answer( - f"Оплата получена — подписка активирована на {days} дн.\n\n" - f"Списано {payment.total_amount} звёзд. Теперь доступны генерация постов, " - f"идей и голосовые. Команда /balance покажет статус." + f"Подписка активна\nЗакончится через {days_left} дн." ) diff --git a/tests/test_payments.py b/tests/test_payments.py index d15d115..2710305 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -46,15 +46,15 @@ class PaymentStoreTests(unittest.TestCase): class InvoiceTests(unittest.IsolatedAsyncioTestCase): async def test_invoice_uses_stars_and_correct_price(self): fake_bot = AsyncMock() - await payments.send_subscription_invoice(fake_bot, 555) + await payments.send_subscription_invoice(fake_bot, 555, days=30) kwargs = fake_bot.send_invoice.await_args.kwargs self.assertEqual(kwargs["currency"], "XTR") self.assertEqual(kwargs["provider_token"], "") self.assertEqual(kwargs["chat_id"], 555) - self.assertTrue(kwargs["payload"].startswith(payments.INVOICE_PREFIX)) - prices = kwargs["prices"] - self.assertIsInstance(prices[0], LabeledPrice) - self.assertEqual(prices[0].amount, user_store.SUBSCRIPTION_PRICE_STARS) + self.assertTrue(kwargs["payload"].startswith("sub:30:")) + self.assertEqual(kwargs["title"], "Подписка на 30 дней") + self.assertEqual(kwargs["description"], "Доступ к боту по генерации текста постов") + self.assertEqual(kwargs["prices"][0], LabeledPrice(label="30 дней", amount=450)) async def test_invoice_includes_thread_when_given(self): fake_bot = AsyncMock() @@ -68,7 +68,7 @@ class InvoiceTests(unittest.IsolatedAsyncioTestCase): class PreCheckoutTests(unittest.IsolatedAsyncioTestCase): - def _query(self, currency="XTR", amount=100, payload="sub30:1"): + def _query(self, currency="XTR", amount=450, payload="sub:30:1"): query = AsyncMock() query.currency = currency query.total_amount = amount @@ -76,7 +76,7 @@ class PreCheckoutTests(unittest.IsolatedAsyncioTestCase): return query async def test_accepts_valid_subscription_invoice(self): - query = self._query() + query = self._query(payload="sub:30:1") await payments.handle_pre_checkout(query) query.answer.assert_awaited_once_with(ok=True) @@ -121,7 +121,7 @@ class _StubMessage: return self -def _paid_message(charge_id="charge1", amount=100, currency="XTR", payload="sub30:1"): +def _paid_message(charge_id="charge1", amount=450, currency="XTR", payload="sub:30:1"): payment = SuccessfulPayment( currency=currency, total_amount=amount, @@ -152,7 +152,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): with patch.dict(os.environ, {"BOT_DB_PATH": self.db}): await payments.handle_successful_payment(message) self.assertEqual(len(message.answers), 1) - self.assertIn("активирована", message.answers[0]) + self.assertIn("Подписка активна", message.answers[0]) self.assertTrue(payments.payment_recorded("charge1", self.db)) row = user_store.subscription_row(42, self.db) self.assertIsNotNone(row["last_paid_at"]) @@ -172,7 +172,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): message = _paid_message(amount=5) with ( patch.object(payments, "save_payment", new=AsyncMock()) as save, - patch.object(payments, "activate_subscription") as activate, + patch.object(payments, "activate_subscription_days") as activate, ): await payments.handle_successful_payment(message) activate.assert_not_called() @@ -181,13 +181,13 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase): async def test_wrong_currency_is_rejected(self): message = _paid_message(currency="USD") - with patch.object(payments, "activate_subscription") as activate: + with patch.object(payments, "activate_subscription_days") as activate: await payments.handle_successful_payment(message) activate.assert_not_called() async def test_foreign_payload_is_rejected_without_activation(self): message = _paid_message(payload="other") - with patch.object(payments, "activate_subscription") as activate: + with patch.object(payments, "activate_subscription_days") as activate: await payments.handle_successful_payment(message) activate.assert_not_called() diff --git a/user_store.py b/user_store.py index e01bac0..c9028db 100644 --- a/user_store.py +++ b/user_store.py @@ -137,10 +137,15 @@ def init_db(db_path: str | None = None) -> None: ) -# Периоды: триал 3 дня, подписка 30 дней (100 звёзд) +# Единый бесплатный старт и разовые тарифы подписки. TRIAL_DAYS = 3 -SUBSCRIPTION_MONTH_DAYS = 30 -SUBSCRIPTION_PRICE_STARS = 100 +SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием. +SUBSCRIPTION_PRICE_STARS = 450 +SUBSCRIPTION_PLANS = { + 1: 15, + 10: 150, + 30: 450, +} def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int: @@ -185,31 +190,35 @@ def ensure_subscription(user_id: int, db_path: str | None = None) -> None: ) -def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int: - """Активирует/продлевает подписку на заданное число месяцев. Возвращает дни нового срока.""" +def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int: + """Добавляет срок к текущей подписке и возвращает оставшиеся дни.""" + if days <= 0: + raise ValueError("Subscription days must be positive") 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: + if not sub: raise RuntimeError("Subscription record not found") - new_expires = now + datetime.timedelta(days=SUBSCRIPTION_MONTH_DAYS * months) + now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) + current_days = _days_until(sub, now) + if current_days > 0: + current_expires = now + datetime.timedelta(days=current_days, seconds=1) + new_expires = current_expires + datetime.timedelta(days=days) + else: + new_expires = now + datetime.timedelta(days=days) connection.execute( """ UPDATE subscriptions - SET last_paid_at = ?, - is_active = 1, - monthly_days = ?, - updated_at = CURRENT_TIMESTAMP + 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), + (new_expires.isoformat(), days, user_id), ) - return (new_expires - now).days + return max(0, (new_expires - now).days) + + +def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int: + """Совместимый интерфейс старого dev-сценария: добавляет месячный срок.""" + return activate_subscription_days(user_id, SUBSCRIPTION_MONTH_DAYS * months, db_path) def deactivate_subscription(user_id: int, db_path: str | None = None) -> None: