- is_subscribed used _days_until() (floor), blocking generation for the whole
last day when <24h remained though subscription was still active; now checks
absolute expiry timestamp (expires > now)
- covers all gated actions (post generation, ideas) via _check_subscription
- activate_subscription_days now adds timedelta to the real expires_at
(max(now, current_expires)) instead of _days_until() floor value;
prevents losing fractional time on each renewal (10d + 1d -> was 10d)
- return ceil day count for display consistency
- keep _days_until only for is_subscribed gate and support stats
- handle_successful_payment: after status, reset FSM to lobby and send main
menu keyboard (state injected by dispatcher)
- confirm_development_payment (dev path): same — edit status then reply with
main menu; add state param
- tests: assert second message carries main-menu markup; _StubState clear/set_state
- subscription_time_left: replace floor-div '23 ч.'/'мин.' branches with the
concrete expiry timestamp when less than a full day remains
- add MSK_TIMEZONE (UTC+3) as display default; label time as '(МСК)'
- add shared subscription_status_text helper, use it in status menu,
post-payment reply and dev confirmation instead of floor-rounded days
- tests: cover under-a-day clock format; fix stale to_thread mock
Коммит 3819230 случайно удалил декоратор @router.callback_query(F.data.startswith("subscription:plan:"))
у subscription_plan — хендлер перестал регистрироваться, и все кнопки выбора
тарифа (0/1/10/30 дней) отвечали молча: callback ничем не обрабатывался,
а catch-all @router.message() его не видит.
Оператор мог ответить, когда пользователь уже вышел из режима поддержки.
Сообщение приходило, но ответить было нечем: снова идти в «Помощь →
Поддержка», а про прежний контекст уже ничего не напоминало.
- К сообщению оператора добавляется инлайн-кнопка «↩️ Ответить», только
если обращение закрыто (при активном пользователь и так пишет оператору).
- Кнопка ставит is_active=1, убирает себя с сообщения и показывает
клавиатуру поддержки (только «Главное меню»).
- Callback проверяет владельца: support:reply:<user_id> сработает только
для того пользователя, кому пришло сообщение.
- Роутер support.router не затронут фильтром основного роутера
(F.message.chat.type == "private") — он на него не распространяется.
Тесты: 251 OK (7 новых). Проверено, что aiogram вызывает bot(SendMessage(...)),
а не bot.send_message — asserts адаптированы, иначе тест проходил вхолостую.
VERSION: 0.1.0+58 -> 0.1.0+59
В топике было непонятно, от кого сообщение: текст приходил без автора.
- Текст уходит в топик как «<Имя>:\n<текст>»; имени нет — «<id>:\n<текст>»
(user_signature()).
- Caption медиа уходит текстом с той же подписью, само медиа копируется.
- Всё остальное (фото, документы, голосовые без подписи) копируется как есть,
без подписи — как и просили «если не текст, то пофиг».
Симметрично подписи оператора «Оператор поддержки:» на ответах.
Тесты: 244 OK (8 новых — подпись имени, fallback на id, медиа с caption,
медиа без текста копируется без подписи).
VERSION: 0.1.0+57 -> 0.1.0+58
Ответ из топика приходил пользователю без подписи, и было непонятно, кто
пишет — бот или человек.
- Текст уходит одним сообщением: «Оператор поддержки:\n<текст>».
- У медиа copy_message не позволяет подменить caption, поэтому подпись
отправляется отдельным сообщением перед копией; caption медиа, если был,
попадает в то же сообщение.
- Подпись задаётся SUPPORT_OPERATOR_SIGNATURE (default «Оператор поддержки»).
- Заметки с префиксом // по-прежнему не уходят пользователю.
Тесты: 236 OK (6 новых — текст, медиа с caption, медиа без caption,
переопределение подписи через env).
VERSION: 0.1.0+56 -> 0.1.0+57
Деактивация была только в /start и «Главное меню». После перехода в
«Обучение» флаг is_active оставался, и текст уезжал оператору, а не в
обучение — сообщение уходило не туда.
Правило теперь: ушёл из контекста поддержки — обращение неактивно.
_close_support_case() вызывается из enter_mode() (любой режим),
show_main_menu() и start(). «🆘 Поддержка» — не режим, флаг не снимает.
Топик и история сохраняются, следующее обращение продолжает переписку.
Три точки вызова сведены в один helper вместо трёх копий try/except.
Тесты: 230 OK (3 новых). Регрессия проверена на старом коде: после входа
в «Обучение» is_active=True и relay=1 (текст ушёл оператору); с фиксом
is_active=False, relay=0 (текст обработал бот).
VERSION: 0.1.0+55 -> 0.1.0+56
/start возвращает в лобби, но не снимал is_active в support_topics: FSM
сбрасывался, а обращение в БД оставалось открытым. Пользователь оказывался
в лобби, а следующее сообщение всё равно уходило оператору. Дополнительно
enter_mode() блокировал вход в режим сообщением «Идёт обращение в поддержку»
— после /start это выглядело как необъяснимый отказ.
- start() вызывает support.deactivate() перед сбросом FSM.
- Блокировка из enter_mode() удалена: выход в любой режим из открытого
обращения разрешён — пользователя нельзя запирать. Проверка дублировала
middleware иConflictовала с /start.
Тесты: 227 OK (3 новых). Регрессия проверена на старом коде: /start оставляет
is_active=True (баг), с фиксом — False. Топик при этом сохраняется, поэтому
следующее обращение продолжает ту же переписку.
VERSION: 0.1.0+54 -> 0.1.0+55
Сообщение оператора в топике поддержки не доходило до пользователя: бот
отвечал прямо в группу «Выбери режим работы кнопкой ниже». Причина — порядок
регистрации роутеров: основной router с catch-all @router.message() (без
фильтра по чату) включался ПЕРВЫМ, а support.router — вторым. У aiogram
побеждает первый подходящий, поэтому до поддержки очередь не доходила.
Комментарий в main() утверждал обратное.
- support.router регистрируется до основного router.
- Основной роутер отфильтрован на F.chat.type == "private" (роль «работник»);
групповые сценарии обслуживаются своими роутерами. Это первый шаг к ролям:
работник / помощник поддержки / аналитик / почтальон.
- Роутер поддержки дополнительно отсекает сообщения от ботов.
Вторая поломка того же корня: при активном обращении «📖 Помощь» и
«🎓 Обучение» уезжали оператору как обычный текст, режим не переключался и
бот в личке молчал — пользователь был заперт в обращении. Middleware теперь
пропускает все команды и все кнопки reply-клавиатуры через _is_bot_control(),
а не только «Поддержка» и «Главное меню».
Тесты: 224 OK (10 новых). Регрессия проверена: на старой логике «Помощь»
уходит оператору (relay=1), на новой обрабатывается ботом (relay=0).
Заглушки заменены на настоящие Message — middleware проверяет
isinstance(event, Message), поэтому SimpleNamespace проходил вхолостую.
is_support_active вызывается через to_thread — AsyncMock-заглушка возвращала
truthy-корутину и делала проверку фиктивной.
VERSION: 0.1.0+53 -> 0.1.0+54