Compare commits

..
6 Commits
9 changed files with 45 additions and 117 deletions

No files matched your search

+5 -8
View File
@@ -35,9 +35,8 @@ Telegram-бот, который по запросу пользователя г
## Технические заметки
- Формат: Telegram-бот.
- Стек: Python 3.12, aiogram 3.x, aiohttp, SQLite.
- Аналитика: отдельная SQLite `/app/data/analytics.sqlite3`, события на пользователя; команда `/stats` в админском топике выводит таблицы по периодам «Сегодня», «7 дней», «30 дней», «Всё время».
- APInex Voice STT расшифровывает голосовые; Gemini 3.8 Flash через APInex классифицирует намерения, пишет/правит посты и извлекает ToV из скриншотов.
- Coolify env с отдельными модельными настройками: `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL`, `APINEX_TRAINING_SUMMARY_MODEL`; общий секрет `APINEX_API_KEY`. При невалидном JSON допускается одна попытка исправления форматирования до валидации схемы. Обрезанный на середине строки ответ не требует доотправки: `_extract_complete_post_strings` достаёт целые строки и возвращает три полных варианта. Лимит `max_tokens` у вызовов LLM снят.
- Coolify env с отдельными модельными настройками: `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL`; общий секрет `APINEX_API_KEY`. При невалидном JSON допускается одна попытка исправления форматирования до валидации схемы. Обрезанный на середине строки ответ не требует доотправки: `_extract_complete_post_strings` достаёт целые строки и возвращает три полных варианта. Лимит `max_tokens` у вызовов LLM снят.
- В режиме «Обучение» текстовые заметки, голосовые/аудио и примеры постов дополняют единый накопительный профиль аккаунта; сброс обучения удаляет и заметки, и образцы. Флаг `users.is_trained` включает самостоятельную генерацию поста.
- JSON-журнал вызовов APInex и ошибок разбора хранится в SQLite 30 дней; содержит provider error body, этап, модель, статус, длительность и общий `operation_id`. Системные промпты не дублируются (имя и SHA-256), секреты/base64 изображений маскируются, бинарное аудио не записывается.
- Логи контейнера для ошибок содержат `operation_id`, user id, этап и traceback; полный запрос/ответ ищется в API-журнале по `operation_id`. Текст пользовательских запросов и ответов — приватные данные.
@@ -66,14 +65,14 @@ Telegram-бот, который по запросу пользователя г
- Gitea: `admin/social-post-writer-bot`, ветка `dev`.
- Coolify проект: `Social Post Writer Bot`, окружение `development`.
- Coolify приложение: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`) на `spb-easy-dale`.
- SQLite: `/app/data/social_writer.sqlite3`; analytics: `/app/data/analytics.sqlite3` на том же persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`.
- SQLite: `/app/data/social_writer.sqlite3`; persistent volume `d32u5zcq2g4odmdpq9hw91yn-social-post-writer-dev-data` монтируется в `/app/data`.
- Важно при поиске логов: приложение запущено на сервере `spb-easy-dale` (`31.129.103.242`), который может отличаться от хоста управления Coolify. Ориентируйся на поле Server у приложения; `docker ps` и `docker logs --since 24h <container>` выполняй на сервере приложения, имя контейнера начинается с `d32u5zcq2g4odmdpq9hw91yn-`. Не делай вывод об отсутствии контейнера по проверке только хоста Coolify.
- После деплоя файл приложения `/app/data/bot.log` и его ротационные копии хранятся на persistent volume выше и переживают пересоздание контейнера. Пока logging-изменение не задеплоено, используй `docker logs` и API-журнал `api_call_logs` в SQLite. API-логи сохраняются в `/app/data/social_writer.sqlite3` на том же volume.
- Raw update log (все окружения, включая production): отдельная SQLite `/app/data/bot_updates.sqlite3`, таблица `raw_updates`; поиск по Telegram `update_id` (`SELECT payload FROM raw_updates WHERE update_id = ?`). Middleware подключается всегда — в проде это единственный способ увидеть события из групп администрирования и поддержки; записи старше 30 дней удаляются при запуске и записи новых событий. Секретные поля/токены маскируются. JSON включает приватный текст и metadata апдейта.
- Секреты хранятся в переменных окружения приложения Coolify; не сохраняются в репозитории.
- Админская группа «Писатель постов: Админка»: `chat_id = -1004459624166` (supergroup, forum). Топики: `4` — Статистика, `5` — Расходы, `6` — Рассылка, `7` — Ошибки. В production эти значения заданы env (`ADMIN_GROUP_ID`, `ANALYTICS_CHAT_ID`, `ANALYTICS_TOPIC_ID`, `TOPIC_STATS_ID`, `TOPIC_EXPENSES_ID`, `TOPIC_BROADCAST_ID`, `TOPIC_ERRORS_ID`); в коде оставлены дефолты для dev. Реализован только топик «Статистика» (`/stats`), остальные зарезервированы.
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1003957663233`, «Писатель постов: Поддержка», forum; production), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_OPERATOR_SIGNATURE` (по умолчанию `Оператор поддержки`), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. **Требование к правам:** бот в этой группе должен быть админом с включённым «Manage Topics» (`can_manage_topics=true`) — иначе создание топика падает с `not enough rights to create a topic`, а пользователь видит «Поддержка временно недоступна». Проверка: `get_chat_member(SUPPORT_GROUP_ID, bot_id)`.
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+67`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`).
- Поддержка: `SUPPORT_GROUP_ID` — id группы операторов (`-1003957663233`, «Писатель постов: Поддержка», forum; production), `SUPPORT_NOTE_PREFIX` (по умолчанию `//`), `SUPPORT_GREETINGS` (пул приветствий, разделитель `|`), `SUPPORT_OPERATOR_SIGNATURE` (по умолчанию «Оператор поддержки»), `SUPPORT_TOPIC_EMOJI_ID` (опционально). Без `SUPPORT_GROUP_ID` поддержка выключена. **Требование к правам:** бот в этой группе должен быть админом с включённым «Manage Topics» (`can_manage_topics=true`) — иначе создание топика падает с `not enough rights to create a topic`, а пользователь видит «Поддержка временно недоступна». Проверка: `get_chat_member(SUPPORT_GROUP_ID, bot_id)`.
- `DEPLOY_ENV` включает dev-поведение только при точном значении `development`; production, пустое и неизвестное значение отключают его. `BOT_ENV` отдельно выбирает Telegram Bot API. `DEV_CHAT_ID` используется для уведомления о версии; версия в файле `VERSION` (сейчас `0.1.0+78`). Ротационный файл лога — `BOT_LOG_PATH` (по умолчанию `/app/data/bot.log`) на том же volume; сырые апдейты — `RAW_UPDATES_DB_PATH` (по умолчанию `/app/data/bot_updates.sqlite3`).
- Вики Gitea: страницы `Home`, `User-Guide`, `Developer-Guide`, `Subscription`, `Deployment`.
## Статус
@@ -82,7 +81,6 @@ Telegram-бот, который по запросу пользователя г
- [x] Классификатор намерений и Tone of Voice из скриншота
- [x] SQLite-настройки пользователей и reply-правки без хранения диалога
- [x] Development-развёртывание в Coolify: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`)
- [x] Аналитика пользователей: отдельная SQLite, отчёт `/stats` с таблицами и периодами
- [x] Подключён persistent Coolify volume `/app/data` для SQLite
- [x] Проверены на моках STT, LLM, vision, ToV, reply-поля и изоляция пользователей
- [x] Выполнен live smoke test: классификатор вернул `write_post`, генератор — 3 разных варианта; vision распознал тестовый скриншот
@@ -107,8 +105,7 @@ Telegram-бот, который по запросу пользователя г
- [x] Исправлен баг: `_send_idea` была потеряна в коммите про подписку — `/idea` падал с `NameError`
- [x] Реальная оплата Stars: `sendInvoice` (XTR) → `pre_checkout_query` → `successful_payment`; подписка активируется только после оплаты, `telegram_payment_charge_id` защищает от двойного начисления
- [x] В `DEPLOY_ENV=development` после обычного сообщения об инвойсе отдельным сообщением доступна кнопка «🧪 Оплатил (dev)»; callback ограничен пользователем инвойса, а все dev-ветви fail-closed выключены вне development
- [x] Статистика активности по пользователям хранится в отдельной SQLite `/app/data/analytics.sqlite3`; `/stats` в админском топике показывает таблицы с колонками за сегодня, 7 дней, 30 дней и всё время. Генерация считает посты, идеи, правки и типы сообщений; обучение — только типы сообщений.
- Версия разработки: `0.1.0+67`
- Версия разработки: `0.1.0+64`
- [x] Статус долгой операции стримится живым драфтом `sendMessageDraft` в приватном чате и каждые 3–8 сек меняет случайную фразу из пула операции (`activity_phrases.py`); в группах и топиках работает прежний `edit_text`
- [x] Справка `/help` отправляется как Rich Message с обычной inline-клавиатурой; каждый callback `help:<section>` открывает раздел, кнопки доступны на всех страницах.
- [x] Проект Gitea «Social Post Writer Bot Roadmap» с задачами (выполненные — в «Готово», будущие — в Backlog)
+1 -1
View File
@@ -371,7 +371,7 @@ SUBSCRIPTION_PRICE_STARS = 100
- **Деплой:** `bash /root/.hermes/scripts/gitea_deploy.sh dev` (POST `/api/v1/deploy?uuid=...&force=false`).
- **Окружение:** `DEPLOY_ENV` включает dev-функции только при точном значении `development`; production, пустое и неизвестное значение отключают их. `BOT_ENV` отдельно выбирает Telegram Bot API и не используется для включения dev-поведения.
- **VERSION:** файл `VERSION` (`0.1.0+64`). При `DEPLOY_ENV=development` и изменении версии бот шлёт уведомление о версии в `DEV_CHAT_ID`.
- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_TRAINING_SUMMARY_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `ANALYTICS_DB_PATH` (optional; default `/app/data/analytics.sqlite3`), `ANALYTICS_CHAT_ID` (default `-1008001427913`), `ANALYTICS_TOPIC_ID` (default `5`), `OWNER_USER_ID` (optional; when set, `/stats` is restricted to this Telegram user), `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации),
- **Переменные Coolify:** `BOT_TOKEN`, `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_INJECTION_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_RESPONSE_REPAIR_MODEL` (optional; default uses the standard model), `BOT_DB_PATH`, `ANALYTICS_DB_PATH` (optional; default `/app/data/analytics.sqlite3`), `ANALYTICS_CHAT_ID` (default `-1008001427913`), `ANALYTICS_TOPIC_ID` (default `5`), `OWNER_USER_ID` (optional; when set, `/stats` is restricted to this Telegram user), `RAW_UPDATES_DB_PATH` (optional; default `/app/data/bot_updates.sqlite3`), `DEPLOY_ENV`, `DEV_CHAT_ID`, `BOT_LOG_PATH` (optional; default `/app/data/bot.log`), `ACTIVITY_MIN_SECONDS` / `ACTIVITY_MAX_SECONDS` (optional; default 3 / 8 — границы паузы между сменами фраз в драфте), `ACTIVITY_MAX_ROTATIONS` (optional; default 12 — предохранитель от бесконечной ротации),
`ADMIN_GROUP_ID` (id админской супергруппы с темами; production: `-1004459624166` — «Писатель постов: Админка»),
`SUPPORT_GROUP_ID` (id супергруппы с темами для обращений; production: `-1003957663233` — «Писатель постов: Поддержка»; без неё поддержка выключена),
`SUPPORT_NOTE_PREFIX` (optional; default `//` — префикс заметки, которая не уходит пользователю),
+1 -1
View File
@@ -1 +1 @@
0.1.0+84
0.1.0+78
+1 -1
View File
@@ -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 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129 Safari/537.36"
USER_AGENT = "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"
POSTS_SYSTEM_PROMPT = """ВСЕ служебные сообщения пользователю — на русском языке. Исключение — дословно приводимый технический текст ошибки. Сам текст публикации пиши на языке, который пользователь явно запросил; при отсутствии явного языкового требования используй язык исходного запроса.
Ты — профессиональный редактор и автор текстов для социальных сетей. Помогай превращать тему, идею, черновик или факты в понятную и полезную публикацию, соответствующую цели аккаунта, аудитории и выбранной площадке.
+8 -10
View File
@@ -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, state: FSMContext) -> None:
async def confirm_development_payment(callback: CallbackQuery) -> None:
if not is_development_environment():
await callback.answer()
return
@@ -436,17 +436,11 @@ async def confirm_development_payment(callback: CallbackQuery, state: FSMContext
await callback.answer("Dev-оплата подтверждена. Срок подписки не изменён.")
return
await asyncio.to_thread(ensure_subscription, user_id)
await asyncio.to_thread(user_store.activate_subscription_days, user_id, days)
sub = await asyncio.to_thread(subscription_row, user_id)
days_left = await asyncio.to_thread(user_store.activate_subscription_days, user_id, days)
await callback.answer("Dev-оплата подтверждена.")
if isinstance(callback.message, Message):
await callback.message.edit_reply_markup(reply_markup=None)
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)
await callback.message.edit_text(f"Подписка активна\nЗакончится через {days_left} дн.")
@router.message(CommandStart())
@@ -476,7 +470,11 @@ async def _show_subscription_info(
if not sub:
text = "Подписка закончилась"
else:
text = user_store.subscription_status_text(sub)
active, value, unit = user_store.subscription_time_left(sub)
text = (
f"Подписка активна\nЗакончится через {value} {unit}"
if active else "Подписка закончилась"
)
if mode == "subscription":
markup = mode_keyboard("subscription", subscription_purchased=bool(sub and sub["last_paid_at"]))
+5 -18
View File
@@ -15,19 +15,14 @@ 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__)
@@ -138,16 +133,8 @@ 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, state: FSMContext | None = None) -> None:
async def handle_successful_payment(message: Message) -> None:
payment = message.successful_payment
if payment is None or message.from_user is None:
return
@@ -168,8 +155,7 @@ async def handle_successful_payment(message: Message, state: FSMContext | None =
days = 1
await asyncio.to_thread(ensure_subscription, message.from_user.id)
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)
days_left = await asyncio.to_thread(activate_subscription_days, message.from_user.id, days)
await asyncio.to_thread(
save_payment,
charge_id,
@@ -179,8 +165,9 @@ async def handle_successful_payment(message: Message, state: FSMContext | None =
payment.total_amount,
db_path,
)
await message.answer(subscription_status_text(sub))
await _return_to_main_menu(message, state)
await message.answer(
f"Подписка активна\nЗакончится через {days_left} дн."
)
async def _reject(message: Message, charge_id: str, reason: str) -> None:
-12
View File
@@ -250,18 +250,6 @@ 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()):
+7 -15
View File
@@ -20,12 +20,6 @@ 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):
@@ -172,10 +166,8 @@ 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), 2)
self.assertEqual(len(message.answers), 1)
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"])
@@ -265,7 +257,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, _StubState())
await bot.confirm_development_payment(callback)
to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with("Dev-оплата подтверждена. Срок подписки не изменён.")
@@ -295,9 +287,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=_direct_thread_mock()) as to_thread,
patch.object(bot.asyncio, "to_thread", new=AsyncMock(return_value=32)) as to_thread,
):
await bot.confirm_development_payment(callback, _StubState())
await bot.confirm_development_payment(callback)
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))
@@ -312,7 +304,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, _StubState())
await bot.confirm_development_payment(callback)
to_thread.assert_not_awaited()
callback.answer.assert_awaited_once()
@@ -324,7 +316,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, _StubState())
await bot.confirm_development_payment(callback)
to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with()
@@ -336,7 +328,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, _StubState())
await bot.confirm_development_payment(callback)
to_thread.assert_not_awaited()
callback.answer.assert_awaited_once_with()
+17 -51
View File
@@ -145,8 +145,6 @@ 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,
@@ -185,11 +183,7 @@ 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,
display_tz: datetime.timezone | None = None,
) -> tuple[bool, int | str, str]:
def subscription_time_left(sub: sqlite3.Row, now: datetime.datetime | None = None) -> tuple[bool, int, str]:
expires = _subscription_expires_at(sub)
if expires is None:
return False, 0, ""
@@ -199,30 +193,10 @@ def subscription_time_left(
if seconds <= 0:
return False, 0, ""
if seconds >= 86400:
# Округляем вверх: купленные 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}"
return True, seconds // 86400, "дн."
if seconds >= 3600:
return True, seconds // 3600, "ч."
return True, max(1, seconds // 60), "мин."
def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None:
with closing(connect(db_path)) as connection:
@@ -240,11 +214,7 @@ 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:
@@ -252,9 +222,12 @@ 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_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)
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
@@ -263,8 +236,7 @@ def activate_subscription_days(user_id: int, days: int, db_path: str | None = No
""",
(new_expires.isoformat(), days, user_id),
)
remaining_seconds = int((new_expires - now).total_seconds())
return -(-remaining_seconds // 86400) if remaining_seconds > 0 else 0
return max(0, (new_expires - now).days)
def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int:
@@ -281,19 +253,13 @@ 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 or not sub["is_active"]:
if not sub:
return False
expires = _subscription_expires_at(sub)
if expires is None:
if not sub["is_active"]:
return False
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
return expires > now
return _days_until(sub) > 0
def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]: