Compare commits
25
Commits
e9123274a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4321482a2 | ||
|
|
796a77d7f7 | ||
|
|
37d8f80236 | ||
|
|
3a6cc1067f | ||
|
|
b96bffda37 | ||
|
|
992d8645cf | ||
|
|
88230c92b5 | ||
|
|
57ccc3d70c | ||
|
|
a01195a1c0 | ||
|
|
a9e8c2e3ba | ||
|
|
6f52599342 | ||
|
|
261b2b2d02 | ||
|
|
9400050ade | ||
|
|
b1bc011306 | ||
|
|
87f22fb249 | ||
|
|
4ea1fc2737 | ||
|
|
140f2f7263 | ||
|
|
cdff37a183 | ||
|
|
376f26b2eb | ||
|
|
2b78351707 | ||
|
|
48a82bd0b3 | ||
|
|
741a27eb27 | ||
|
|
f1712d6db0 | ||
|
|
089c59bc68 | ||
|
|
de638bb4d0 |
No files matched your search
@@ -185,6 +185,8 @@ onboarding. В «Подписке» сразу отображаются тип
|
||||
|
||||
---
|
||||
|
||||
- **Статистика бота:** команда `/stats` доступна в топике «Статистика» группы «Мастер постинга: Админка» (`chat_id=-1008001427913`, `message_thread_id=5`). Отчёт отправляется Rich Message таблицами с колонками «Сегодня», «7 дней», «30 дней», «Всё время». События хранятся по Telegram user id в отдельной SQLite `/app/data/analytics.sqlite3` на persistent volume; текст/медиа пользователей не сохраняются. В режиме генерации учитываются генерации постов, идеи, правки и типы сообщений; в обучении — только типы сообщений. Групповой роутер ограничен аналитическим топиком; доступ к отчёту ограничивается `OWNER_USER_ID`, если переменная задана.
|
||||
|
||||
## Архитектура кода
|
||||
|
||||
```
|
||||
@@ -192,7 +194,8 @@ bot.py — роутинг, команды, обработка со
|
||||
payments.py — оплата Stars: sendInvoice, pre_checkout_query, successful_payment, защита от двойного списания
|
||||
apinex_client.py — клиент APInex (LLM, STT, vision), промпты, логирование вызовов
|
||||
user_store.py — SQLite: пользователи, ToV, посты, идеи, API-логи, подписки
|
||||
raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update для development
|
||||
analytics.py — отдельная SQLite-аналитика по Telegram user id; события активности и отчёты
|
||||
raw_updates.py — отдельная SQLite-БД сырых входящих Telegram Update (работает и в production)
|
||||
support.py — поддержка: личка ↔ персональный топик в группе операторов
|
||||
keyboards.py — клавиатуры главного меню и режимов; маппинг кнопок на команды
|
||||
help_message.py — Rich Message: `WELCOME_RICH_HTML` (онбординг) + `HELP_SECTIONS`/`help_view()` (справка с кнопками), plain fallback
|
||||
@@ -316,11 +319,13 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
|
||||
## Данные и хранилище
|
||||
|
||||
- **SQLite:** `/app/data/social_writer.sqlite3` (persistent Coolify volume).
|
||||
- SQLite `/app/data/social_writer.sqlite3` (persistent Coolify volume) — бизнес-данные пользователей, подписки и платежи.
|
||||
- SQLite `/app/data/analytics.sqlite3` (тот же persistent volume) — append-only события по `telegram_user_id`, режиму, действию и типу входящего сообщения; без пользовательского текста и медиа. Общий отчёт агрегируется запросами, отдельные дублирующие сводки не хранятся.
|
||||
- Команда `/stats` работает в форуме группы «Мастер постинга: Админка», топик `5`, и показывает таблицы за сегодня, последние 7 дней, последние 30 дней и всё время. Счётчики генерации: посты, идеи, правки, сообщения и типы сообщений; обучение — типы входящих сообщений.
|
||||
- **Постоянное хранилище dev:** volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` смонтирован в `/app/data` контейнера на сервере Coolify `spb-easy-dale` (`31.129.103.242`). Сервер приложения и сервер управления Coolify могут быть разными: сначала смотри назначенный приложению Server в записи Coolify, затем проверяй контейнер именно на нём. Не ищи контейнер только на хосте Coolify.
|
||||
- **Где смотреть ошибки:** подключись к `spb-easy-dale`, найди контейнер по имени `d32u5zcq2g4odmdpq9hw91yn-*` командой `docker ps`, затем смотри его stdout/stderr через `docker logs --since 24h <container>`. После деплоя ротационный файл приложения будет доступен как `/app/data/bot.log` (до 5 MiB и 3 архива); он сохраняется в указанном persistent volume при пересоздании контейнера. До деплоя изменения логирования ещё не действуют.
|
||||
- **API-журнал ошибки:** открой `/app/data/social_writer.sqlite3`, таблицу `api_call_logs`; записи связываются по `operation_id` и времени. Файл/БД находятся в volume на сервере приложения, не в локальном checkout и не на сервере Coolify, если это другой хост.
|
||||
- **Сырые Telegram Update (только dev):** отдельная БД `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; хранится JSON входящего Update, `update_id`, тип события и время получения. Поиск по `update_id`: `SELECT payload FROM raw_updates WHERE update_id = ?`. Записи старше 30 дней удаляются при старте dev-бота и при сохранении новых апдейтов. Чувствительные значения маскируются; Update могут содержать тексты и персональные данные пользователей. В production middleware не подключается и БД не создаётся.
|
||||
- **Сырые Telegram Update (все окружения, включая production):** отдельная БД `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; хранится JSON входящего Update, `update_id`, тип события и время получения. Поиск по `update_id`: `SELECT payload FROM raw_updates WHERE update_id = ?`. Записи старше 30 дней удаляются при старте бота и при сохранении новых апдейтов. Чувствительные значения маскируются; Update могут содержать тексты и персональные данные пользователей. Middleware подключается всегда: в production это единственный способ посмотреть события из групп администрирования и поддержки.
|
||||
- **Обращения в поддержку:** таблица `support_topics` в той же рабочей БД — `telegram_user_id` (PK), `thread_id` топика в группе операторов, `is_active`, время создания и обновления. Один пользователь — один топик; при завершении запись не удаляется, только снимается `is_active`, поэтому повторное обращение продолжается с сохранённой историей. Если топик удалили вручную в Telegram, при следующем сообщении он создаётся заново.
|
||||
- **Per-user данные:** профиль аккаунта/ToV, текстовые обучающие заметки, образцы постов со скриншотов, история сгенерированных постов и идеи. В «Обучении» текст/голос/скриншоты накапливают и обновляют профиль, а не запускают генерацию. История диалогов и исходные изображения **не сохраняются**.
|
||||
- **Пустые ответы APInex:** при пустом `content` бот делает до двух повторов; третий запрос добавляет `reasoning_effort: low`. Если ответ всё ещё пустой, операция завершается ошибкой и каждая попытка попадает в API-журнал. Ошибки HTTP автоматически не повторяются. APInex документирует `low`, но не режим отключения reasoning; поддержка параметра зависит от модели.
|
||||
@@ -332,6 +337,17 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
|
||||
---
|
||||
|
||||
## Статистика и аналитика
|
||||
|
||||
- `/stats` доступна в админском форуме, топик «Статистика» (`chat_id=-1008001427913`, `message_thread_id=5`). Отчёт уходит в этот же топик Rich Message-таблицами.
|
||||
- По умолчанию `/stats` показывает данные за всё время; отчёт содержит сопоставимые колонки «Сегодня», «7 дней», «30 дней», «Всё время».
|
||||
- Таблицы: активные пользователи; генерации постов, идеи, правки и сообщения; распределение типов сообщений в генерации; сообщения и типы сообщений в обучении.
|
||||
- Периоды рассчитываются как текущие 24 часа, последние 7×24 часа, последние 30×24 часа и весь срок хранения соответственно.
|
||||
- События хранятся в `/app/data/analytics.sqlite3`, отдельной SQLite на persistent volume. Запись привязана к `telegram_user_id`; общие значения агрегируются запросом без дублирующих таблиц.
|
||||
- Сохраняются только режим, тип действия, тип входящего сообщения и время. Содержимое сообщений, изображений и аудио аналитикой не сохраняется.
|
||||
- В генерации учитываются действие генерации поста, идеи, правки и входящие сообщения по типам. В обучении — только входящие сообщения по типам.
|
||||
- Вход в отчёт проверяет точные `chat_id` и `message_thread_id`; при заданном `OWNER_USER_ID` команда доступна только этому Telegram-пользователю.
|
||||
|
||||
## Команды
|
||||
|
||||
| Команда | Что делает | Режим |
|
||||
@@ -342,6 +358,7 @@ SUBSCRIPTION_PRICE_STARS = 100
|
||||
| `/post`, `/idea` | Самостоятельный пост по обученному профилю; идея | Генерация; для генерации нужна подписка |
|
||||
| `/balance`, `/subscribe` | Статус или оплата Stars | Подписка |
|
||||
| `/support` | Написать в поддержку | Любой |
|
||||
| `/stats` | Отчёт аналитики в админском топике | Топик «Статистика» |
|
||||
|
||||
В режиме «Генерация» русские фразы («придумай идею», «о чём написать?») работают как `/idea`.
|
||||
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
_operation_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("operation_id", default=None)
|
||||
APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt"
|
||||
APINEX_CHAT_URL = "https://api.apinex.bond/v1/chat/completions"
|
||||
USER_AGENT = "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129 Safari/537.36"
|
||||
|
||||
POSTS_SYSTEM_PROMPT = """ВСЕ служебные сообщения пользователю — на русском языке. Исключение — дословно приводимый технический текст ошибки. Сам текст публикации пиши на языке, который пользователь явно запросил; при отсутствии явного языкового требования используй язык исходного запроса.
|
||||
Ты — профессиональный редактор и автор текстов для социальных сетей. Помогай превращать тему, идею, черновик или факты в понятную и полезную публикацию, соответствующую цели аккаунта, аудитории и выбранной площадке.
|
||||
@@ -96,7 +96,7 @@ TONE_PROFILE_SYSTEM_PROMPT = """Составь или обнови единый
|
||||
"""
|
||||
|
||||
|
||||
def _setting(name: str, default: str = "gemini-3.8-flash") -> str:
|
||||
def _setting(name: str, default: str = "free/gemini-3.8-flash") -> str:
|
||||
return os.getenv(name, "").strip() or default
|
||||
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ async def help_navigation(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("dev_paid:"))
|
||||
async def confirm_development_payment(callback: CallbackQuery) -> None:
|
||||
async def confirm_development_payment(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
if not is_development_environment():
|
||||
await callback.answer()
|
||||
return
|
||||
@@ -436,11 +436,17 @@ async def confirm_development_payment(callback: CallbackQuery) -> None:
|
||||
await callback.answer("Dev-оплата подтверждена. Срок подписки не изменён.")
|
||||
return
|
||||
await asyncio.to_thread(ensure_subscription, user_id)
|
||||
days_left = await asyncio.to_thread(user_store.activate_subscription_days, user_id, days)
|
||||
await asyncio.to_thread(user_store.activate_subscription_days, user_id, days)
|
||||
sub = await asyncio.to_thread(subscription_row, user_id)
|
||||
await callback.answer("Dev-оплата подтверждена.")
|
||||
if isinstance(callback.message, Message):
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
await callback.message.edit_text(f"Подписка активна\nЗакончится через {days_left} дн.")
|
||||
await callback.message.edit_text(
|
||||
user_store.subscription_status_text(sub)
|
||||
)
|
||||
await callback.message.answer("Выбери действие.", reply_markup=main_menu_keyboard())
|
||||
await state.clear()
|
||||
await state.set_state(BotMode.lobby)
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@@ -470,11 +476,7 @@ async def _show_subscription_info(
|
||||
if not sub:
|
||||
text = "Подписка закончилась"
|
||||
else:
|
||||
active, value, unit = user_store.subscription_time_left(sub)
|
||||
text = (
|
||||
f"Подписка активна\nЗакончится через {value} {unit}"
|
||||
if active else "Подписка закончилась"
|
||||
)
|
||||
text = user_store.subscription_status_text(sub)
|
||||
|
||||
if mode == "subscription":
|
||||
markup = mode_keyboard("subscription", subscription_purchased=bool(sub and sub["last_paid_at"]))
|
||||
|
||||
+18
-5
@@ -15,14 +15,19 @@ import os
|
||||
from contextlib import closing
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment
|
||||
|
||||
from keyboards import main_menu_keyboard
|
||||
|
||||
from user_store import (
|
||||
SUBSCRIPTION_PLANS,
|
||||
activate_subscription_days,
|
||||
activate_subscription,
|
||||
connect,
|
||||
ensure_subscription,
|
||||
subscription_row,
|
||||
subscription_status_text,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -133,8 +138,16 @@ async def handle_pre_checkout(query: PreCheckoutQuery) -> None:
|
||||
await query.answer(ok=True)
|
||||
|
||||
|
||||
async def _return_to_main_menu(message: Message, state: FSMContext | None) -> None:
|
||||
"""После успешной оплаты/продления возвращает пользователя в главное меню."""
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
await state.set_state("BotMode:lobby")
|
||||
await message.answer("Оплата принята.", reply_markup=main_menu_keyboard())
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def handle_successful_payment(message: Message) -> None:
|
||||
async def handle_successful_payment(message: Message, state: FSMContext | None = None) -> None:
|
||||
payment = message.successful_payment
|
||||
if payment is None or message.from_user is None:
|
||||
return
|
||||
@@ -155,7 +168,8 @@ async def handle_successful_payment(message: Message) -> None:
|
||||
days = 1
|
||||
|
||||
await asyncio.to_thread(ensure_subscription, message.from_user.id)
|
||||
days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
|
||||
await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
|
||||
sub = await asyncio.to_thread(subscription_row, message.from_user.id, db_path)
|
||||
await asyncio.to_thread(
|
||||
save_payment,
|
||||
charge_id,
|
||||
@@ -165,9 +179,8 @@ async def handle_successful_payment(message: Message) -> None:
|
||||
payment.total_amount,
|
||||
db_path,
|
||||
)
|
||||
await message.answer(
|
||||
f"Подписка активна\nЗакончится через {days_left} дн."
|
||||
)
|
||||
await message.answer(subscription_status_text(sub))
|
||||
await _return_to_main_menu(message, state)
|
||||
|
||||
|
||||
async def _reject(message: Message, charge_id: str, reason: str) -> None:
|
||||
|
||||
@@ -250,6 +250,18 @@ class ModeRoutingTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn("Подписка активна", message.answers[0][0])
|
||||
self.assertIsNotNone(markup)
|
||||
|
||||
async def test_subscription_status_shows_clock_time_when_under_a_day(self):
|
||||
message = _StubMessage()
|
||||
sub = {"last_paid_at": "2026-01-01T00:00:00", "is_active": 1}
|
||||
with patch.object(
|
||||
bot.user_store, "subscription_time_left", return_value=(True, "31.10.2026 12:51 (МСК)", "")
|
||||
):
|
||||
await bot._show_subscription_info(cast(Any, message), cast(Any, sub))
|
||||
text = message.answers[0][0]
|
||||
self.assertIn("Подписка активна", text)
|
||||
self.assertIn("Закончится 31.10.2026 в 12:51 (МСК)", text)
|
||||
self.assertNotIn("ч.", text)
|
||||
|
||||
async def test_subscription_action_button_shows_inline_plan_choices(self):
|
||||
message = _StubMessage(keyboards.BUY_SUBSCRIPTION_BUTTON)
|
||||
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
|
||||
|
||||
+15
-7
@@ -20,6 +20,12 @@ class _StubState:
|
||||
async def get_state(self):
|
||||
return self.value
|
||||
|
||||
async def clear(self):
|
||||
self.value = None
|
||||
|
||||
async def set_state(self, state):
|
||||
self.value = getattr(state, "state", state)
|
||||
|
||||
|
||||
class PaymentStoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -166,8 +172,10 @@ class SuccessfulPaymentTests(unittest.IsolatedAsyncioTestCase):
|
||||
user_store.ensure_subscription(42, self.db)
|
||||
with patch.dict(os.environ, {"BOT_DB_PATH": self.db}):
|
||||
await payments.handle_successful_payment(message)
|
||||
self.assertEqual(len(message.answers), 1)
|
||||
self.assertEqual(len(message.answers), 2)
|
||||
self.assertIn("Подписка активна", message.answers[0])
|
||||
# Второе сообщение — возврат в главное меню с его клавиатурой.
|
||||
self.assertIsNotNone(message.answer_kwargs[1].get("reply_markup"))
|
||||
self.assertTrue(payments.payment_recorded("charge1", self.db))
|
||||
row = user_store.subscription_row(42, self.db)
|
||||
self.assertIsNotNone(row["last_paid_at"])
|
||||
@@ -257,7 +265,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot, "DEPLOY_ENV", "development"),
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
|
||||
):
|
||||
await bot.confirm_development_payment(callback)
|
||||
await bot.confirm_development_payment(callback, _StubState())
|
||||
to_thread.assert_not_awaited()
|
||||
callback.answer.assert_awaited_once_with("Dev-оплата подтверждена. Срок подписки не изменён.")
|
||||
|
||||
@@ -287,9 +295,9 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(bot, "DEPLOY_ENV", "development"),
|
||||
patch.dict(os.environ, {"BOT_DB_PATH": db}),
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=32)) as to_thread,
|
||||
patch.object(bot.asyncio, "to_thread", new=_direct_thread_mock()) as to_thread,
|
||||
):
|
||||
await bot.confirm_development_payment(callback)
|
||||
await bot.confirm_development_payment(callback, _StubState())
|
||||
db_row = user_store.subscription_row(42, db)
|
||||
patch_calls = [call for call in to_thread.await_args_list if call.args[0] is user_store.activate_subscription_days]
|
||||
self.assertEqual(patch_calls[0].args[1:], (42, 10))
|
||||
@@ -304,7 +312,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot, "DEPLOY_ENV", "development"),
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
|
||||
):
|
||||
await bot.confirm_development_payment(callback)
|
||||
await bot.confirm_development_payment(callback, _StubState())
|
||||
to_thread.assert_not_awaited()
|
||||
callback.answer.assert_awaited_once()
|
||||
|
||||
@@ -316,7 +324,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot, "DEPLOY_ENV", "production"),
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
|
||||
):
|
||||
await bot.confirm_development_payment(callback)
|
||||
await bot.confirm_development_payment(callback, _StubState())
|
||||
to_thread.assert_not_awaited()
|
||||
callback.answer.assert_awaited_once_with()
|
||||
|
||||
@@ -328,7 +336,7 @@ class SubscribeCommandTests(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(bot, "DEPLOY_ENV", "staging"),
|
||||
patch.object(bot.asyncio, "to_thread", new=AsyncMock()) as to_thread,
|
||||
):
|
||||
await bot.confirm_development_payment(callback)
|
||||
await bot.confirm_development_payment(callback, _StubState())
|
||||
to_thread.assert_not_awaited()
|
||||
callback.answer.assert_awaited_once_with()
|
||||
|
||||
|
||||
+51
-17
@@ -145,6 +145,8 @@ def init_db(db_path: str | None = None) -> None:
|
||||
TRIAL_DAYS = 3
|
||||
SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием.
|
||||
SUBSCRIPTION_PRICE_STARS = 450
|
||||
# Время окончания подписки показываем по Москве (MSK = фиксированный UTC+3).
|
||||
MSK_TIMEZONE = datetime.timezone(datetime.timedelta(hours=3))
|
||||
SUBSCRIPTION_PLANS = {
|
||||
0: 1,
|
||||
1: 15,
|
||||
@@ -183,7 +185,11 @@ def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int:
|
||||
return delta.days if delta.total_seconds() > 0 else -1
|
||||
|
||||
|
||||
def subscription_time_left(sub: sqlite3.Row, now: datetime.datetime | None = None) -> tuple[bool, int, str]:
|
||||
def subscription_time_left(
|
||||
sub: sqlite3.Row,
|
||||
now: datetime.datetime | None = None,
|
||||
display_tz: datetime.timezone | None = None,
|
||||
) -> tuple[bool, int | str, str]:
|
||||
expires = _subscription_expires_at(sub)
|
||||
if expires is None:
|
||||
return False, 0, ""
|
||||
@@ -193,10 +199,30 @@ def subscription_time_left(sub: sqlite3.Row, now: datetime.datetime | None = Non
|
||||
if seconds <= 0:
|
||||
return False, 0, ""
|
||||
if seconds >= 86400:
|
||||
return True, seconds // 86400, "дн."
|
||||
if seconds >= 3600:
|
||||
return True, seconds // 3600, "ч."
|
||||
return True, max(1, seconds // 60), "мин."
|
||||
# Округляем вверх: купленные N дней показываем как "N дн.", а не "N-1".
|
||||
return True, -(-seconds // 86400), "дн."
|
||||
# Меньше суток (последний день) — показываем конкретное время окончания вместо "N ч.".
|
||||
display_tz = display_tz or MSK_TIMEZONE
|
||||
stamp = (expires + datetime.timedelta(microseconds=1)).replace(tzinfo=datetime.timezone.utc)
|
||||
return True, stamp.astimezone(display_tz).strftime("%d.%m.%Y %H:%M") + " (МСК)", ""
|
||||
|
||||
|
||||
def subscription_status_text(
|
||||
sub: sqlite3.Row | None,
|
||||
now: datetime.datetime | None = None,
|
||||
display_tz: datetime.timezone | None = None,
|
||||
) -> str:
|
||||
"""Единый текст статуса подписки: дни (>= суток) или конкретное время (< суток)."""
|
||||
if not sub:
|
||||
return "Подписка закончилась"
|
||||
active, value, unit = subscription_time_left(sub, now=now, display_tz=display_tz)
|
||||
if not active:
|
||||
return "Подписка закончилась"
|
||||
if unit == "":
|
||||
# value = "31.10.2026 13:32 (МСК)" -> "Закончится 31.10.2026 в 13:32 (МСК)".
|
||||
date_part, _, rest = str(value).partition(" ")
|
||||
return f"Подписка активна\nЗакончится {date_part} в {rest}"
|
||||
return f"Подписка активна\nЗакончится через {value} {unit}"
|
||||
|
||||
def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None:
|
||||
with closing(connect(db_path)) as connection:
|
||||
@@ -214,7 +240,11 @@ def ensure_subscription(user_id: int, db_path: str | None = None) -> None:
|
||||
|
||||
|
||||
def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int:
|
||||
"""Добавляет срок к текущей подписке и возвращает оставшиеся дни."""
|
||||
"""Добавляет срок к подписке и возвращает оставшиеся дни (ceil).
|
||||
|
||||
Сложение ведётся по абсолютному UTC-моменту истечения, без округления дней:
|
||||
это исключает потерю дроби при каждой продлировке (было 10д + 1д -> 10д).
|
||||
"""
|
||||
if days <= 0:
|
||||
raise ValueError("Subscription days must be positive")
|
||||
with closing(connect(db_path)) as connection, connection:
|
||||
@@ -222,12 +252,9 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No
|
||||
if not sub:
|
||||
raise RuntimeError("Subscription record not found")
|
||||
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)
|
||||
current_expires = _subscription_expires_at(sub)
|
||||
base = max(now, current_expires) if current_expires is not None else now
|
||||
new_expires = base + datetime.timedelta(days=days)
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE subscriptions
|
||||
@@ -236,7 +263,8 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No
|
||||
""",
|
||||
(new_expires.isoformat(), days, user_id),
|
||||
)
|
||||
return max(0, (new_expires - now).days)
|
||||
remaining_seconds = int((new_expires - now).total_seconds())
|
||||
return -(-remaining_seconds // 86400) if remaining_seconds > 0 else 0
|
||||
|
||||
|
||||
def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int:
|
||||
@@ -253,13 +281,19 @@ def deactivate_subscription(user_id: int, db_path: str | None = None) -> None:
|
||||
|
||||
|
||||
def is_subscribed(user_id: int, db_path: str | None = None) -> bool:
|
||||
"""Правда ли подписка активна (срок не истёк)."""
|
||||
"""Правда ли подписка активна (срок не истёк).
|
||||
|
||||
Проверяем по абсолютному UTC-моменту истечения, а не по целым оставшимся
|
||||
дням: доступ действует до последней секунды срока, весь «последний день».
|
||||
"""
|
||||
sub = subscription_row(user_id, db_path)
|
||||
if not sub:
|
||||
if not sub or not sub["is_active"]:
|
||||
return False
|
||||
if not sub["is_active"]:
|
||||
expires = _subscription_expires_at(sub)
|
||||
if expires is None:
|
||||
return False
|
||||
return _days_until(sub) > 0
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
return expires > now
|
||||
|
||||
|
||||
def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]:
|
||||
|
||||
Reference in new issue
Block a user