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
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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"]
|
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 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 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
|
from payments import send_subscription_invoice
|
||||||
import support
|
import support
|
||||||
|
import analytics
|
||||||
from user_store import (
|
from user_store import (
|
||||||
add_post_samples,
|
add_post_samples,
|
||||||
clear_user_data,
|
clear_user_data,
|
||||||
@@ -88,6 +89,7 @@ DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production")
|
|||||||
_deploy_notice_lock = asyncio.Lock()
|
_deploy_notice_lock = asyncio.Lock()
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
admin_router = Router()
|
||||||
# Роль «работник»: основной сценарий работает только в личке бота.
|
# Роль «работник»: основной сценарий работает только в личке бота.
|
||||||
# Групповые чаты (поддержка и будущие роли) обслуживаются своими роутерами —
|
# Групповые чаты (поддержка и будущие роли) обслуживаются своими роутерами —
|
||||||
# иначе catch-all @router.message() перехватывает апдейты из групп и отвечает
|
# иначе catch-all @router.message() перехватывает апдейты из групп и отвечает
|
||||||
@@ -108,6 +110,9 @@ OFF_TOPIC_REPLIES = (
|
|||||||
"Я работаю с публикациями для соцсетей. Сформулируй запрос на пост или идею.",
|
"Я работаю с публикациями для соцсетей. Сформулируй запрос на пост или идею.",
|
||||||
)
|
)
|
||||||
INJECTION_BLOCK_REPLY = "Не могу выполнить инструкции, которые пытаются изменить правила работы бота. Могу помочь с постом или идеей для соцсетей."
|
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_GROUP_COLLECT_SECONDS = 0.8
|
||||||
_media_groups: dict[tuple[int, str], dict[str, object]] = {}
|
_media_groups: dict[tuple[int, str], dict[str, object]] = {}
|
||||||
_media_groups_lock = asyncio.Lock()
|
_media_groups_lock = asyncio.Lock()
|
||||||
@@ -458,28 +463,14 @@ async def _show_subscription_info(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
days_left = user_store._days_until(sub)
|
days_left = user_store._days_until(sub)
|
||||||
if sub["last_paid_at"]:
|
text = (
|
||||||
if days_left >= 0:
|
f"Подписка активна\nЗакончится через {days_left} дн."
|
||||||
text = f"Активирована платная подписка. Осталось {days_left} дн."
|
if days_left > 0 else "Подписка закончилась"
|
||||||
else:
|
)
|
||||||
text = (
|
|
||||||
f"Срок платной подписки истёк. Продление — "
|
|
||||||
f"{SUBSCRIPTION_PRICE_STARS} звёзд за 30 дней."
|
|
||||||
)
|
|
||||||
elif days_left >= 0:
|
|
||||||
text = f"Активирован бесплатный пробный период. Осталось {days_left} дн."
|
|
||||||
else:
|
|
||||||
text = (
|
|
||||||
f"Бесплатный пробный период истёк. Подписка — "
|
|
||||||
f"{SUBSCRIPTION_PRICE_STARS} звёзд за 30 дней."
|
|
||||||
)
|
|
||||||
|
|
||||||
if mode == "subscription":
|
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(
|
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")
|
) if sub else mode_keyboard("subscription")
|
||||||
else:
|
else:
|
||||||
markup = mode_keyboard(mode)
|
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)
|
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)
|
await asyncio.to_thread(ensure_subscription, user_id)
|
||||||
try:
|
try:
|
||||||
await send_subscription_invoice(
|
if days == 30:
|
||||||
message.bot, message.chat.id, message.message_thread_id
|
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:
|
except Exception as exc:
|
||||||
logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
logger.error("Invoice send failed (%s): %s", type(exc).__name__, str(exc)[:180])
|
||||||
if is_development_environment():
|
if is_development_environment():
|
||||||
@@ -523,7 +517,7 @@ async def _send_subscription_invoice(message: Message, user_id: int) -> None:
|
|||||||
return
|
return
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"🧾 <b>Счёт на подписку</b>\n"
|
f"🧾 <b>Счёт на подписку</b>\n"
|
||||||
f"Сумма: {SUBSCRIPTION_PRICE_STARS} ⭐ · Срок: 30 дней\n"
|
f"Срок: {days} дней\n"
|
||||||
f"ID пользователя: <code>{user_id}</code>",
|
f"ID пользователя: <code>{user_id}</code>",
|
||||||
parse_mode="HTML",
|
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")
|
@router.callback_query(F.data.startswith("subscription:plan:"))
|
||||||
async def subscription_renew(callback: CallbackQuery, state: FSMContext) -> None:
|
async def subscription_plan(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
if await current_mode(state) != "subscription":
|
if await current_mode(state) != "subscription" or not isinstance(callback.message, Message):
|
||||||
await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True)
|
await callback.answer("Открой раздел «Подписка» из главного меню.", show_alert=True)
|
||||||
return
|
return
|
||||||
message = callback.message
|
try:
|
||||||
if not isinstance(message, Message) or callback.from_user is None:
|
days = int((callback.data or "").rsplit(":", 1)[1])
|
||||||
await callback.answer("Не удалось открыть оплату. Попробуй ещё раз.", show_alert=True)
|
except ValueError:
|
||||||
|
await callback.answer("Некорректный срок.", show_alert=True)
|
||||||
|
return
|
||||||
|
if days not in user_store.SUBSCRIPTION_PLANS:
|
||||||
|
await callback.answer("Такой тариф недоступен.", show_alert=True)
|
||||||
return
|
return
|
||||||
await callback.answer()
|
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:
|
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",
|
parse_mode="HTML",
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(save_idea, user_id, idea)
|
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:
|
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)
|
await message.answer(post)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
await asyncio.to_thread(save_generated_posts, user_id, posts, origin)
|
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
|
@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:
|
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:
|
if (await notify_development_deploy(bot, state, message.from_user.id if message.from_user else None)) is True:
|
||||||
return
|
return
|
||||||
with diagnostic_operation():
|
with diagnostic_operation():
|
||||||
@@ -1339,7 +1351,16 @@ async def _process_voice(message: Message, bot: Bot, state: FSMContext, status=N
|
|||||||
await status.finish("⚠️ Не удалось распознать речь")
|
await status.finish("⚠️ Не удалось распознать речь")
|
||||||
await message.answer("Не удалось распознать речь в аудио.")
|
await message.answer("Не удалось распознать речь в аудио.")
|
||||||
return
|
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 message.from_user is None:
|
||||||
if status:
|
if status:
|
||||||
await status.finish("❌ Не удалось определить пользователя")
|
await status.finish("❌ Не удалось определить пользователя")
|
||||||
@@ -1414,6 +1435,8 @@ async def handle_command_text(message: Message, state: FSMContext) -> None:
|
|||||||
await subscribe_command(message, state)
|
await subscribe_command(message, state)
|
||||||
elif command == "/support":
|
elif command == "/support":
|
||||||
await open_support(message, state)
|
await open_support(message, state)
|
||||||
|
elif command == "/stats":
|
||||||
|
await stats_command(message, state)
|
||||||
else:
|
else:
|
||||||
await message.answer("Неизвестная команда. Открой /help, чтобы посмотреть доступные команды.")
|
await message.answer("Неизвестная команда. Открой /help, чтобы посмотреть доступные команды.")
|
||||||
|
|
||||||
@@ -1436,7 +1459,7 @@ async def handle_text(message: Message, state: FSMContext) -> None:
|
|||||||
return
|
return
|
||||||
if button == SUBSCRIPTION_ACTION_VALUE:
|
if button == SUBSCRIPTION_ACTION_VALUE:
|
||||||
if await require_mode(message, state, "subscription") and message.from_user is not None:
|
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
|
return
|
||||||
if button == TRAINING_RESET_VALUE:
|
if button == TRAINING_RESET_VALUE:
|
||||||
await clear_command(message, state)
|
await clear_command(message, state)
|
||||||
@@ -1446,6 +1469,8 @@ async def handle_text(message: Message, state: FSMContext) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
mode = await current_mode(state)
|
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")
|
pending_image = (await state.get_data()).get("pending_image_request")
|
||||||
if mode == "generation" and pending_image:
|
if mode == "generation" and pending_image:
|
||||||
await state.update_data(pending_image_request=None)
|
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))
|
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:
|
async def set_main_menu(bot: Bot) -> None:
|
||||||
"""Публикует команды переключения режимов и действия текущих режимов."""
|
"""Публикует команды переключения режимов и действия текущих режимов."""
|
||||||
try:
|
try:
|
||||||
@@ -1508,6 +1553,7 @@ async def set_main_menu(bot: Bot) -> None:
|
|||||||
BotCommand(command="balance", description="Статус подписки"),
|
BotCommand(command="balance", description="Статус подписки"),
|
||||||
BotCommand(command="subscribe", description="Оплатить подписку"),
|
BotCommand(command="subscribe", description="Оплатить подписку"),
|
||||||
BotCommand(command="support", description="Написать в поддержку"),
|
BotCommand(command="support", description="Написать в поддержку"),
|
||||||
|
BotCommand(command="stats", description="Статистика бота"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1516,6 +1562,7 @@ async def set_main_menu(bot: Bot) -> None:
|
|||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
init_db()
|
init_db()
|
||||||
|
analytics.init_db()
|
||||||
payments.init_payments_table()
|
payments.init_payments_table()
|
||||||
await asyncio.to_thread(support.init_support_table)
|
await asyncio.to_thread(support.init_support_table)
|
||||||
if is_development_environment():
|
if is_development_environment():
|
||||||
@@ -1534,6 +1581,7 @@ async def main() -> None:
|
|||||||
# Роли: групповые сценарии регистрируются ДО основного роутера, иначе
|
# Роли: групповые сценарии регистрируются ДО основного роутера, иначе
|
||||||
# catch-all @router.message() основного роутера перехватывает их апдейты.
|
# catch-all @router.message() основного роутера перехватывает их апдейты.
|
||||||
dp.include_router(support.router)
|
dp.include_router(support.router)
|
||||||
|
dp.include_router(admin_router)
|
||||||
dp.include_router(router)
|
dp.include_router(router)
|
||||||
dp.message.outer_middleware(support.SupportRelayMiddleware())
|
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:
|
def help_inline_keyboard() -> InlineKeyboardMarkup:
|
||||||
"""Стандартная inline-клавиатура для навигации по разделам справки."""
|
"""Стандартная inline-клавиатура для навигации по разделам справки."""
|
||||||
return InlineKeyboardMarkup(
|
return InlineKeyboardMarkup(
|
||||||
|
|||||||
+30
-20
@@ -18,7 +18,8 @@ from aiogram import F, Router
|
|||||||
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
|
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
|
||||||
|
|
||||||
from user_store import (
|
from user_store import (
|
||||||
SUBSCRIPTION_PRICE_STARS,
|
SUBSCRIPTION_PLANS,
|
||||||
|
activate_subscription_days,
|
||||||
activate_subscription,
|
activate_subscription,
|
||||||
connect,
|
connect,
|
||||||
ensure_subscription,
|
ensure_subscription,
|
||||||
@@ -28,7 +29,18 @@ logger = logging.getLogger(__name__)
|
|||||||
router = Router(name="payments")
|
router = Router(name="payments")
|
||||||
|
|
||||||
STARS_CURRENCY = "XTR"
|
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:
|
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:
|
async def send_subscription_invoice(
|
||||||
"""Отправляет инвойс на 30 дней подписки за Stars."""
|
bot, chat_id: int, thread_id: int | None = None, days: int = 30
|
||||||
payload = f"{INVOICE_PREFIX}{datetime.datetime.now(datetime.timezone.utc):%Y%m%d%H%M%S}"
|
) -> 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] = {
|
kwargs: dict[str, object] = {
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"title": "Подписка на 30 дней",
|
"title": f"Подписка на {days} дней",
|
||||||
"description": "Доступ к генерации постов, идей и голосовым на 30 дней.",
|
"description": "Доступ к боту по генерации текста постов",
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
"currency": STARS_CURRENCY,
|
"currency": STARS_CURRENCY,
|
||||||
"prices": [LabeledPrice(label="30 дней", amount=SUBSCRIPTION_PRICE_STARS)],
|
"prices": [LabeledPrice(label=f"{days} дней", amount=price)],
|
||||||
"provider_token": "",
|
"provider_token": "",
|
||||||
}
|
}
|
||||||
if thread_id is not None:
|
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()
|
@router.pre_checkout_query()
|
||||||
async def handle_pre_checkout(query: PreCheckoutQuery) -> None:
|
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="Некорректная сумма или валюта платежа.")
|
await query.answer(ok=False, error_message="Некорректная сумма или валюта платежа.")
|
||||||
return
|
return
|
||||||
if not query.invoice_payload.startswith(INVOICE_PREFIX):
|
|
||||||
await query.answer(ok=False, error_message="Этот счёт не принадлежит подписке.")
|
|
||||||
return
|
|
||||||
await query.answer(ok=True)
|
await query.answer(ok=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,12 +130,11 @@ async def handle_successful_payment(message: Message) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
charge_id = payment.telegram_payment_charge_id
|
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, "неверная сумма или валюта")
|
await _reject(message, charge_id, "неверная сумма или валюта")
|
||||||
return
|
return
|
||||||
if not payment.invoice_payload.startswith(INVOICE_PREFIX):
|
days, _price = plan
|
||||||
await _reject(message, charge_id, "неизвестный invoice_payload")
|
|
||||||
return
|
|
||||||
|
|
||||||
db_path = _db_path()
|
db_path = _db_path()
|
||||||
if await asyncio.to_thread(payment_recorded, charge_id, 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
|
return
|
||||||
|
|
||||||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
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(
|
await asyncio.to_thread(
|
||||||
save_payment,
|
save_payment,
|
||||||
charge_id,
|
charge_id,
|
||||||
@@ -141,9 +153,7 @@ async def handle_successful_payment(message: Message) -> None:
|
|||||||
db_path,
|
db_path,
|
||||||
)
|
)
|
||||||
await message.answer(
|
await message.answer(
|
||||||
f"Оплата получена — подписка активирована на {days} дн.\n\n"
|
f"Подписка активна\nЗакончится через {days_left} дн."
|
||||||
f"Списано {payment.total_amount} звёзд. Теперь доступны генерация постов, "
|
|
||||||
f"идей и голосовые. Команда /balance покажет статус."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -46,15 +46,15 @@ class PaymentStoreTests(unittest.TestCase):
|
|||||||
class InvoiceTests(unittest.IsolatedAsyncioTestCase):
|
class InvoiceTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_invoice_uses_stars_and_correct_price(self):
|
async def test_invoice_uses_stars_and_correct_price(self):
|
||||||
fake_bot = AsyncMock()
|
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
|
kwargs = fake_bot.send_invoice.await_args.kwargs
|
||||||
self.assertEqual(kwargs["currency"], "XTR")
|
self.assertEqual(kwargs["currency"], "XTR")
|
||||||
self.assertEqual(kwargs["provider_token"], "")
|
self.assertEqual(kwargs["provider_token"], "")
|
||||||
self.assertEqual(kwargs["chat_id"], 555)
|
self.assertEqual(kwargs["chat_id"], 555)
|
||||||
self.assertTrue(kwargs["payload"].startswith(payments.INVOICE_PREFIX))
|
self.assertTrue(kwargs["payload"].startswith("sub:30:"))
|
||||||
prices = kwargs["prices"]
|
self.assertEqual(kwargs["title"], "Подписка на 30 дней")
|
||||||
self.assertIsInstance(prices[0], LabeledPrice)
|
self.assertEqual(kwargs["description"], "Доступ к боту по генерации текста постов")
|
||||||
self.assertEqual(prices[0].amount, user_store.SUBSCRIPTION_PRICE_STARS)
|
self.assertEqual(kwargs["prices"][0], LabeledPrice(label="30 дней", amount=450))
|
||||||
|
|
||||||
async def test_invoice_includes_thread_when_given(self):
|
async def test_invoice_includes_thread_when_given(self):
|
||||||
fake_bot = AsyncMock()
|
fake_bot = AsyncMock()
|
||||||
@@ -68,7 +68,7 @@ class InvoiceTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class PreCheckoutTests(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 = AsyncMock()
|
||||||
query.currency = currency
|
query.currency = currency
|
||||||
query.total_amount = amount
|
query.total_amount = amount
|
||||||
@@ -76,7 +76,7 @@ class PreCheckoutTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
return query
|
return query
|
||||||
|
|
||||||
async def test_accepts_valid_subscription_invoice(self):
|
async def test_accepts_valid_subscription_invoice(self):
|
||||||
query = self._query()
|
query = self._query(payload="sub:30:1")
|
||||||
await payments.handle_pre_checkout(query)
|
await payments.handle_pre_checkout(query)
|
||||||
query.answer.assert_awaited_once_with(ok=True)
|
query.answer.assert_awaited_once_with(ok=True)
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ class _StubMessage:
|
|||||||
return self
|
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(
|
payment = SuccessfulPayment(
|
||||||
currency=currency,
|
currency=currency,
|
||||||
total_amount=amount,
|
total_amount=amount,
|
||||||
@@ -152,7 +152,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
with patch.dict(os.environ, {"BOT_DB_PATH": self.db}):
|
with patch.dict(os.environ, {"BOT_DB_PATH": self.db}):
|
||||||
await payments.handle_successful_payment(message)
|
await payments.handle_successful_payment(message)
|
||||||
self.assertEqual(len(message.answers), 1)
|
self.assertEqual(len(message.answers), 1)
|
||||||
self.assertIn("активирована", message.answers[0])
|
self.assertIn("Подписка активна", message.answers[0])
|
||||||
self.assertTrue(payments.payment_recorded("charge1", self.db))
|
self.assertTrue(payments.payment_recorded("charge1", self.db))
|
||||||
row = user_store.subscription_row(42, self.db)
|
row = user_store.subscription_row(42, self.db)
|
||||||
self.assertIsNotNone(row["last_paid_at"])
|
self.assertIsNotNone(row["last_paid_at"])
|
||||||
@@ -172,7 +172,7 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
message = _paid_message(amount=5)
|
message = _paid_message(amount=5)
|
||||||
with (
|
with (
|
||||||
patch.object(payments, "save_payment", new=AsyncMock()) as save,
|
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)
|
await payments.handle_successful_payment(message)
|
||||||
activate.assert_not_called()
|
activate.assert_not_called()
|
||||||
@@ -181,13 +181,13 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
async def test_wrong_currency_is_rejected(self):
|
async def test_wrong_currency_is_rejected(self):
|
||||||
message = _paid_message(currency="USD")
|
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)
|
await payments.handle_successful_payment(message)
|
||||||
activate.assert_not_called()
|
activate.assert_not_called()
|
||||||
|
|
||||||
async def test_foreign_payload_is_rejected_without_activation(self):
|
async def test_foreign_payload_is_rejected_without_activation(self):
|
||||||
message = _paid_message(payload="other")
|
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)
|
await payments.handle_successful_payment(message)
|
||||||
activate.assert_not_called()
|
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
|
TRIAL_DAYS = 3
|
||||||
SUBSCRIPTION_MONTH_DAYS = 30
|
SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием.
|
||||||
SUBSCRIPTION_PRICE_STARS = 100
|
SUBSCRIPTION_PRICE_STARS = 450
|
||||||
|
SUBSCRIPTION_PLANS = {
|
||||||
|
1: 15,
|
||||||
|
10: 150,
|
||||||
|
30: 450,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int:
|
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:
|
with closing(connect(db_path)) as connection, connection:
|
||||||
sub = subscription_row(user_id, db_path)
|
sub = subscription_row(user_id, db_path)
|
||||||
if sub and sub["last_paid_at"]:
|
if not sub:
|
||||||
# Продление: отсчитываем от момента оплаты
|
|
||||||
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")
|
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(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE subscriptions
|
UPDATE subscriptions
|
||||||
SET last_paid_at = ?,
|
SET last_paid_at = ?, is_active = 1, monthly_days = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
is_active = 1,
|
|
||||||
monthly_days = ?,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE telegram_user_id = ?
|
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:
|
def deactivate_subscription(user_id: int, db_path: str | None = None) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user