feat: add per-user analytics and stats report
This commit is contained in:
1 parent
aa59e30776
commit
112c3247e0
8 files changed
+301
-88
No files matched your search
+1
-1
@@ -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"]
|
||||
+138
@@ -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"<b>{label}:</b> {value}"
|
||||
rows = ["<h2>📊 Статистика бота</h2>", f"<p>Период: {period}</p>", "<h3>Пользователи</h3>", line("Пользователей", metrics["users"]), line("Активных", metrics["active_users"]), "<h3>Генерация</h3>", 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 "нет"), "<h3>Обучение</h3>", line("Сообщений", metrics["training_messages"]), line("Типы", ", ".join(f"{k}: {v}" for k, v in training_types.items()) or "нет")]
|
||||
return "<p>" + "<br/>".join(rows) + "</p>"
|
||||
@@ -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"🧾 <b>Счёт на подписку</b>\n"
|
||||
f"Сумма: {SUBSCRIPTION_PRICE_STARS} ⭐ · Срок: 30 дней\n"
|
||||
f"Срок: {days} дней\n"
|
||||
f"ID пользователя: <code>{user_id}</code>",
|
||||
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())
|
||||
# Ставим меню один раз при старте, чтобы оно было у всех, кто писал боту.
|
||||
|
||||
@@ -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(
|
||||
|
||||
+30
-20
@@ -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} дн."
|
||||
)
|
||||
|
||||
|
||||
|
||||
+12
-12
@@ -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()
|
||||
|
||||
|
||||
+28
-19
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user