3x-ui · WireGuard
+Панель 3.5.0 · API URL и URL подписки задаются вручную
+Загрузка…
+Покупка WG-конфигов
+При активной подписке — до 10 конфигов. Ползунок включает продажи.
+From aaf04faa961206e791bf6518ae0377f17669cf75 Mon Sep 17 00:00:00 2001
From: VPN Service Актуальная версия · {html.escape(EFFECTIVE)} · {html.escape(SITE)} Настоящий документ является публичной офертой и условиями использования сервиса {e(BRAND_SITE)},
+подготовлен с учётом требований ЕС (права потребителей, цифровой контент, e-commerce, GDPR — где применимо)
+и США (нормы FTC и штатов, включая CCPA/CPRA). Регистрация, оплата и использование сервиса означают акцепт оферты. Исполнитель — владелец сервиса {e(BRAND_SITE)}, предоставляющий доступ к VPN.
+Заказчик (пользователь, потребитель) — дееспособное физическое или юридическое лицо, принявшее оферту
+путём регистрации и/или оплаты. Услуги — предоставление доступа к VPN-конфигурациям и функциям кабинета
+по выбранному тарифу. Цифровой контент — подписка, ссылка доступа и иные данные в электронной форме. Полный список: Контакты. Исполнитель обязуется предоставить Заказчику VPN-доступ согласно оплаченному тарифу; Заказчик обязуется
+оплатить услуги по ценам на сайте на момент покупки. Услуга является цифровым контентом, не поставляемым
+на материальном носителе, предоставляемым удалённо (онлайн). Существенные характеристики (срок, цена, лимиты трафика и устройств, список серверов) указываются
+на странице тарифа до оплаты. Дополнительные условия — в
+правилах использования VPN и правилах сайта. Договор считается заключённым с момента: Публикация тарифов — приглашение делать оферты; акцепт — оплата выбранного тарифа Заказчиком. Заказчик регистрируется, при необходимости пополняет баланс и покупает подписку в кабинете.
+Доступ предоставляется сразу после успешной оплаты: конфигурация и ссылка подписки отображаются в кабинете.
+Отдельная плата за доставку не взимается. Подробнее: Условия доставки. Срок подписки считается по тарифу (календарные дни/месяцы), если не включён режим заморозки до активации —
+тогда срок начинается с момента активации, указанного на сайте. Цены указываются на сайте до оформления заказа. Оплата — доступными в кабинете способами;
+обработку платежей ведут платёжные провайдеры по своим правилам. Исполнитель не хранит полные данные банковских карт. Валюта отображения — на усмотрение Исполнителя; фактическая сумма списания может зависеть от курса и комиссий.
+НДС: если Исполнитель не является налогоплательщиком в соответствующей юрисдикции, цена может указываться
+без отдельной налоговой строки; для потребителей ЕС налоговые обязанности определяются законом и статусом Исполнителя. Для потребителей в ЕС действует право отказаться от дистанционного договора в течение 14 календарных дней
+без указания причин (Директива 2011/83/EU), если ниже не предусмотрено иное. VPN-доступ — цифровой контент не на материальном носителе. При оформлении заказа Заказчик: Возвраты после начала использования — по условиям возврата, в пределах императивных норм ЕС. Для потребителей в США применяются условия возврата и обязательные нормы штата проживания
+(включая права жителей Калифорнии по CCPA/CPRA — см. политику персональных данных). Цифровые услуги с мгновенной доставкой, как правило, не возвращаются после фактического подключения и использования,
+кроме случаев, прямо указанных в условиях возврата, ошибочного двойного списания или когда это обязательно по закону. Исполнитель определяет допустимое использование услуг на основе права ЕС и США
+(см. правила VPN). Законодательство стран с авторитарным или цензурным регулированием интернета
+не является референсной рамкой настоящих условий. Заказчик обязуется: Заказчик самостоятельно оценивает риски использования VPN в стране пребывания.
+Исполнитель не гарантирует законность VPN во всех юрисдикциях мира. Обработка данных — по Политике обработки персональных данных
+(GDPR, ePrivacy, CCPA/CPRA — где применимо). Регистрация и оплата подтверждают ознакомление с политикой.
+Права субъекта данных можно реализовать через профиль и поддержку. Исполнитель стремится поддерживать работоспособность VPN, но услуга предоставляется «как есть» и «как доступна»
+в пределах, допускаемых императивным правом ЕС и США. Не гарантируются: непрерывная доступность, конкретная скорость, доступность отдельных сайтов или сервисов,
+обход всех блокировок операторов и государств. Обслуживание, сбои провайдеров и форс-мажор могут влиять на доступ. Для потребителей ЕС: ничто в оферте не ограничивает законные гарантии соответствия цифрового контента
+по Директиве (EU) 2019/770 в объёме, применимом к характеру услуги. Исполнитель не отвечает за: действия Заказчика и третьих лиц; содержание пользовательского трафика;
+блокировки VPN на уровне государств и провайдеров; косвенные убытки, упущенную выгоду, потерю данных —
+в максимальной мере, допускаемой применимым правом. Совокупная ответственность Исполнителя ограничена суммой, фактически уплаченной Заказчиком
+за спорную подписку за последние 12 месяцев, если иное не требуется императивными нормами защиты потребителей ЕС/США. Ограничения не применяются к ответственности за умысел, грубую неосторожность, вред жизни и здоровью
+и иным случаям, когда ответственность нельзя исключить законом. Услуги не предоставляются лицам и организациям, подпадающим под санкции США (OFAC), ЕС или иные обязательные режимы.
+Заказчик подтверждает отсутствие таких ограничений. При нарушении Исполнитель может немедленно расторгнуть договор. Исполнитель может приостановить или прекратить доступ за нарушение оферты, правил, права ЕС/США,
+злоупотребление ресурсами, неоплату или по запросу платёжного провайдера.
+Постоянная блокировка за серьёзные нарушения — по правилам сайта. Заказчик может прекратить использование в любое время; удаление аккаунта — через профиль.
+Неиспользованный предоплаченный период не возвращается, кроме случаев по условиям возврата и императивному праву. Исполнитель может изменять оферту, цены и тарифы. Актуальная версия публикуется на этой странице.
+О существенных изменениях для активных подписок может быть направлено уведомление (сайт, email).
+Продолжение использования после вступления изменений в силу означает согласие, если иное не запрещено законом.
+Для потребителей ЕС: изменения не ухудшают уже оплаченные права без согласия в течение оплаченного тарифа. Договор регулируется правом, определяемым по императивным коллизионным нормам защиты потребителей:
+для потребителей в ЕС/ЕЭЗ сохраняются обязательные нормы страны обычного проживания, если они более благоприятны. Для потребителей в США — обязательные нормы штата проживания в требуемом законом объёме. Споры решаются переговорами и обращением в поддержку. При отсутствии согласия: Ничто в этом разделе не лишает потребителя права на судебную защиту по императивным нормам его страны. Оферта действует с момента публикации на сайте. Недействительность отдельного положения не влечёт
+недействительности оферты в целом. Неотъемлемые документы:
+правила сайта, правила VPN,
+политика персональных данных, доставка, возврат. Актуальная версия. Политика подготовлена с учётом требований ЕС (GDPR, ePrivacy)
+и США (штатные законы о приватности, включая CCPA/CPRA, рекомендации FTC).
+По вопросам обработки данных — контакты. Сервис {e(BRAND_SITE)} («мы», «сервис», «оператор») уважает вашу конфиденциальность.
+Политика описывает, какие персональные данные мы собираем, на каком основании обрабатываем,
+кому передаём, как долго храним и какие у вас права. Политика применяется при использовании сайта, кабинета, VPN и иных услуг {e(BRAND_SITE)}.
+Используя сервис, вы подтверждаете ознакомление с документом. Оператор (контролёр данных в смысле GDPR) — владелец сервиса {e(BRAND_SITE)}. Отдельный DPO не назначен; запросы по персональным данным принимаются по указанным каналам. Мы не собираем особые категории данных (здоровье, биометрия, политические взгляды и т.п.);
+сервис не предназначен для детей младше 16 лет (см. раздел 12). Мы используем cookies и local storage браузера. По назначению они делятся на: Мы не используем стороннюю рекламу и профилирующие cookies для межсайтового трекинга.
+Вы можете удалить или заблокировать cookies в настройках браузера; отказ от необходимых может сделать вход невозможным. Срок хранения cookie согласия — до 12 месяцев, если иное не указано в браузере. Политика конфиденциальности VPN дополняется правилами использования VPN.
+Мы соблюдаем принцип минимизации: не ведём целевой мониторинг содержимого трафика.
+Технические метаданные (например, объём трафика на панелях) могут обрабатываться для биллинга,
+лимитов тарифа и предотвращения злоупотреблений — без привязки к содержимому посещённых сайтов. По истечении сроков данные удаляются или обезличиваются. Мы не продаём персональные данные и не передаём их за денежное вознаграждение
+(включая «sale»/«sharing» по CCPA/CPRA для поведенческой рекламы). Данные могут передаваться обработчикам только для оказания услуги: платёжные системы, хостинг и ЦОД,
+сервисы email/уведомлений, виджет чата (при согласии «Принять все»), подрядчики мониторинга
+(без доступа к содержимому VPN-трафика). С ними заключаются DPA или стандартные договорные условия в объёме GDPR. Раскрытие государственным органам — только по действительному и обязательному запросу
+по праву ЕС, США или иной юрисдикции, обязательной для нас. Серверы и подрядчики могут находиться вне вашей страны, в том числе вне ЕЭЗ.
+При передаче из ЕС/ЕЭЗ применяются меры главы V GDPR: SCC, решения об адекватности или иные законные механизмы. Шифрование паролей, ограничение доступа к БД, HTTPS, разграничение прав администраторов, резервные копии.
+Ни один способ передачи или хранения в интернете не гарантирует абсолютную безопасность. Сервис не предназначен для лиц младше 16 лет (в отдельных штатах США — младше 13 без согласия родителей).
+Мы сознательно не собираем данные детей. При обнаружении такого аккаунта можем удалить его и связанные данные. Мы не принимаем решений о вас исключительно на основе автоматизированной обработки,
+порождающих правовые последствия или существенно затрагивающих вас (ст. 22 GDPR).
+Ограничения аккаунта за злоупотребления принимаются администрацией с учётом обстоятельств. В зависимости от места проживания вы можете иметь права на доступ, исправление, удаление («право быть забытым»),
+ограничение обработки, переносимость, возражение, отзыв согласия, жалобу в надзорный орган ЕС/ЕЭЗ
+или компетентный орган штата США. Как реализовать: экспорт данных и удаление аккаунта — в профиле кабинета;
+иные запросы — тикет в поддержку или email {e(SUPPORT_EMAIL)}.
+Срок ответа — до 30 дней (GDPR) или сроки штатного права США (например, 45 дней по CCPA с возможным продлением).
+Мы можем запросить подтверждение личности. Жители Калифорнии: мы не «продаём» и не «расшариваем» персональные данные для кросс-контекстной поведенческой рекламы.
+Права знать категории данных, запросить удаление и исправление — по CCPA/CPRA. Дискриминации за реализацию прав нет. Актуальная версия всегда на этой странице; о существенных изменениях можем уведомить через сайт или email. По всем вопросам приватности: {e(SUPPORT_EMAIL)},
+раздел Поддержка, Контакты. Использование сервиса означает принятие настоящих правил. Референсная рамка допустимого поведения —
+право Европейского Союза и Соединённых Штатов Америки
+(см. также правила VPN и оферту). Сервис {e(BRAND_SITE)} предоставляет доступ к VPN и связанным цифровым услугам.
+Регистрируясь, вы соглашаетесь с правилами, политикой персональных данных (GDPR, CCPA/CPRA)
+и офертой. Подробные условия VPN — в правилах использования VPN. Для покупки необходима регистрация. Укажите действующий email и храните пароль в безопасности.
+Вы отвечаете за действия с вашего аккаунта. Сервис не предназначен для лиц младше 16 лет
+(13 в отдельных штатах США без согласия родителей). Оплата — через баланс кабинета и платёжных провайдеров. Конфигурации выдаются в кабинете.
+Передача доступа третьим лицам в коммерческих целях без согласия сервиса запрещена. Одна подписка — не более 10 одновременных устройств, если тариф не указывает иное.
+Для большего числа устройств — отдельная подписка. Превышение лимита — нарушение; возможна приостановка. Запрещено использовать сервис для деятельности, противоречащей праву ЕС/США, включая
+(но не ограничиваясь): несанкционированный доступ к системам, вредоносное ПО, DDoS, спам, мошенничество,
+CSAM, обход санкций OFAC/ЕС, систематическое пиратство. Полный список — в правилах VPN. Сервис не обязан ограничивать доступ к ресурсам только потому, что они заблокированы в отдельных странах
+с авторитарным регулированием сети, если иное не требуется применимым правом ЕС/США. Вопросы — через Поддержку или контакты.
+Потребители ЕС: ODR. Споры — по оферте, раздел 16. Возвраты — по условиям возврата и императивному праву ЕС/США.
+При существенном вреде сервису возврат может не выдаваться.
+За серьёзные нарушения возможна постоянная блокировка без восстановления доступа. Государственные или провайдерские блокировки VPN — риск пользователя, а не основание для автоматического возврата
+(см. возврат, раздел 7). Возврат возможен только при длительном простое по вине сервиса
+(>3 подряд календарных дней без замены сервера) — см. страницу возврата. Актуальная версия — на этой странице. О существенных изменениях можем сообщить через сайт или email.
+Для потребителей ЕС: изменения не ухудшают уже оплаченный период без согласия. Документ дополняет правила сайта и договор оферты.
+Используя VPN-услуги, вы подтверждаете ознакомление со следующим.
+Политика допустимого использования VPN формируется со ссылкой на право ЕС и США. Сервис {e(BRAND_SITE)} предоставляет доступ к зашифрованному VPN-соединению для личного законного использования:
+защита приватности, безопасность данных в публичных и домашних сетях.
+Сервис не предназначен для нарушения закона и не гарантирует доступ к конкретным ресурсам
+при изменении политик операторов или сетевых ограничений. Если в описании тарифа указано «безлимит» или сходная формулировка, это означает условно безлимитное использование
+в рамках разумного личного потребления (веб, мессенджеры, стриминг в обычном качестве и т.п.). Такой режим не является абсолютным неограниченным объёмом: существуют физические лимиты канала,
+политики ЦОД и требования стабильной работы сети для всех клиентов. VPN-инфраструктура рассчитана на совместное использование многими пользователями.
+Для стабильной скорости и доступности сервис следует принципу справедливого распределения ресурсов (fair use):
+избыточная, нетипичная или монополизирующая нагрузка на каналы и узлы не допускается. При обнаружении злоупотребления администрация может по своему усмотрению: Цель мер — справедливые условия для всех пользователей и работоспособность инфраструктуры.
+Решение сообщается пользователю через поддержку (тикеты) или иные каналы на сайте. Сервис {e(BRAND_SITE)} при определении допустимого и недопустимого использования VPN и при принятии мер
+опирается на требования и общепризнанные стандарты права ЕС и США. Законодательство и практика стран с авторитарным, цензурным или иным несвободным регулированием интернета —
+включая Российскую Федерацию и Украину — не являются референсной рамкой настоящих правил.
+Ограничения таких юрисдикций сами по себе не создают обязанности сервиса блокировать или ограничивать VPN,
+если использование не противоречит применимому праву ЕС/США и настоящему документу. Это не означает безусловной безнаказанности пользователя в любой стране:
+вы самостоятельно оцениваете риски в месте пребывания. Сервис не консультирует по законам конкретных стран
+и не гарантирует, что использование VPN будет законным или безопасным во всех юрисдикциях. VPN предназначен для законного личного использования. Сервис не мониторит содержимое трафика в реальном времени
+и не отвечает за ваши действия, но может ограничить или прекратить доступ при нарушении правил. Запрещено использовать VPN для деятельности, противоречащей праву ЕС и/или США, включая: Услуги не предоставляются лицам и организациям, подпадающим под обязательные санкции США (включая списки OFAC),
+ЕС или иных юрисдикций, чьи ограничения сервис обязан соблюдать по закону.
+Сервис может прекратить доступ при выявлении нарушений санкционных или экспортных ограничений. Конфигурации и ключи предназначены для личного использования владельцем аккаунта, если тариф прямо не предусматривает иное.
+Передача доступа третьим лицам в коммерческих целях, массовая перепродажа или публичное распространение без согласия сервиса не допускаются. Одна подписка допускает использование не более чем на 10 устройствах одновременно,
+если на сайте для тарифа не указано иное. Для большего числа устройств нужна дополнительная подписка.
+Превышение лимита нарушает правила; сервис может применить меры вплоть до приостановки аккаунта. VPN зависит от сетей провайдеров, ЦОД и сторонних сервисов. Возможны краткие сбои, смена IP и обслуживание.
+Сервис стремится обеспечить доступность, но не гарантирует бесперебойность. Сервис не ведёт целевой мониторинг содержимого пользовательского трафика.
+При получении действительных и обязательных запросов от компетентных органов в рамках процедур,
+предусмотренных правом ЕС, США или иной юрисдикции, обязательной для сервиса,
+мы можем предоставить имеющиеся у нас данные (например, сведения об аккаунте и платежах) в объёме, требуемом законом.
+Подробнее — в политике персональных данных. Запросы властей РФ, Украины и иных авторитарных юрисдикций, не имеющие обязательной силы по праву ЕС/США
+и не подкреплённые международно признанной процедурой, не являются основанием для произвольного раскрытия данных
+или блокировки пользователя, если иное прямо не требуется применимым правом. Актуальная версия публикуется на этой странице. Продолжение использования VPN после обновления правил
+означает принятие в объёме, не противоречащем заключённому договору и применимому праву. Порядок возврата средств за цифровые VPN-услуги {e(BRAND_SITE)}.
+Документ учитывает обязательные нормы ЕС (Директива 2011/83/EU, Регламент (EU) 2019/770)
+и США (права потребителей штатов, FTC). Референсная рамка — право ЕС/США. Услуга — цифровой контент не на материальном носителе, предоставляемый удалённо и, как правило,
+мгновенно после оплаты (см. доставку). Если вы потребитель в ЕС, у вас может быть право отказаться от дистанционного договора
+в течение 14 календарных дней без указания причин (Директива 2011/83/EU). Исключение для цифрового контента: если при покупке вы явно запросили немедленное начало оказания услуги
+и получили доступ (конфигурация в кабинете), право отказа может быть утрачено в объёме уже оказанной услуги
+(ст. 16(m) Директивы). Подробнее — в оферте, раздел 7. До начала использования (доступ не активирован/не подключён) возврат может быть рассмотрен
+по обращению в поддержку в разумный срок. Для жителей США возврат за цифровые услуги с мгновенной доставкой, как правило, не предоставляется
+после фактического использования, если иное не требуется императивным правом штата или настоящей политикой
+(ошибочное списание, двойной платёж). Запрос на возврат неиспользованного доступа может быть рассмотрен через
+поддержку или контакты.
+Возврат не гарантируется автоматически и зависит от технической возможности платёжного провайдера. Возврат может быть отказан при нарушении правил сайта,
+правил VPN или при причинении существенного вреда сервису. При ошибочном переводе или двойном списании обратитесь в поддержку с номером транзакции, суммой и способом оплаты.
+При подтверждении ошибки средства возвращаются по процедурам платёжной системы. Не злоупотребляйте chargeback без оснований — это может привести к блокировке аккаунта. Возврат, как правило, не выдаётся, если доступ уже использован (подключение по выданным данным),
+а также при серьёзных нарушениях правил или права ЕС/США. Ограничения VPN, введённые государствами, провайдерами или операторами связи,
+являются обстоятельствами вне контроля сервиса и сами по себе не являются основанием для возврата,
+если услуга была технически оказана с нашей стороны. Вы самостоятельно оцениваете риски
+в месте пребывания (см. правила VPN). Возврат за период подписки может быть рассмотрен, если простой серверов по вине сервиса
+превысил 3 подряд календарных дня, доступ не был восстановлен заменой сервера,
+и вы своевременно уведомили поддержку. Краткие сбои и плановое обслуживание не являются основанием. Для потребителей ЕС сохраняются права при несоответствии цифрового контента по Регламенту (EU) 2019/770 —
+в применимом объёме. Рассмотрение запроса — до 14 рабочих дней (для запросов GDPR — до 30 дней).
+При одобрении — возврат тем же способом оплаты; срок зачисления — по правилам платёжной системы
+(обычно 10–30 рабочих дней). Споры — через поддержку. Потребители ЕС: платформа ODR.
+Обязательные нормы страны проживания сохраняются (см. оферту, раздел 16). Сервис {e(BRAND_SITE)} предоставляет цифровой контент (VPN-доступ).
+Документ подготовлен с учётом требований ЕС (Директива 2011/83/EU, Регламент (EU) 2019/770)
+и США (прозрачность для потребителей, FTC). Доставка осуществляется исключительно в электронной форме. После успешной оплаты доступ к конфигурациям
+предоставляется автоматически: ссылки подписки и данные подключения отображаются в кабинете сразу после зачисления платежа.
+Физическая доставка не применяется. Отдельная плата за доставку не взимается. Цена на странице тарифа включает предоставление цифрового доступа
+(принцип прозрачности цены — право потребителей ЕС/США). Доступ предоставляется сразу после подтверждения оплаты платёжной системой (обычно в течение нескольких минут).
+При задержке у провайдера — в разумный срок, как правило до 24 часов; обратитесь в
+поддержку. Для потребителей ЕС: при оформлении заказа вы запрашиваете немедленное начало оказания услуги;
+это может повлиять на 14-дневное право отказа — см. оферту и
+политику возврата. Сервис доступен пользователям в регионах, где доступны сайт и выбранные способы оплаты,
+с учётом ограничений санкций OFAC/ЕС (см. оферту). Предоставление доступа фиксируется автоматически в кабинете. Это считается исполнением обязанности
+по поставке цифрового контента. При проблемах после оплаты — откройте тикет в поддержке. До оплаты на сайте отображаются: наименование услуги, цена, срок подписки, основные характеристики тарифа
+(лимиты, протоколы). Полные условия — в оферте,
+политике персональных данных и контактах. Актуальные контактные данные сервиса {e(BRAND_SITE)}.
+По вопросам персональных данных (GDPR, CCPA/CPRA) и прав потребителей ЕС/США используйте каналы ниже. Оператор сервиса (контролёр данных) Email Поддержка Срок ответа на запросы по персональным данным — до 30 дней (GDPR)
+ или в сроки CCPA/CPRA (обычно до 45 дней). Жители ЕС/ЕЭЗ могут обратиться в надзорный орган по защите данных своей страны (список — EDPB)
+и использовать платформу Online Dispute Resolution (ODR) Еврокомиссии. Жители США (включая Калифорнию) — по вопросам персональных данных см.
+политику; жалобы можно подавать в компетентный орган штата
+(например, California Attorney General — Privacy). Поддержка сервиса {e(BRAND_SITE)}. Email: {e(SUPPORT_EMAIL)} Перед обращением проверьте: актуальность приложения, корректность скопированной ссылки подписки,
+статус серверов на странице Статус серверов. Справка по типичным вопросам: Помощь. Краткая справка по кабинету {e(BRAND)}: регистрация, тарифы, подписка Remnawave, устройства и лимиты. При регистрации укажите логин и пароль. Забыли пароль — сброс со страницы входа (если настроен SMTP).
+Реферальная ссылка при регистрации (если есть) применяется автоматически. В кабинете откройте «Баланс». Способы оплаты зависят от настроек сервиса.
+Мы не списываем карту автоматически без вашего действия: услуги оплачиваются с баланса сайта.
+В профиле можно включить добровольное автопродление с баланса — это не привязка карты,
+а списание уже зачисленных средств при истечении тарифа. После покупки в кабинете появляется ссылка подписки Не превышайте лимит устройств — иначе доступ может быть ограничен до покупки слотов или удаления лишних устройств.
+Одна выданная подписка/ссылка не предназначена для массовой раздачи.
+Злоупотребления могут привести к блокировке аккаунта. Возвраты — см.
+политику возврата и правила. Создайте тикет в разделе Поддержка.
+Укажите тип подключения и что уже пробовали. Перед обращением обновите приложение и проверьте ссылку. Актуальный статус узлов и инфраструктуры сервиса {e(BRAND_SITE)}. Публичная страница статуса: Краткие сбои и плановое обслуживание обычно отражаются на статус-странице.
+ При длительной недоступности (>3 суток подряд по вине сервиса без замены сервера)
+ см. условия возврата. Если сервис недоступен — напишите в поддержку. Настройки cookie для сайта {e(BRAND_SITE)}. Подробности — в разделе 5
+Политики обработки персональных данных. Строго необходимые — сессия, безопасность, работа кабинета. Согласие не требуется. Функциональные — запоминание вашего выбора cookie. Опциональные — сторонние виджеты (например, чат поддержки), если включены на сайте.
+ Загружаются только при выборе «Принять все». Выбор ещё не сохранён. Выбор хранится в localStorage браузера (/addbal TELEGRAM_ID СУММА"
+)
+
+
+@router.message(Command("admin"))
+@router.message(F.text == "🛠 Админка")
+async def admin_home(message: Message, state: FSMContext) -> None:
+ await state.clear()
+ await message.answer(ADMIN_HOME, reply_markup=admin_inline(), parse_mode="HTML")
+
+
+@router.callback_query(F.data == "admin:home")
+async def cb_admin_home(callback: CallbackQuery, state: FSMContext) -> None:
+ await state.clear()
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(ADMIN_HOME, reply_markup=admin_inline(), parse_mode="HTML")
+
+
+@router.callback_query(F.data == "admin:servers")
+async def cb_admin_servers(callback: CallbackQuery, sdk: RemnawaveSDK) -> None:
+ await callback.answer("Пингую…")
+ try:
+ nodes = await fetch_nodes_status(sdk)
+ text = format_nodes_status(nodes, detailed=True)
+ if callback.message:
+ await callback.message.edit_text(
+ text, reply_markup=admin_back_inline(), parse_mode="HTML"
+ )
+ except Exception as exc: # noqa: BLE001
+ if callback.message:
+ await callback.message.edit_text(
+ f"Ошибка:\n{exc}",
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "admin:health")
+async def cb_admin_health(callback: CallbackQuery, sdk: RemnawaveSDK) -> None:
+ await callback.answer()
+ try:
+ health = await sdk.system.get_health()
+ try:
+ meta = await sdk.system.get_metadata()
+ version = getattr(meta, "version", "—")
+ except Exception: # noqa: BLE001
+ version = "—"
+
+ lines = [
+ "💚 Remnawave Health",
+ "",
+ f"Версия: {version}",
+ "Статус API: OK",
+ ]
+ pm2 = getattr(health, "pm2_stats", None) or []
+ if pm2:
+ lines.append("")
+ lines.append("Процессы:")
+ for proc in pm2:
+ lines.append(
+ f"• {proc.name} — CPU {proc.cpu}, RAM {proc.memory}"
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ "\n".join(lines),
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+ except Exception as exc: # noqa: BLE001
+ if callback.message:
+ await callback.message.edit_text(
+ f"API недоступен.\n{exc}",
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "admin:users")
+async def cb_admin_users(callback: CallbackQuery, sdk: RemnawaveSDK) -> None:
+ await callback.answer()
+ try:
+ users = await sdk.users.get_all_users(size=1)
+ total = getattr(users, "total", None)
+ if total is None:
+ items = getattr(users, "users", None) or getattr(users, "root", None)
+ total = len(items) if items is not None else "?"
+ text = f"👥 Пользователи\n\nВсего в панели: {total}"
+ if callback.message:
+ await callback.message.edit_text(
+ text, reply_markup=admin_inline(), parse_mode="HTML"
+ )
+ except Exception as exc: # noqa: BLE001
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось получить пользователей.\n{exc}",
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "admin:stats")
+async def cb_admin_stats(callback: CallbackQuery, sdk: RemnawaveSDK) -> None:
+ await callback.answer()
+ try:
+ stats = await sdk.system.get_stats()
+ mem = stats.memory
+ users = stats.users
+ online = stats.online_stats
+ nodes = stats.nodes
+ text = (
+ "📊 Статистика панели\n\n"
+ f"CPU cores: {stats.cpu.cores:g}\n"
+ f"RAM: {mem.used / 1024**3:.1f} / {mem.total / 1024**3:.1f} GB\n\n"
+ f"Пользователи: {users.total_users:g}\n"
+ f"Онлайн сейчас: {online.online_now:g}\n"
+ f"Онлайн за сутки: {online.last_day:g}\n\n"
+ f"Ноды онлайн: {nodes.total_online:g}\n"
+ f"Трафик lifetime: {nodes.total_bytes_lifetime}"
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ text, reply_markup=admin_inline(), parse_mode="HTML"
+ )
+ except Exception as exc: # noqa: BLE001
+ if callback.message:
+ await callback.message.edit_text(
+ f"Статистика недоступна.\n{exc}",
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "admin:topup")
+async def cb_admin_topup(callback: CallbackQuery, state: FSMContext) -> None:
+ await state.set_state(TopUpStates.waiting_user_id)
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "💰 Пополнение баланса\n\n"
+ "Пришли Telegram ID пользователя.\n"
+ "Или используй: /addbal ID СУММА",
+ reply_markup=admin_topup_cancel(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "admin:topup_cancel")
+async def cb_admin_topup_cancel(callback: CallbackQuery, state: FSMContext) -> None:
+ await state.clear()
+ await callback.answer("Отменено")
+ if callback.message:
+ await callback.message.edit_text(
+ ADMIN_HOME, reply_markup=admin_inline(), parse_mode="HTML"
+ )
+
+
+@router.message(StateFilter(TopUpStates.waiting_user_id), F.text)
+async def admin_topup_got_id(message: Message, state: FSMContext, db: Database) -> None:
+ text = (message.text or "").strip()
+ if not text.isdigit():
+ await message.answer(
+ "Нужен числовой Telegram ID.\nПример: 123456789",
+ reply_markup=admin_topup_cancel(),
+ parse_mode="HTML",
+ )
+ return
+ target_id = int(text)
+ user = db.ensure_user(target_id)
+ await state.clear()
+ await message.answer(
+ f"Пользователь {target_id}\n"
+ f"Текущий баланс: {user.balance:.0f} ₽\n\n"
+ "Выбери сумму пополнения:",
+ reply_markup=admin_topup_amounts(target_id),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("admin:topup_custom:"))
+async def cb_admin_topup_custom(callback: CallbackQuery, state: FSMContext) -> None:
+ target_id = int((callback.data or "").split(":")[-1])
+ await state.set_state(TopUpStates.waiting_custom_amount)
+ await state.update_data(topup_target_id=target_id)
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ f"Введи сумму пополнения для {target_id} (₽).\n"
+ "Можно отрицательную — для списания.",
+ reply_markup=admin_topup_cancel(),
+ parse_mode="HTML",
+ )
+
+
+@router.message(StateFilter(TopUpStates.waiting_custom_amount), F.text)
+async def admin_topup_custom_amount(
+ message: Message,
+ state: FSMContext,
+ db: Database,
+) -> None:
+ data = await state.get_data()
+ target_id = int(data.get("topup_target_id") or 0)
+ raw = (message.text or "").strip().replace(",", ".")
+ try:
+ amount = float(raw)
+ except ValueError:
+ await message.answer("Нужно число, например 750", parse_mode="HTML")
+ return
+ if amount == 0:
+ await message.answer("Сумма не должна быть 0.")
+ return
+
+ await state.clear()
+ admin = message.from_user
+ balance = db.add_balance(
+ target_id,
+ amount,
+ kind="admin_topup",
+ meta=f"by:{admin.id if admin else '?'}",
+ )
+ await message.answer(
+ f"✅ Начислено {amount:.0f} ₽ → {target_id}\n"
+ f"Баланс: {balance:.0f} ₽",
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+ try:
+ if message.bot:
+ await message.bot.send_message(
+ target_id,
+ f"💰 Баланс изменён на {amount:+.0f} ₽\n"
+ f"Доступно: {balance:.0f} ₽",
+ parse_mode="HTML",
+ )
+ except Exception: # noqa: BLE001
+ pass
+
+
+@router.callback_query(F.data.startswith("admin:topup_do:"))
+async def cb_admin_topup_do(callback: CallbackQuery, db: Database) -> None:
+ parts = (callback.data or "").split(":")
+ if len(parts) != 4:
+ await callback.answer("Некорректные данные", show_alert=True)
+ return
+ try:
+ target_id = int(parts[2])
+ amount = float(parts[3])
+ except ValueError:
+ await callback.answer("Некорректные данные", show_alert=True)
+ return
+
+ admin = callback.from_user
+ balance = db.add_balance(
+ target_id,
+ amount,
+ kind="admin_topup",
+ meta=f"by:{admin.id if admin else '?'}",
+ )
+ await callback.answer("Готово")
+ if callback.message:
+ await callback.message.edit_text(
+ f"✅ Начислено {amount:.0f} ₽\n"
+ f"Пользователь: {target_id}\n"
+ f"Новый баланс: {balance:.0f} ₽",
+ reply_markup=admin_inline(),
+ parse_mode="HTML",
+ )
+ try:
+ if callback.bot:
+ await callback.bot.send_message(
+ target_id,
+ f"💰 Баланс пополнен на {amount:.0f} ₽\n"
+ f"Доступно: {balance:.0f} ₽",
+ parse_mode="HTML",
+ )
+ except Exception: # noqa: BLE001
+ pass
+
+
+@router.message(Command("addbal"))
+async def cmd_addbal(
+ message: Message,
+ command: CommandObject,
+ db: Database,
+) -> None:
+ args = (command.args or "").split()
+ if len(args) != 2:
+ await message.answer(
+ "Формат: /addbal TELEGRAM_ID СУММА\n"
+ "Пример: /addbal 123456789 500",
+ parse_mode="HTML",
+ )
+ return
+ try:
+ target_id = int(args[0])
+ amount = float(args[1])
+ except ValueError:
+ await message.answer("ID и сумма должны быть числами.")
+ return
+ if amount == 0:
+ await message.answer("Сумма не должна быть 0.")
+ return
+
+ admin = message.from_user
+ balance = db.add_balance(
+ target_id,
+ amount,
+ kind="admin_topup",
+ meta=f"by:{admin.id if admin else '?'}",
+ )
+ await message.answer(
+ f"✅ Начислено {amount:.0f} ₽ → {target_id}\n"
+ f"Баланс: {balance:.0f} ₽",
+ parse_mode="HTML",
+ )
+ try:
+ if message.bot:
+ await message.bot.send_message(
+ target_id,
+ f"💰 Баланс пополнен на {amount:.0f} ₽\n"
+ f"Доступно: {balance:.0f} ₽",
+ parse_mode="HTML",
+ )
+ except Exception: # noqa: BLE001
+ pass
diff --git a/handlers_shop.py b/handlers_shop.py
new file mode 100644
index 0000000..1f2362c
--- /dev/null
+++ b/handlers_shop.py
@@ -0,0 +1,1723 @@
+"""Баланс, тарифы, оплата с баланса и криптой."""
+
+import time
+import uuid
+
+from aiogram import F, Router
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import CallbackQuery, LabeledPrice, Message, PreCheckoutQuery
+from remnawave import RemnawaveSDK
+
+from keyboards import (
+ device_confirm_inline,
+ device_packs_inline,
+ digiseller_cancel_inline,
+ digiseller_inline,
+ invoice_inline,
+ renew_confirm_inline,
+ renew_tariffs_inline,
+ stars_amounts_inline,
+ subscription_inline,
+ tariffs_inline,
+ topup_amounts_inline,
+ topup_code_inline,
+ topup_providers_inline,
+ traffic_confirm_inline,
+ traffic_packs_inline,
+)
+from config import Settings
+from services.anypay import Anypay, AnypayError
+from services.cryptopay import CryptoPay, CryptoPayError
+from services.db import Database
+from services.devices import (
+ BASE_DEVICES,
+ EXTRA_DEVICE_PRICE_RUB,
+ get_device_pack,
+)
+from services.digiseller import Digiseller, DigisellerError
+from services.digiseller_fulfill import fulfill_digiseller_unique_code
+from services.heleket import Heleket, HeleketError
+from services.lava import Lava, LavaError
+from services.payments import fulfill_paid_invoice
+from services.purchase import (
+ get_my_subscription_info,
+ purchase_device_pack,
+ purchase_tariff,
+ purchase_traffic_pack,
+)
+from services.stars import create_stars_topup, parse_stars_payload
+from services.tariffs import Tariff, get_tariff
+from services.topup_code_redeem import TopupCodeError, redeem_topup_code
+from services.traffic import (
+ BASE_TRAFFIC_GB,
+ PACK_GB,
+ PACK_PRICE_RUB,
+ format_gb,
+ get_traffic_pack,
+)
+from services.transactions_ui import transaction_label
+
+router = Router(name="shop")
+
+
+class DigisellerStates(StatesGroup):
+ waiting_code = State()
+
+
+class TopupCodeStates(StatesGroup):
+ waiting_code = State()
+
+
+def _any_crypto(cryptopay: CryptoPay, heleket: Heleket) -> bool:
+ return cryptopay.enabled or heleket.enabled
+
+
+def _pay_kb(
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller | None = None,
+ lava: Lava | None = None,
+ tariff_id: str | None = None,
+ *,
+ anypay: Anypay | None = None,
+ settings: Settings | None = None,
+ stars_enabled: bool | None = None,
+):
+ from keyboards import balance_inline, tariff_pay_methods_inline
+
+ digi_on = bool(digiseller and digiseller.enabled)
+ lava_on = bool(lava and lava.enabled)
+ anypay_on = bool(anypay and anypay.enabled)
+ if stars_enabled is None:
+ stars_on = bool(settings.stars_enabled) if settings else True
+ else:
+ stars_on = bool(stars_enabled)
+ if tariff_id:
+ return tariff_pay_methods_inline(
+ tariff_id,
+ crypto_enabled=cryptopay.enabled,
+ heleket_enabled=heleket.enabled,
+ lava_enabled=lava_on,
+ anypay_enabled=anypay_on,
+ )
+ return balance_inline(
+ crypto_enabled=cryptopay.enabled,
+ heleket_enabled=heleket.enabled,
+ digiseller_enabled=digi_on,
+ lava_enabled=lava_on,
+ anypay_enabled=anypay_on,
+ stars_enabled=stars_on,
+ )
+
+
+def _tariff_card(t: Tariff, balance: float) -> str:
+ traffic = format_gb(t.traffic_gb if t.traffic_gb > 0 else BASE_TRAFFIC_GB, digits=0)
+ return (
+ f"🛒 {t.title}\n\n"
+ f"{t.description}\n\n"
+ f"⏱ Срок: {t.days} дн.\n"
+ f"📦 Трафик: {traffic} (база)\n"
+ f"📱 Устройств: {BASE_DEVICES} (база)\n"
+ f"💵 Цена: {t.format_price()}\n\n"
+ f"Твой баланс: {balance:.0f} ₽\n\n"
+ f"Докупка трафика: +{format_gb(PACK_GB, digits=0)} = {int(PACK_PRICE_RUB)} ₽\n"
+ f"Доп. устройство: +1 = {int(EXTRA_DEVICE_PRICE_RUB)} ₽ "
+ f"(при превышении подписка отключается)\n"
+ "Оплата: с баланса или криптой (Crypto Bot / Heleket)."
+ )
+
+
+def _safe_order_id(raw: str) -> str:
+ cleaned = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in raw)
+ return cleaned[:128]
+
+
+@router.message(F.text == "💰 Баланс")
+async def cmd_balance(
+ message: Message,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+ anypay: Anypay,
+ settings: Settings,
+) -> None:
+ user = message.from_user
+ assert user
+ bot_user = db.ensure_user(
+ user.id,
+ username=user.username,
+ full_name=user.full_name,
+ )
+ txs = db.recent_transactions(user.id, limit=5)
+ lines = [
+ "💰 Баланс\n",
+ f"Доступно: {bot_user.balance:.0f} ₽\n",
+ "Пополнить: Stars, картой/СБП (Lava / AnyPay), криптой или кодом.",
+ ]
+ if txs:
+ lines.append("\nПоследние операции:")
+ for tx in txs:
+ sign = "+" if tx["amount"] > 0 else ""
+ label = transaction_label(str(tx["kind"]), tx.get("meta"))
+ lines.append(f"• {sign}{float(tx['amount']):.0f} ₽ · {label}")
+ await message.answer(
+ "\n".join(lines),
+ reply_markup=_pay_kb(
+ cryptopay, heleket, digiseller, lava, anypay=anypay, settings=settings
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:balance")
+async def cb_pay_balance(
+ callback: CallbackQuery,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+ anypay: Anypay,
+ state: FSMContext,
+ settings: Settings,
+) -> None:
+ await state.clear()
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ balance = db.get_balance(user.id)
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ f"💰 Баланс\n\nДоступно: {balance:.0f} ₽",
+ reply_markup=_pay_kb(
+ cryptopay, heleket, digiseller, lava, anypay=anypay, settings=settings
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:stars")
+async def cb_pay_stars(callback: CallbackQuery, settings: Settings) -> None:
+ if not settings.stars_enabled:
+ await callback.answer("Stars выключены", show_alert=True)
+ return
+ await callback.answer()
+ if callback.message:
+ rate = settings.stars_rub_per_star
+ await callback.message.edit_text(
+ "⭐ Пополнение через Telegram Stars\n\n"
+ f"Курс: 1 ⭐ ≈ {rate:g} ₽ на баланс.\n"
+ "Выбери сумму — оплата прямо в Telegram, без внешних сайтов.",
+ reply_markup=stars_amounts_inline(rub_per_star=rate),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("pay:stars_amount:"))
+async def cb_pay_stars_amount(
+ callback: CallbackQuery,
+ db: Database,
+ settings: Settings,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+) -> None:
+ if not settings.stars_enabled:
+ await callback.answer("Stars выключены", show_alert=True)
+ return
+ user = callback.from_user
+ if not user or not callback.message:
+ await callback.answer()
+ return
+ try:
+ amount = float((callback.data or "").split(":")[-1])
+ except ValueError:
+ await callback.answer("Неверная сумма", show_alert=True)
+ return
+ if amount <= 0:
+ await callback.answer("Неверная сумма", show_alert=True)
+ return
+
+ await callback.answer("Счёт Stars…")
+ try:
+ created = await create_stars_topup(
+ bot=callback.bot,
+ db=db,
+ telegram_id=user.id,
+ amount_rub=amount,
+ rub_per_star=settings.stars_rub_per_star,
+ )
+ # Чат (включая Telegram Web): инвойс + запасная ссылка
+ await callback.message.answer_invoice(
+ title="Пополнение баланса",
+ description=(
+ f"Зачисление {created['rub_label']} на баланс VPN Service "
+ f"({created['stars_label']})"
+ ),
+ payload=created["payload"],
+ currency="XTR",
+ prices=[
+ LabeledPrice(
+ label=f"Баланс {created['rub_label']}",
+ amount=int(created["stars"]),
+ )
+ ],
+ provider_token="",
+ )
+ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+
+ await callback.message.answer(
+ f"Счёт на {created['stars_label']} → "
+ f"{created['rub_label']} на баланс.\n\n"
+ "Оплати инвойс выше или кнопку ниже "
+ "(удобно в Telegram Web / мобильном).",
+ parse_mode="HTML",
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=f"⭐ Оплатить {created['stars_label']}",
+ url=created["invoice_link"],
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text="💰 Баланс",
+ callback_data="pay:balance",
+ )
+ ],
+ ]
+ ),
+ )
+ except Exception as exc: # noqa: BLE001
+ await callback.message.answer(
+ f"Не удалось создать счёт Stars.\n{exc}",
+ parse_mode="HTML",
+ reply_markup=_pay_kb(
+ cryptopay, heleket, digiseller, lava, settings=settings
+ ),
+ )
+
+
+@router.pre_checkout_query()
+async def on_pre_checkout(query: PreCheckoutQuery) -> None:
+ parsed = parse_stars_payload(query.invoice_payload or "")
+ if not parsed:
+ await query.answer(ok=False, error_message="Неверный платёж")
+ return
+ await query.answer(ok=True)
+
+
+@router.message(F.successful_payment)
+async def on_successful_payment(
+ message: Message,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+ settings: Settings,
+) -> None:
+ payment = message.successful_payment
+ if not payment or payment.currency != "XTR":
+ return
+ user = message.from_user
+ if not user:
+ return
+
+ parsed = parse_stars_payload(payment.invoice_payload or "")
+ if not parsed:
+ await message.answer("Оплата получена, но payload не распознан. Напиши админу.")
+ return
+
+ invoice_id, telegram_id, amount_rub = parsed
+ if telegram_id != user.id:
+ await message.answer("Оплата не совпала с аккаунтом. Напиши админу.")
+ return
+
+ local = db.get_invoice(invoice_id, provider="stars")
+ if not local:
+ # fallback: credit anyway once, record meta by charge id
+ charge = payment.telegram_payment_charge_id
+ balance = db.add_balance(
+ user.id,
+ amount_rub,
+ kind="stars_topup",
+ meta=f"stars:orphan:{charge}",
+ )
+ await message.answer(
+ f"✅ Баланс пополнен\n\n"
+ f"+{amount_rub:.0f} ₽ через Telegram Stars\n"
+ f"Доступно: {balance:.0f} ₽",
+ parse_mode="HTML",
+ reply_markup=_pay_kb(
+ cryptopay, heleket, digiseller, lava, settings=settings
+ ),
+ )
+ return
+
+ if local.get("status") == "paid":
+ await message.answer(
+ "Этот платёж уже зачислен.",
+ reply_markup=_pay_kb(
+ cryptopay, heleket, digiseller, lava, settings=settings
+ ),
+ )
+ return
+
+ if not db.mark_invoice_paid(invoice_id, provider="stars"):
+ await message.answer(
+ "Этот платёж уже зачислен.",
+ reply_markup=_pay_kb(
+ cryptopay, heleket, digiseller, lava, settings=settings
+ ),
+ )
+ return
+
+ amount = float(local["amount_rub"])
+ charge = payment.telegram_payment_charge_id
+ balance = db.add_balance(
+ user.id,
+ amount,
+ kind="stars_topup",
+ meta=f"stars:{invoice_id}:{charge}",
+ )
+ await message.answer(
+ f"✅ Баланс пополнен\n\n"
+ f"+{amount:.0f} ₽ через Telegram Stars ({payment.total_amount} ⭐)\n"
+ f"Доступно: {balance:.0f} ₽",
+ parse_mode="HTML",
+ reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava, settings=settings),
+ )
+
+
+@router.callback_query(F.data == "pay:digiseller")
+async def cb_pay_digiseller(
+ callback: CallbackQuery,
+ digiseller: Digiseller,
+ state: FSMContext,
+) -> None:
+ if not digiseller.enabled:
+ await callback.answer("Digiseller не настроен", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ await state.clear()
+ await callback.answer()
+ pay_url = digiseller.pay_url_for(user.id)
+ if callback.message:
+ await callback.message.edit_text(
+ "💳 Пополнение через Digiseller\n\n"
+ "1) Нажми «Оплатить на Digiseller»\n"
+ "2) Оплати товар на сайте\n"
+ "3) Digiseller сам перенаправит на проверку — "
+ "баланс зачислится автоматически\n"
+ "4) В бот придёт уведомление о пополнении\n\n"
+ "Код вводить не нужно. Ручной ввод — только если автозачисление "
+ "не сработало.",
+ reply_markup=digiseller_inline(pay_url=pay_url),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:digiseller_code")
+async def cb_pay_digiseller_code(
+ callback: CallbackQuery,
+ digiseller: Digiseller,
+ state: FSMContext,
+) -> None:
+ if not digiseller.enabled:
+ await callback.answer("Digiseller не настроен", show_alert=True)
+ return
+ await state.set_state(DigisellerStates.waiting_code)
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "🔑 Ручной ввод кода Digiseller\n\n"
+ "Нужен только если автозачисление не сработало.\n"
+ "Отправь уникальный код сообщением.",
+ reply_markup=digiseller_cancel_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:digiseller_cancel")
+async def cb_pay_digiseller_cancel(
+ callback: CallbackQuery,
+ state: FSMContext,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+) -> None:
+ await state.clear()
+ user = callback.from_user
+ balance = db.get_balance(user.id) if user else 0
+ await callback.answer("Отменено")
+ if callback.message:
+ await callback.message.edit_text(
+ f"💰 Баланс\n\nДоступно: {balance:.0f} ₽",
+ reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava),
+ parse_mode="HTML",
+ )
+
+
+@router.message(DigisellerStates.waiting_code)
+async def digiseller_got_code(
+ message: Message,
+ state: FSMContext,
+ db: Database,
+ digiseller: Digiseller,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+) -> None:
+ user = message.from_user
+ if not user:
+ return
+ code = (message.text or "").strip()
+ if not code or code.startswith("/"):
+ await message.answer(
+ "Пришли уникальный код Digiseller или нажми «Отмена».",
+ reply_markup=digiseller_cancel_inline(),
+ )
+ return
+
+ wait = await message.answer("⏳ Проверяю уникальный код…")
+ try:
+ result = await fulfill_digiseller_unique_code(
+ db=db,
+ digiseller=digiseller,
+ unique_code=code,
+ telegram_id_hint=user.id,
+ bot=None,
+ notify=False,
+ )
+ await state.clear()
+ await wait.edit_text(
+ result.message,
+ reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava),
+ parse_mode="HTML",
+ )
+ except DigisellerError as exc:
+ await wait.edit_text(
+ f"Не удалось проверить код.\n{exc}\n\n"
+ "Проверь код и попробуй ещё раз, либо нажми «Отмена».",
+ reply_markup=digiseller_cancel_inline(),
+ parse_mode="HTML",
+ )
+ except Exception as exc: # noqa: BLE001
+ await wait.edit_text(
+ f"Ошибка проверки.\n{exc}",
+ reply_markup=digiseller_cancel_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:topup_code")
+async def cb_pay_topup_code(
+ callback: CallbackQuery,
+ state: FSMContext,
+ settings: Settings,
+) -> None:
+ await state.set_state(TopupCodeStates.waiting_code)
+ await callback.answer()
+ pay_url = settings.topup_code_pay_url
+ lines = [
+ "🎫 Пополнение по коду\n",
+ "Для карты и СБП — покупай код (на странице оплаты "
+ "более 40 способов).\n",
+ "Напрямую в боте без кода — только крипта.\n",
+ "Отправь код сообщением (XXXX-XXXX-XXXX-XXXX). Один код — одно пополнение.",
+ ]
+ if pay_url:
+ lines.append("\nКупить код — кнопка ниже.")
+ if callback.message:
+ await callback.message.edit_text(
+ "\n".join(lines),
+ reply_markup=topup_code_inline(pay_url=pay_url),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:topup_code_cancel")
+async def cb_pay_topup_code_cancel(
+ callback: CallbackQuery,
+ state: FSMContext,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+) -> None:
+ await state.clear()
+ user = callback.from_user
+ balance = db.get_balance(user.id) if user else 0
+ await callback.answer("Отменено")
+ if callback.message:
+ await callback.message.edit_text(
+ f"💰 Баланс\n\nДоступно: {balance:.0f} ₽",
+ reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava),
+ parse_mode="HTML",
+ )
+
+
+@router.message(TopupCodeStates.waiting_code)
+async def topup_code_got_code(
+ message: Message,
+ state: FSMContext,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ digiseller: Digiseller,
+ lava: Lava,
+) -> None:
+ user = message.from_user
+ if not user:
+ return
+ code = (message.text or "").strip()
+ if not code or code.startswith("/"):
+ await message.answer(
+ "Пришли код пополнения или нажми «Отмена».",
+ reply_markup=topup_code_inline(),
+ )
+ return
+
+ wait = await message.answer("⏳ Проверяю код…")
+ try:
+ result = redeem_topup_code(db, telegram_id=user.id, raw_code=code)
+ await state.clear()
+ await wait.edit_text(
+ result.message,
+ reply_markup=_pay_kb(cryptopay, heleket, digiseller, lava),
+ parse_mode="HTML",
+ )
+ except TopupCodeError as exc:
+ await wait.edit_text(
+ f"Не удалось активировать код.\n{exc}",
+ reply_markup=topup_code_inline(),
+ parse_mode="HTML",
+ )
+ except Exception as exc: # noqa: BLE001
+ await wait.edit_text(
+ f"Ошибка.\n{exc}",
+ reply_markup=topup_code_inline(),
+ parse_mode="HTML",
+ )
+
+
+
+@router.callback_query(F.data == "pay:lava")
+async def cb_pay_lava(callback: CallbackQuery, lava: Lava) -> None:
+ if not lava.enabled:
+ await callback.answer("Lava не настроен", show_alert=True)
+ return
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "💳 Пополнение через Lava\n\n"
+ "Карта / СБП. Выбери сумму — после оплаты баланс зачислится автоматически.",
+ reply_markup=topup_amounts_inline("lava"),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:anypay")
+async def cb_pay_anypay(callback: CallbackQuery, anypay: Anypay) -> None:
+ if not anypay.enabled:
+ await callback.answer("AnyPay не настроен", show_alert=True)
+ return
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "💳 Пополнение через AnyPay\n\n"
+ "Карта, СБП, крипта и др. Сумма от 10 до 3000 ₽. "
+ "После оплаты баланс зачислится автоматически.",
+ reply_markup=topup_amounts_inline("anypay"),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("shop:buy_lava:"))
+async def cb_shop_buy_lava(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ lava: Lava,
+) -> None:
+ if not lava.enabled:
+ await callback.answer("Lava не настроен", show_alert=True)
+ return
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+
+ await callback.answer("Создаю счёт…")
+ order_id = _safe_order_id(f"tariff_{tariff.id}_{user.id}_{int(time.time())}")
+ try:
+ inv = await lava.create_invoice(
+ amount_rub=float(tariff.price),
+ order_id=order_id,
+ comment=f"Tariff {tariff.id} tg={user.id}",
+ custom_fields=str(user.id),
+ )
+ except LavaError as exc:
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось создать счёт Lava.\n{exc}",
+ parse_mode="HTML",
+ )
+ return
+ local_id = db.next_local_invoice_id()
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=local_id,
+ kind="tariff",
+ amount_rub=float(tariff.price),
+ pay_url=inv.pay_url,
+ payload=None,
+ provider="lava",
+ tariff_id=tariff.id,
+ order_id=order_id,
+ external_uuid=inv.invoice_id,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ f"💳 Счёт Lava · {tariff.title}\n\n"
+ f"Сумма: {tariff.format_price()}\n"
+ "Оплати картой или СБП — тариф активируется автоматически.",
+ reply_markup=invoice_inline(local_id, inv.pay_url, provider="lava"),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("shop:buy_anypay:"))
+async def cb_shop_buy_anypay(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ anypay: Anypay,
+) -> None:
+ if not anypay.enabled:
+ await callback.answer("AnyPay не настроен", show_alert=True)
+ return
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ if float(tariff.price) < 10 or float(tariff.price) > 3000:
+ await callback.answer(
+ "Для тарифа вне 10–3000 ₽ оплатите с баланса или другим способом",
+ show_alert=True,
+ )
+ return
+
+ await callback.answer("Создаю счёт…")
+ local_id = db.next_local_invoice_id()
+ pay_id = str(local_id)
+ try:
+ inv = await anypay.create_invoice(
+ pay_id=pay_id,
+ amount_rub=float(tariff.price),
+ desc=f"Tariff {tariff.id} tg={user.id}",
+ )
+ except AnypayError as exc:
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось создать счёт AnyPay.\n{exc}",
+ parse_mode="HTML",
+ )
+ return
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=local_id,
+ kind="tariff",
+ amount_rub=float(tariff.price),
+ pay_url=inv.pay_url,
+ payload=pay_id,
+ provider="anypay",
+ tariff_id=tariff.id,
+ order_id=pay_id,
+ external_uuid=inv.transaction_id,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ f"💳 Счёт AnyPay · {tariff.title}\n\n"
+ f"Сумма: {tariff.format_price()}\n"
+ "Оплати на странице AnyPay — тариф активируется автоматически.",
+ reply_markup=invoice_inline(local_id, inv.pay_url, provider="anypay"),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("pay:check_lava:"))
+async def cb_pay_check_lava(
+ callback: CallbackQuery,
+ db: Database,
+ sdk: RemnawaveSDK,
+ tariffs: list[Tariff],
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+) -> None:
+ if not lava.enabled:
+ await callback.answer("Lava не настроен", show_alert=True)
+ return
+ invoice_id = int((callback.data or "").split(":")[-1])
+ local = db.get_invoice(invoice_id, provider="lava")
+ if not local:
+ await callback.answer("Счёт не найден", show_alert=True)
+ return
+ if local["telegram_id"] != (callback.from_user.id if callback.from_user else 0):
+ await callback.answer("Это не твой счёт", show_alert=True)
+ return
+
+ await callback.answer("Проверяю…")
+ order_id = str(local.get("order_id") or "")
+ external = str(local.get("external_uuid") or "")
+ try:
+ remote = await lava.get_invoice(
+ order_id=order_id or None,
+ invoice_id=external or None,
+ )
+ except LavaError as exc:
+ if callback.message:
+ await callback.message.answer(
+ f"Ошибка проверки: {exc}", parse_mode="HTML"
+ )
+ return
+
+ status = remote.status if remote else "unknown"
+ if remote is None or not lava.is_paid(status):
+ await callback.answer(f"Ещё не оплачено ({status})", show_alert=True)
+ return
+
+ result = await fulfill_paid_invoice(db=db, sdk=sdk, tariffs=tariffs, local=local)
+ if callback.message:
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=_pay_kb(cryptopay, heleket, lava=lava),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "pay:topup")
+async def cb_pay_topup(
+ callback: CallbackQuery,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+) -> None:
+ if not _any_crypto(cryptopay, heleket):
+ await callback.answer("Крипто-оплата не настроена", show_alert=True)
+ return
+ await callback.answer()
+ if not callback.message:
+ return
+
+ if cryptopay.enabled and not heleket.enabled:
+ await callback.message.edit_text(
+ "🪙 Пополнение криптой\n\n"
+ "Без кода — только криптовалюта. Карта и СБП — через "
+ "код пополнения (40+ способов на странице покупки).\n\n"
+ "Выбери сумму в рублях.\n"
+ "Оплата через @CryptoBot (USDT / TON / BTC…).",
+ reply_markup=topup_amounts_inline("cryptobot"),
+ parse_mode="HTML",
+ )
+ return
+ if heleket.enabled and not cryptopay.enabled:
+ await callback.message.edit_text(
+ "🪙 Пополнение криптой\n\n"
+ "Без кода — только криптовалюта. Карта и СБП — код пополнения.\n\n"
+ "Выбери сумму в рублях.\n"
+ "Оплата через Heleket.",
+ reply_markup=topup_amounts_inline("heleket"),
+ parse_mode="HTML",
+ )
+ return
+
+ await callback.message.edit_text(
+ "🪙 Пополнение криптой\n\n"
+ "Напрямую без кода — только крипта. Карта / СБП — «Код пополнения».\n\n"
+ "Выбери платёжку:\n"
+ "• Crypto Bot — основная\n"
+ "• Heleket — резервная",
+ reply_markup=topup_providers_inline(
+ crypto_enabled=cryptopay.enabled,
+ heleket_enabled=heleket.enabled,
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("pay:topup_provider:"))
+async def cb_pay_topup_provider(callback: CallbackQuery) -> None:
+ provider = (callback.data or "").split(":")[-1]
+ label = {
+ "cryptobot": "Crypto Bot",
+ "heleket": "Heleket",
+ "lava": "Lava",
+ "anypay": "AnyPay",
+ }.get(provider, provider)
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ f"🪙 Пополнение через {label}\n\nВыбери сумму в рублях.",
+ reply_markup=topup_amounts_inline(provider),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("pay:topup_amount:"))
+async def cb_pay_topup_amount(
+ callback: CallbackQuery,
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+ anypay: Anypay,
+) -> None:
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ parts = (callback.data or "").split(":")
+ if len(parts) >= 4:
+ provider = parts[2]
+ amount = float(parts[3])
+ else:
+ provider = "cryptobot"
+ amount = float(parts[-1])
+
+ if amount < 10 or amount > 3000:
+ await callback.answer("Сумма от 10 до 3000 ₽", show_alert=True)
+ return
+
+ await callback.answer("Создаю счёт…")
+ try:
+ if provider == "lava":
+ if not lava.enabled:
+ await callback.answer("Lava не настроен", show_alert=True)
+ return
+ order_id = _safe_order_id(f"topup_{user.id}_{int(amount)}_{int(time.time())}")
+ inv = await lava.create_invoice(
+ amount_rub=amount,
+ order_id=order_id,
+ comment=f"Topup {int(amount)} RUB tg={user.id}",
+ custom_fields=str(user.id),
+ )
+ local_id = db.next_local_invoice_id()
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ payload=None,
+ provider="lava",
+ order_id=order_id,
+ external_uuid=inv.invoice_id,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ f"💳 Счёт Lava на {amount:.0f} ₽\n\n"
+ "Оплати картой или СБП. Баланс зачислится автоматически.",
+ reply_markup=invoice_inline(local_id, inv.pay_url, provider="lava"),
+ parse_mode="HTML",
+ )
+ return
+ if provider == "anypay":
+ if not anypay.enabled:
+ await callback.answer("AnyPay не настроен", show_alert=True)
+ return
+ local_id = db.next_local_invoice_id()
+ pay_id = str(local_id)
+ inv = await anypay.create_invoice(
+ pay_id=pay_id,
+ amount_rub=amount,
+ desc=f"Topup {int(amount)} RUB tg={user.id}",
+ )
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ payload=pay_id,
+ provider="anypay",
+ order_id=pay_id,
+ external_uuid=inv.transaction_id,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ f"💳 Счёт AnyPay на {amount:.0f} ₽\n\n"
+ "Оплати на странице AnyPay. Баланс зачислится автоматически.",
+ reply_markup=invoice_inline(local_id, inv.pay_url, provider="anypay"),
+ parse_mode="HTML",
+ )
+ return
+ if provider == "heleket":
+ if not heleket.enabled:
+ await callback.answer("Heleket не настроен", show_alert=True)
+ return
+ order_id = _safe_order_id(f"topup_{user.id}_{int(amount)}_{int(time.time())}")
+ inv = await heleket.create_invoice(
+ amount_rub=amount,
+ order_id=order_id,
+ description=f"Topup {int(amount)} RUB tg={user.id}",
+ )
+ local_id = db.next_local_invoice_id()
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ payload=order_id,
+ provider="heleket",
+ order_id=order_id,
+ external_uuid=inv.uuid,
+ )
+ pay_url = inv.pay_url
+ invoice_id = local_id
+ how = "Heleket"
+ else:
+ if not cryptopay.enabled:
+ await callback.answer("Crypto Bot не настроен", show_alert=True)
+ return
+ payload = f"topup:{user.id}:{int(amount)}"
+ invoice = await cryptopay.create_fiat_invoice(
+ amount_rub=amount,
+ description=f"Пополнение баланса на {int(amount)} ₽",
+ payload=payload,
+ )
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=invoice.invoice_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=invoice.pay_url,
+ payload=payload,
+ provider="cryptobot",
+ )
+ pay_url = invoice.pay_url
+ invoice_id = invoice.invoice_id
+ how = "Crypto Bot"
+ except (CryptoPayError, HeleketError, AnypayError) as exc:
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось создать счёт.\n{exc}",
+ parse_mode="HTML",
+ )
+ return
+
+ if callback.message:
+ await callback.message.edit_text(
+ f"🪙 Счёт на {int(amount)} ₽ ({how})\n\n"
+ "1) Нажми «Оплатить»\n"
+ "2) Оплати криптой\n"
+ "3) Вернись и нажми «Я оплатил»",
+ reply_markup=invoice_inline(invoice_id, pay_url, provider=provider),
+ parse_mode="HTML",
+ )
+
+
+@router.message(F.text == "🛒 Тарифы")
+async def cmd_tariffs(message: Message, tariffs: list[Tariff]) -> None:
+ await message.answer(
+ "🛒 Тарифы\n\nВыбери подходящий план:",
+ reply_markup=tariffs_inline(tariffs),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "shop:list")
+async def cb_shop_list(callback: CallbackQuery, tariffs: list[Tariff]) -> None:
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "🛒 Тарифы\n\nВыбери подходящий план:",
+ reply_markup=tariffs_inline(tariffs),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("shop:view:"))
+async def cb_shop_view(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+) -> None:
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ balance = db.get_balance(user.id) if user else 0
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ _tariff_card(tariff, balance),
+ reply_markup=_pay_kb(cryptopay, heleket, tariff_id=tariff.id, lava=lava),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("shop:buy_balance:"))
+async def cb_shop_buy_balance(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ sdk: RemnawaveSDK,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+) -> None:
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer("Ошибка пользователя", show_alert=True)
+ return
+
+ await callback.answer("Оформляю…")
+ db.ensure_user(user.id, username=user.username, full_name=user.full_name)
+ result = await purchase_tariff(
+ db=db,
+ sdk=sdk,
+ telegram_id=user.id,
+ tariff=tariff,
+ charge_balance=True,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=(
+ _pay_kb(cryptopay, heleket, lava=lava)
+ if result.ok
+ else _pay_kb(cryptopay, heleket, tariff_id=tariff.id, lava=lava)
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("shop:buy_crypto:"))
+async def cb_shop_buy_crypto(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ cryptopay: CryptoPay,
+) -> None:
+ if not cryptopay.enabled:
+ await callback.answer("Крипто-оплата не настроена", show_alert=True)
+ return
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+
+ await callback.answer("Создаю счёт…")
+ payload = f"tariff:{tariff.id}:{user.id}"
+ try:
+ invoice = await cryptopay.create_fiat_invoice(
+ amount_rub=tariff.price,
+ description=f"Тариф «{tariff.title}» — {tariff.format_price()}",
+ payload=payload,
+ )
+ except CryptoPayError as exc:
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось создать счёт.\n{exc}",
+ parse_mode="HTML",
+ )
+ return
+
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=invoice.invoice_id,
+ kind="tariff",
+ amount_rub=tariff.price,
+ pay_url=invoice.pay_url,
+ payload=payload,
+ tariff_id=tariff.id,
+ provider="cryptobot",
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ f"🪙 Оплата тарифа «{tariff.title}» (Crypto Bot)\n"
+ f"Сумма: {tariff.format_price()}\n\n"
+ "1) Нажми «Оплатить»\n"
+ "2) Оплати в Crypto Bot\n"
+ "3) Вернись и нажми «Я оплатил»",
+ reply_markup=invoice_inline(
+ invoice.invoice_id, invoice.pay_url, provider="cryptobot"
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("shop:buy_heleket:"))
+async def cb_shop_buy_heleket(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ heleket: Heleket,
+) -> None:
+ if not heleket.enabled:
+ await callback.answer("Heleket не настроен", show_alert=True)
+ return
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+
+ await callback.answer("Создаю счёт…")
+ order_id = _safe_order_id(
+ f"tariff_{tariff.id}_{user.id}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
+ )
+ try:
+ inv = await heleket.create_invoice(
+ amount_rub=tariff.price,
+ order_id=order_id,
+ description=f"Tariff {tariff.id} tg={user.id}",
+ )
+ except HeleketError as exc:
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось создать счёт Heleket.\n{exc}",
+ parse_mode="HTML",
+ )
+ return
+
+ local_id = db.next_local_invoice_id()
+ db.create_invoice_record(
+ telegram_id=user.id,
+ invoice_id=local_id,
+ kind="tariff",
+ amount_rub=tariff.price,
+ pay_url=inv.pay_url,
+ payload=order_id,
+ tariff_id=tariff.id,
+ provider="heleket",
+ order_id=order_id,
+ external_uuid=inv.uuid,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ f"🪙 Оплата тарифа «{tariff.title}» (Heleket)\n"
+ f"Сумма: {tariff.format_price()}\n\n"
+ "1) Нажми «Оплатить»\n"
+ "2) Оплати на странице Heleket\n"
+ "3) Вернись и нажми «Я оплатил» "
+ "(или дождись авто-зачисления)",
+ reply_markup=invoice_inline(local_id, inv.pay_url, provider="heleket"),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("pay:check:"))
+async def cb_pay_check(
+ callback: CallbackQuery,
+ db: Database,
+ sdk: RemnawaveSDK,
+ tariffs: list[Tariff],
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+) -> None:
+ if not cryptopay.enabled:
+ await callback.answer("Крипто-оплата не настроена", show_alert=True)
+ return
+ invoice_id = int((callback.data or "").split(":")[-1])
+ local = db.get_invoice(invoice_id, provider="cryptobot")
+ if not local:
+ await callback.answer("Счёт не найден", show_alert=True)
+ return
+ if local["telegram_id"] != (callback.from_user.id if callback.from_user else 0):
+ await callback.answer("Это не твой счёт", show_alert=True)
+ return
+
+ await callback.answer("Проверяю…")
+ try:
+ remote = await cryptopay.get_invoice(invoice_id)
+ except CryptoPayError as exc:
+ if callback.message:
+ await callback.message.answer(
+ f"Ошибка проверки: {exc}", parse_mode="HTML"
+ )
+ return
+
+ if remote is None or remote.status != "paid":
+ status = remote.status if remote else "unknown"
+ await callback.answer(f"Ещё не оплачено ({status})", show_alert=True)
+ return
+
+ result = await fulfill_paid_invoice(db=db, sdk=sdk, tariffs=tariffs, local=local)
+ if callback.message:
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=_pay_kb(cryptopay, heleket, lava=lava),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("pay:check_hk:"))
+async def cb_pay_check_heleket(
+ callback: CallbackQuery,
+ db: Database,
+ sdk: RemnawaveSDK,
+ tariffs: list[Tariff],
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+) -> None:
+ if not heleket.enabled:
+ await callback.answer("Heleket не настроен", show_alert=True)
+ return
+ invoice_id = int((callback.data or "").split(":")[-1])
+ local = db.get_invoice(invoice_id, provider="heleket")
+ if not local:
+ await callback.answer("Счёт не найден", show_alert=True)
+ return
+ if local["telegram_id"] != (callback.from_user.id if callback.from_user else 0):
+ await callback.answer("Это не твой счёт", show_alert=True)
+ return
+
+ await callback.answer("Проверяю…")
+ order_id = str(local.get("order_id") or "")
+ try:
+ remote = await heleket.get_invoice(
+ order_id=order_id or None,
+ uuid=str(local.get("external_uuid") or "") or None,
+ )
+ except HeleketError as exc:
+ if callback.message:
+ await callback.message.answer(
+ f"Ошибка проверки: {exc}", parse_mode="HTML"
+ )
+ return
+
+ if remote is None or not Heleket.is_paid(remote.status):
+ status = remote.status if remote else "unknown"
+ await callback.answer(f"Ещё не оплачено ({status})", show_alert=True)
+ return
+
+ result = await fulfill_paid_invoice(db=db, sdk=sdk, tariffs=tariffs, local=local)
+ if callback.message:
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=_pay_kb(cryptopay, heleket, lava=lava),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "traffic:list")
+async def cb_traffic_list(
+ callback: CallbackQuery,
+ sdk: RemnawaveSDK,
+ db: Database,
+ tariffs: list[Tariff],
+) -> None:
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs)
+ if not info.has_subscription:
+ await callback.answer("Сначала купи тариф", show_alert=True)
+ return
+ balance = db.get_balance(user.id)
+ traffic_line = info.traffic.format_line() if info.traffic else "—"
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "📦 Докупка трафика\n\n"
+ f"{traffic_line}\n"
+ f"Баланс: {balance:.0f} ₽\n\n"
+ f"База в тарифе: {format_gb(BASE_TRAFFIC_GB, digits=0)}\n"
+ f"Пакет: +{format_gb(PACK_GB, digits=0)} = {int(PACK_PRICE_RUB)} ₽/мес\n\n"
+ "⚠️ Доп. трафик оплачивается ежемесячно с баланса "
+ "(первый месяц — при покупке).\n\n"
+ "Выбери объём:",
+ reply_markup=traffic_packs_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("traffic:buy:"))
+async def cb_traffic_buy_view(
+ callback: CallbackQuery,
+ db: Database,
+) -> None:
+ packs = int((callback.data or "").split(":")[-1])
+ pack = get_traffic_pack(str(packs))
+ if not pack:
+ await callback.answer("Пакет не найден", show_alert=True)
+ return
+ user = callback.from_user
+ balance = db.get_balance(user.id) if user else 0
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ f"📦 {pack.title}\n\n"
+ f"Добавится к текущему лимиту.\n"
+ f"Цена: {pack.format_price()} (первый месяц)\n"
+ f"Далее списание каждые 30 дней с баланса.\n"
+ f"Баланс: {balance:.0f} ₽",
+ reply_markup=traffic_confirm_inline(pack.packs),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("traffic:confirm:"))
+async def cb_traffic_confirm(
+ callback: CallbackQuery,
+ db: Database,
+ sdk: RemnawaveSDK,
+) -> None:
+ packs = int((callback.data or "").split(":")[-1])
+ pack = get_traffic_pack(str(packs))
+ if not pack:
+ await callback.answer("Пакет не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ await callback.answer("Покупаю…")
+ result = await purchase_traffic_pack(
+ db=db,
+ sdk=sdk,
+ telegram_id=user.id,
+ pack=pack,
+ charge_balance=True,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=(
+ subscription_inline(has_subscription=True)
+ if result.ok
+ else traffic_confirm_inline(pack.packs)
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "devices:list")
+async def cb_devices_list(
+ callback: CallbackQuery,
+ sdk: RemnawaveSDK,
+ db: Database,
+ tariffs: list[Tariff],
+) -> None:
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs)
+ if not info.has_subscription:
+ await callback.answer("Сначала купи тариф", show_alert=True)
+ return
+ balance = db.get_balance(user.id)
+ devices = info.devices
+ devices_line = devices.format_line() if devices else "—"
+ excess = devices.excess if devices else 0
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ "📱 Устройства\n\n"
+ f"{devices_line}\n"
+ f"Баланс: {balance:.0f} ₽\n\n"
+ f"База: {BASE_DEVICES} устройств в тарифе.\n"
+ f"Доп. слот: {int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес "
+ f"(первый месяц при покупке).\n"
+ "⚠️ Списание с баланса каждые 30 дней за все доп. устройства.\n"
+ "При превышении HWID подписка отключается до оплаты слотов.",
+ reply_markup=device_packs_inline(excess=excess),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("devices:buy:"))
+async def cb_devices_buy_view(
+ callback: CallbackQuery,
+ db: Database,
+) -> None:
+ slots = int((callback.data or "").split(":")[-1])
+ pack = get_device_pack(str(slots))
+ if not pack:
+ await callback.answer("Пакет не найден", show_alert=True)
+ return
+ user = callback.from_user
+ balance = db.get_balance(user.id) if user else 0
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ f"📱 {pack.title}\n\n"
+ f"Добавится к лимиту устройств.\n"
+ f"Цена: {pack.format_price()} (первый месяц)\n"
+ f"Далее — {int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес за слот с баланса.\n"
+ f"Баланс: {balance:.0f} ₽",
+ reply_markup=device_confirm_inline(pack.slots),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("devices:confirm:"))
+async def cb_devices_confirm(
+ callback: CallbackQuery,
+ db: Database,
+ sdk: RemnawaveSDK,
+) -> None:
+ slots = int((callback.data or "").split(":")[-1])
+ pack = get_device_pack(str(slots))
+ if not pack:
+ # оплата произвольного превышения excess N
+ from services.devices import DevicePack, EXTRA_DEVICE_PRICE_RUB as PRICE
+
+ pack = DevicePack(slots=slots, price=PRICE * slots)
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ await callback.answer("Покупаю…")
+ result = await purchase_device_pack(
+ db=db,
+ sdk=sdk,
+ telegram_id=user.id,
+ pack=pack,
+ charge_balance=True,
+ )
+ if callback.message:
+ over = bool(result.devices and result.devices.over_limit)
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=(
+ subscription_inline(has_subscription=True, devices_over=over)
+ if result.ok
+ else device_confirm_inline(pack.slots)
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.message(F.text == "📱 Моя подписка")
+async def cmd_my_sub(
+ message: Message, sdk: RemnawaveSDK, db: Database, tariffs: list[Tariff]
+) -> None:
+ user = message.from_user
+ if not user:
+ return
+ db.ensure_user(user.id, username=user.username, full_name=user.full_name)
+ wait = await message.answer("⏳ Загружаю…")
+ try:
+ info = await get_my_subscription_info(
+ sdk,
+ user.id,
+ db=db,
+ tariffs=tariffs,
+ renewal_bot=message.bot,
+ )
+ await wait.edit_text(
+ info.text,
+ reply_markup=subscription_inline(
+ has_subscription=info.has_subscription,
+ devices_over=bool(info.devices and info.devices.over_limit),
+ ),
+ parse_mode="HTML",
+ )
+ except Exception as exc: # noqa: BLE001
+ await wait.edit_text(
+ f"Не удалось получить подписку.\n{exc}",
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "sub:mine")
+async def cb_my_sub(
+ callback: CallbackQuery,
+ sdk: RemnawaveSDK,
+ db: Database,
+ tariffs: list[Tariff],
+) -> None:
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ await callback.answer()
+ try:
+ info = await get_my_subscription_info(
+ sdk,
+ user.id,
+ db=db,
+ tariffs=tariffs,
+ renewal_bot=callback.bot,
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ info.text,
+ reply_markup=subscription_inline(
+ has_subscription=info.has_subscription,
+ devices_over=bool(info.devices and info.devices.over_limit),
+ ),
+ parse_mode="HTML",
+ )
+ except Exception as exc: # noqa: BLE001
+ if callback.message:
+ await callback.message.edit_text(
+ f"Не удалось получить подписку.\n{exc}",
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "renew:list")
+async def cb_renew_list(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ sdk: RemnawaveSDK,
+ db: Database,
+) -> None:
+ user = callback.from_user
+ if not user:
+ await callback.answer()
+ return
+ info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs)
+ if not info.has_subscription:
+ await callback.answer("Сначала купи тариф", show_alert=True)
+ if callback.message:
+ await callback.message.edit_text(
+ info.text,
+ reply_markup=subscription_inline(has_subscription=False),
+ parse_mode="HTML",
+ )
+ return
+
+ balance = db.get_balance(user.id)
+ await callback.answer()
+ expire = (
+ info.expire_at.strftime("%d.%m.%Y %H:%M UTC")
+ if info.expire_at
+ else "—"
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ "🔄 Продление подписки\n\n"
+ f"Сейчас до: {expire}\n"
+ f"Баланс: {balance:.0f} ₽\n\n"
+ "Выбери срок — дни добавятся к текущей дате.\n"
+ "Оплата только с баланса.",
+ reply_markup=renew_tariffs_inline(tariffs),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("renew:view:"))
+async def cb_renew_view(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ sdk: RemnawaveSDK,
+) -> None:
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ balance = db.get_balance(user.id) if user else 0
+ info = await get_my_subscription_info(sdk, user.id, db=db, tariffs=tariffs) if user else None
+ expire = (
+ info.expire_at.strftime("%d.%m.%Y")
+ if info and info.expire_at
+ else "—"
+ )
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ f"🔄 Продлить: {tariff.title}\n\n"
+ f"{tariff.description}\n\n"
+ f"➕ Срок: +{tariff.days} дн.\n"
+ f"💵 Цена: {tariff.format_price()}\n"
+ f"📅 Сейчас до: {expire}\n"
+ f"💰 Баланс: {balance:.0f} ₽",
+ reply_markup=renew_confirm_inline(tariff.id),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("renew:buy:"))
+async def cb_renew_buy(
+ callback: CallbackQuery,
+ tariffs: list[Tariff],
+ db: Database,
+ sdk: RemnawaveSDK,
+) -> None:
+ tariff_id = (callback.data or "").split(":", 2)[2]
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ await callback.answer("Тариф не найден", show_alert=True)
+ return
+ user = callback.from_user
+ if not user:
+ await callback.answer("Ошибка пользователя", show_alert=True)
+ return
+
+ await callback.answer("Продлеваю…")
+ db.ensure_user(user.id, username=user.username, full_name=user.full_name)
+ result = await purchase_tariff(
+ db=db,
+ sdk=sdk,
+ telegram_id=user.id,
+ tariff=tariff,
+ charge_balance=True,
+ renew=True,
+ payment_meta=f"renew:{tariff.id}",
+ )
+ if callback.message:
+ await callback.message.edit_text(
+ result.message,
+ reply_markup=(
+ subscription_inline(has_subscription=True)
+ if result.ok
+ else renew_confirm_inline(tariff.id)
+ ),
+ parse_mode="HTML",
+ )
diff --git a/handlers_tickets.py b/handlers_tickets.py
new file mode 100644
index 0000000..39da07b
--- /dev/null
+++ b/handlers_tickets.py
@@ -0,0 +1,590 @@
+"""Тикеты поддержки — пользователь и админ в Telegram."""
+
+from __future__ import annotations
+
+import html
+
+from aiogram import Bot, F, Router
+from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
+from aiogram.types import CallbackQuery, Message
+
+from config import Settings
+from keyboards import (
+ admin_tickets_root_inline,
+ ticket_cancel_inline,
+ ticket_view_inline,
+ tickets_list_inline,
+ tickets_root_inline,
+)
+from services.db import Database
+from services.ticket_ai import try_ai_ticket_reply
+from services.tickets import (
+ BODY_MAX,
+ MAX_OPEN_PER_USER,
+ SUBJECT_MAX,
+ clamp_body,
+ clamp_subject,
+ format_admin_ticket_card,
+ format_ticket_card,
+ notify_admins_new_ticket,
+ notify_admins_ticket_reply,
+ notify_user_ticket_reply,
+ status_label,
+)
+
+router = Router(name="tickets")
+
+
+class TicketCreateStates(StatesGroup):
+ waiting_subject = State()
+ waiting_body = State()
+
+
+class TicketReplyStates(StatesGroup):
+ waiting_body = State()
+
+
+class AdminTicketReplyStates(StatesGroup):
+ waiting_body = State()
+
+
+TICKETS_INTRO = (
+ "🎫 Поддержка\n\n"
+ "Создай тикет — ответим здесь или в Mini App.\n"
+ f"Одновременно можно держать до {MAX_OPEN_PER_USER} открытых обращений."
+)
+
+
+def _admin_tickets_home_text(stats: dict) -> str:
+ return (
+ "🎫 Тикеты поддержки\n\n"
+ f"Открытых: {stats.get('open', 0)}\n"
+ f"С ответом: {stats.get('answered', 0)}\n"
+ f"Закрытых: {stats.get('closed', 0)}\n"
+ f"Всего: {stats.get('total', 0)}"
+ )
+
+
+# ─── User ───────────────────────────────────────────────────────────────────
+
+
+@router.message(F.text == "🎫 Тикеты")
+async def cmd_tickets(message: Message, state: FSMContext, db: Database) -> None:
+ await state.clear()
+ uid = message.from_user.id if message.from_user else 0
+ open_n = db.count_open_tickets(uid) if uid else 0
+ await message.answer(
+ TICKETS_INTRO,
+ reply_markup=tickets_root_inline(has_open=open_n > 0),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "tix:root")
+async def cb_tickets_root(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
+ await state.clear()
+ await callback.answer()
+ uid = callback.from_user.id if callback.from_user else 0
+ open_n = db.count_open_tickets(uid) if uid else 0
+ if callback.message:
+ await callback.message.edit_text(
+ TICKETS_INTRO,
+ reply_markup=tickets_root_inline(has_open=open_n > 0),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.in_({"tix:list", "tix:list:open"}))
+async def cb_tickets_list(callback: CallbackQuery, db: Database) -> None:
+ await callback.answer()
+ uid = callback.from_user.id if callback.from_user else 0
+ only_open = (callback.data or "").endswith(":open")
+ tickets = db.list_tickets_for_user(
+ uid,
+ limit=20,
+ include_closed=not only_open,
+ )
+ if not tickets:
+ text = "Открытых тикетов нет." if only_open else "Тикетов пока нет."
+ if callback.message:
+ await callback.message.edit_text(
+ text,
+ reply_markup=tickets_root_inline(has_open=False),
+ parse_mode="HTML",
+ )
+ return
+ title = "🟢 Открытые тикеты" if only_open else "📋 Мои тикеты"
+ if callback.message:
+ await callback.message.edit_text(
+ title + "\n\nВыбери обращение:",
+ reply_markup=tickets_list_inline(tickets),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("tix:view:"))
+async def cb_ticket_view(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
+ await state.clear()
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Некорректный тикет", show_alert=True)
+ return
+ ticket = db.get_ticket(tid)
+ uid = callback.from_user.id if callback.from_user else 0
+ if not ticket or int(ticket["telegram_id"]) != uid:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ await callback.answer()
+ messages = db.list_ticket_messages(tid)
+ text = format_ticket_card(ticket, messages)
+ if callback.message:
+ await callback.message.edit_text(
+ text,
+ reply_markup=ticket_view_inline(tid, status=str(ticket["status"])),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "tix:new")
+async def cb_ticket_new(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
+ uid = callback.from_user.id if callback.from_user else 0
+ if db.count_open_tickets(uid) >= MAX_OPEN_PER_USER:
+ await callback.answer(
+ f"Лимит: не больше {MAX_OPEN_PER_USER} открытых тикетов",
+ show_alert=True,
+ )
+ return
+ await callback.answer()
+ await state.set_state(TicketCreateStates.waiting_subject)
+ if callback.message:
+ await callback.message.edit_text(
+ f"Новый тикет\n\nНапиши тему (до {SUBJECT_MAX} символов):",
+ reply_markup=ticket_cancel_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.message(TicketCreateStates.waiting_subject)
+async def ticket_subject_msg(message: Message, state: FSMContext) -> None:
+ subject = clamp_subject(message.text or "")
+ if len(subject) < 3:
+ await message.answer("Тема слишком короткая. Минимум 3 символа.")
+ return
+ await state.update_data(subject=subject)
+ await state.set_state(TicketCreateStates.waiting_body)
+ await message.answer(
+ f"Тема: {html.escape(subject)}\n\n"
+ f"Опиши проблему (до {BODY_MAX} символов):",
+ reply_markup=ticket_cancel_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.message(TicketCreateStates.waiting_body)
+async def ticket_body_msg(
+ message: Message,
+ state: FSMContext,
+ db: Database,
+ settings: Settings,
+ bot: Bot,
+) -> None:
+ body = clamp_body(message.text or "")
+ if len(body) < 5:
+ await message.answer("Сообщение слишком короткое.")
+ return
+ data = await state.get_data()
+ subject = clamp_subject(str(data.get("subject") or ""))
+ await state.clear()
+ uid = message.from_user.id if message.from_user else 0
+ if not uid or not subject:
+ await message.answer("Не удалось создать тикет. Попробуй снова.")
+ return
+ if db.count_open_tickets(uid) >= MAX_OPEN_PER_USER:
+ await message.answer(f"Лимит открытых тикетов: {MAX_OPEN_PER_USER}.")
+ return
+ db.ensure_user(
+ uid,
+ username=message.from_user.username if message.from_user else None,
+ full_name=message.from_user.full_name if message.from_user else None,
+ )
+ ticket = db.create_ticket(uid, subject=subject, body=body)
+ tid = int(ticket["id"])
+ ai_result = await try_ai_ticket_reply(
+ db=db,
+ bot=bot,
+ settings=settings,
+ ticket_id=tid,
+ notify_user=False,
+ )
+ ai_answered = ai_result.get("action") == "answer" and ai_result.get("ok")
+ # Если ИИ уже ответил пользователю — админам короткое уведомление.
+ # Если эскалация — ticket_ai уже пинганул. Если skip — обычное уведомление.
+ if ai_result.get("action") in ("skip", "error") or ai_answered:
+ if ai_result.get("action") != "escalate":
+ await notify_admins_new_ticket(
+ bot,
+ settings,
+ ticket_id=tid,
+ telegram_id=uid,
+ subject=subject,
+ body=body,
+ ai_handled=bool(ai_answered),
+ )
+ ticket = db.get_ticket(tid) or ticket
+ status = str(ticket.get("status") or "open")
+ extra = ""
+ if ai_answered:
+ extra = "\n🤖 ИИ уже ответил — смотри переписку ниже."
+ await message.answer(
+ f"✅ Тикет #{tid} создан.{extra}\n"
+ "Мы ответим в этом чате или в Mini App.",
+ reply_markup=ticket_view_inline(tid, status=status),
+ parse_mode="HTML",
+ )
+ if ai_answered:
+ messages = db.list_ticket_messages(tid)
+ await message.answer(
+ format_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(tid, status=status),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("tix:reply:"))
+async def cb_ticket_reply(callback: CallbackQuery, state: FSMContext, db: Database) -> None:
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Ошибка", show_alert=True)
+ return
+ ticket = db.get_ticket(tid)
+ uid = callback.from_user.id if callback.from_user else 0
+ if not ticket or int(ticket["telegram_id"]) != uid:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ if str(ticket["status"]) == "closed":
+ await callback.answer("Тикет закрыт", show_alert=True)
+ return
+ await callback.answer()
+ await state.set_state(TicketReplyStates.waiting_body)
+ await state.update_data(ticket_id=tid)
+ if callback.message:
+ await callback.message.edit_text(
+ f"Ответ в тикет #{tid}\n\nНапиши сообщение:",
+ reply_markup=ticket_cancel_inline(back=f"tix:view:{tid}"),
+ parse_mode="HTML",
+ )
+
+
+@router.message(TicketReplyStates.waiting_body)
+async def ticket_reply_msg(
+ message: Message,
+ state: FSMContext,
+ db: Database,
+ settings: Settings,
+ bot: Bot,
+) -> None:
+ body = clamp_body(message.text or "")
+ if len(body) < 1:
+ await message.answer("Пустое сообщение.")
+ return
+ data = await state.get_data()
+ tid = int(data.get("ticket_id") or 0)
+ await state.clear()
+ uid = message.from_user.id if message.from_user else 0
+ ticket = db.get_ticket(tid)
+ if not ticket or int(ticket["telegram_id"]) != uid:
+ await message.answer("Тикет не найден.")
+ return
+ try:
+ ticket = db.add_ticket_message(
+ tid,
+ author_role="user",
+ author_id=uid,
+ body=body,
+ )
+ except ValueError as exc:
+ await message.answer(str(exc))
+ return
+ ai_result = await try_ai_ticket_reply(
+ db=db,
+ bot=bot,
+ settings=settings,
+ ticket_id=tid,
+ notify_user=False,
+ )
+ if ai_result.get("action") in ("skip", "error"):
+ await notify_admins_ticket_reply(
+ bot,
+ settings,
+ ticket_id=tid,
+ telegram_id=uid,
+ body=body,
+ )
+ ticket = db.get_ticket(tid) or ticket
+ messages = db.list_ticket_messages(tid)
+ await message.answer(
+ format_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(tid, status=str(ticket["status"])),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("tix:close:"))
+async def cb_ticket_close(callback: CallbackQuery, db: Database) -> None:
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Ошибка", show_alert=True)
+ return
+ ticket = db.get_ticket(tid)
+ uid = callback.from_user.id if callback.from_user else 0
+ if not ticket or int(ticket["telegram_id"]) != uid:
+ await callback.answer("Тикет не найден", show_alert=True)
+ return
+ if str(ticket["status"]) == "closed":
+ await callback.answer("Уже закрыт")
+ return
+ db.set_ticket_status(tid, "closed")
+ await callback.answer("Закрыто")
+ ticket = db.get_ticket(tid)
+ messages = db.list_ticket_messages(tid)
+ if callback.message and ticket:
+ await callback.message.edit_text(
+ format_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(tid, status="closed"),
+ parse_mode="HTML",
+ )
+
+
+# ─── Admin (same router; callbacks prefixed admin:tix / admin:tickets) ───────
+
+
+@router.callback_query(F.data == "admin:tickets")
+@router.callback_query(F.data.startswith("admin:tickets:"))
+async def cb_admin_tickets(callback: CallbackQuery, state: FSMContext, db: Database, settings: Settings) -> None:
+ if not callback.from_user or callback.from_user.id not in settings.admin_ids:
+ await callback.answer("Нет доступа", show_alert=True)
+ return
+ await state.clear()
+ await callback.answer()
+ data = callback.data or "admin:tickets"
+ if data == "admin:tickets":
+ stats = db.ticket_stats()
+ if callback.message:
+ await callback.message.edit_text(
+ _admin_tickets_home_text(stats),
+ reply_markup=admin_tickets_root_inline(stats),
+ parse_mode="HTML",
+ )
+ return
+
+ status = data.rsplit(":", 1)[-1]
+ if status not in ("open", "answered", "closed", "all"):
+ status = "open"
+ tickets, _total = db.list_tickets_admin(status=status, limit=20)
+ if not tickets:
+ if callback.message:
+ await callback.message.edit_text(
+ "Тикетов в этом фильтре нет.",
+ reply_markup=admin_tickets_root_inline(db.ticket_stats()),
+ parse_mode="HTML",
+ )
+ return
+ title = {
+ "open": "🟢 Открытые",
+ "answered": "💬 С ответом",
+ "closed": "🔒 Закрытые",
+ "all": "📋 Все тикеты",
+ }.get(status, "Тикеты")
+ if callback.message:
+ await callback.message.edit_text(
+ f"{title}\n\nВыбери тикет:",
+ reply_markup=tickets_list_inline(
+ tickets,
+ prefix="admin:tix:view",
+ back="admin:tickets",
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("admin:tix:view:"))
+async def cb_admin_ticket_view(
+ callback: CallbackQuery,
+ state: FSMContext,
+ db: Database,
+ settings: Settings,
+) -> None:
+ if not callback.from_user or callback.from_user.id not in settings.admin_ids:
+ await callback.answer("Нет доступа", show_alert=True)
+ return
+ await state.clear()
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Ошибка", show_alert=True)
+ return
+ ticket = db.get_ticket(tid)
+ if not ticket:
+ await callback.answer("Не найден", show_alert=True)
+ return
+ await callback.answer()
+ messages = db.list_ticket_messages(tid)
+ if callback.message:
+ await callback.message.edit_text(
+ format_admin_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(
+ tid,
+ status=str(ticket["status"]),
+ is_admin=True,
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("admin:tix:reply:"))
+async def cb_admin_ticket_reply(
+ callback: CallbackQuery,
+ state: FSMContext,
+ db: Database,
+ settings: Settings,
+) -> None:
+ if not callback.from_user or callback.from_user.id not in settings.admin_ids:
+ await callback.answer("Нет доступа", show_alert=True)
+ return
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Ошибка", show_alert=True)
+ return
+ ticket = db.get_ticket(tid)
+ if not ticket:
+ await callback.answer("Не найден", show_alert=True)
+ return
+ if str(ticket["status"]) == "closed":
+ await callback.answer("Тикет закрыт", show_alert=True)
+ return
+ await callback.answer()
+ await state.set_state(AdminTicketReplyStates.waiting_body)
+ await state.update_data(ticket_id=tid)
+ if callback.message:
+ await callback.message.edit_text(
+ f"Ответ в тикет #{tid}\n\n"
+ f"Пользователь: {ticket['telegram_id']}\n"
+ f"Статус: {status_label(str(ticket['status']))}\n\n"
+ "Напиши ответ:",
+ reply_markup=ticket_cancel_inline(back=f"admin:tix:view:{tid}"),
+ parse_mode="HTML",
+ )
+
+
+@router.message(AdminTicketReplyStates.waiting_body)
+async def admin_ticket_reply_msg(
+ message: Message,
+ state: FSMContext,
+ db: Database,
+ settings: Settings,
+ bot: Bot,
+) -> None:
+ if not message.from_user or message.from_user.id not in settings.admin_ids:
+ await state.clear()
+ return
+ body = clamp_body(message.text or "")
+ if len(body) < 1:
+ await message.answer("Пустое сообщение.")
+ return
+ data = await state.get_data()
+ tid = int(data.get("ticket_id") or 0)
+ await state.clear()
+ ticket = db.get_ticket(tid)
+ if not ticket:
+ await message.answer("Тикет не найден.")
+ return
+ try:
+ ticket = db.add_ticket_message(
+ tid,
+ author_role="admin",
+ author_id=message.from_user.id,
+ body=body,
+ )
+ except ValueError as exc:
+ await message.answer(str(exc))
+ return
+ await notify_user_ticket_reply(
+ bot,
+ telegram_id=int(ticket["telegram_id"]),
+ ticket_id=tid,
+ subject=str(ticket.get("subject") or ""),
+ body=body,
+ )
+ messages = db.list_ticket_messages(tid)
+ await message.answer(
+ format_admin_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(
+ tid,
+ status=str(ticket["status"]),
+ is_admin=True,
+ ),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("admin:tix:close:"))
+async def cb_admin_ticket_close(
+ callback: CallbackQuery,
+ db: Database,
+ settings: Settings,
+) -> None:
+ if not callback.from_user or callback.from_user.id not in settings.admin_ids:
+ await callback.answer("Нет доступа", show_alert=True)
+ return
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Ошибка", show_alert=True)
+ return
+ ticket = db.get_ticket(tid)
+ if not ticket:
+ await callback.answer("Не найден", show_alert=True)
+ return
+ db.set_ticket_status(tid, "closed")
+ await callback.answer("Закрыто")
+ ticket = db.get_ticket(tid)
+ messages = db.list_ticket_messages(tid)
+ if callback.message and ticket:
+ await callback.message.edit_text(
+ format_admin_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(tid, status="closed", is_admin=True),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("admin:tix:reopen:"))
+async def cb_admin_ticket_reopen(
+ callback: CallbackQuery,
+ db: Database,
+ settings: Settings,
+) -> None:
+ if not callback.from_user or callback.from_user.id not in settings.admin_ids:
+ await callback.answer("Нет доступа", show_alert=True)
+ return
+ try:
+ tid = int((callback.data or "").rsplit(":", 1)[-1])
+ except ValueError:
+ await callback.answer("Ошибка", show_alert=True)
+ return
+ ticket = db.set_ticket_status(tid, "open")
+ if not ticket:
+ await callback.answer("Не найден", show_alert=True)
+ return
+ await callback.answer("Открыт снова")
+ messages = db.list_ticket_messages(tid)
+ if callback.message:
+ await callback.message.edit_text(
+ format_admin_ticket_card(ticket, messages),
+ reply_markup=ticket_view_inline(tid, status="open", is_admin=True),
+ parse_mode="HTML",
+ )
diff --git a/handlers_user.py b/handlers_user.py
new file mode 100644
index 0000000..d8566cb
--- /dev/null
+++ b/handlers_user.py
@@ -0,0 +1,245 @@
+"""Пользовательские хендлеры."""
+
+from __future__ import annotations
+
+from aiogram import F, Router
+from aiogram.filters import CommandObject, CommandStart
+from aiogram.types import CallbackQuery, Message
+from remnawave import RemnawaveSDK
+
+from config import Settings
+from keyboards import (
+ clients_links_inline,
+ clients_root_inline,
+ faq_back_inline,
+ faq_root_inline,
+ main_menu,
+ servers_inline,
+)
+from services.clients import CLIENTS_INTRO, client_card, get_client
+from services.db import AuthError, Database
+from services.faq import faq_answer_text, faq_intro, get_faq
+from services.nodes_status import fetch_nodes_status, format_nodes_status
+
+router = Router(name="user")
+
+WELCOME = (
+ "👋 Привет, {name}!\n\n"
+ "Я бот VPN на Remnawave.\n"
+ "Открой Mini App или пользуйся кнопками ниже.\n\n"
+ "Баланс: {balance:.0f} ₽"
+)
+
+HELP = (
+ "ℹ️ Помощь\n\n"
+ "• Mini App — кабинет в Telegram\n"
+ "• Тарифы — оплата с баланса или криптой\n"
+ "• Баланс — Crypto Bot / Heleket / Lava / Digiseller / код\n"
+ "• Моя подписка — ссылка, срок, трафик, продление\n"
+ "• Скачать клиент — топ приложений\n"
+ "• Статус серверов — онлайн / оффлайн\n"
+ "• FAQ — частые вопросы и ответы\n"
+ "• Тикеты — написать в поддержку\n\n"
+ "Не нашёл ответ — создай тикет или открой FAQ."
+)
+
+
+def _is_admin(settings: Settings, user_id: int | None) -> bool:
+ return bool(user_id and user_id in settings.admin_ids)
+
+
+def _kb(settings: Settings, is_admin: bool = False):
+ return main_menu(
+ is_admin=is_admin,
+ webapp_url=settings.webapp_url or None,
+ )
+
+
+@router.message(CommandStart())
+async def cmd_start(
+ message: Message,
+ command: CommandObject,
+ settings: Settings,
+ db: Database,
+) -> None:
+ user = message.from_user
+ name = user.full_name if user else "друг"
+ telegram_id = user.id if user else 0
+ bot_user = db.ensure_user(
+ telegram_id,
+ username=user.username if user else None,
+ full_name=name if user else None,
+ )
+ admin = _is_admin(settings, telegram_id or None)
+
+ args = (command.args or "").strip()
+ if args.startswith("qrauth_") and telegram_id > 0:
+ token = args[len("qrauth_") :]
+ try:
+ db.approve_login_qr(token, telegram_id)
+ await message.answer(
+ "✅ Вход на сайт подтверждён\n\n"
+ "Можно вернуться в браузер — кабинет откроется автоматически.",
+ reply_markup=_kb(settings, admin),
+ parse_mode="HTML",
+ )
+ return
+ except AuthError as exc:
+ await message.answer(
+ f"⚠️ Не удалось подтвердить QR: {exc}\n\n"
+ + WELCOME.format(name=name, balance=bot_user.balance),
+ reply_markup=_kb(settings, admin),
+ parse_mode="HTML",
+ )
+ return
+
+ if args.startswith("ref_") and telegram_id > 0:
+ code = args[len("ref_") :]
+ if db.attach_referrer(telegram_id, code):
+ await message.answer(
+ "🎁 Реферальная ссылка принята! Бонус начислим после первой покупки тарифа.",
+ parse_mode="HTML",
+ )
+ db.ensure_referral_code(telegram_id)
+
+ if bot_user.banned:
+ await message.answer(
+ "🚫 Аккаунт заблокирован. Напишите в поддержку, если это ошибка.",
+ parse_mode="HTML",
+ )
+ return
+
+ text = WELCOME.format(name=name, balance=bot_user.balance)
+ if not settings.webapp_url:
+ text += (
+ "\n\nMini App пока выключен: задай WEBAPP_URL (https) в .env"
+ )
+ await message.answer(
+ text,
+ reply_markup=_kb(settings, admin),
+ parse_mode="HTML",
+ )
+
+
+@router.message(F.text == "ℹ️ Помощь")
+async def cmd_help(message: Message, settings: Settings) -> None:
+ admin = _is_admin(settings, message.from_user.id if message.from_user else None)
+ await message.answer(HELP, reply_markup=_kb(settings, admin), parse_mode="HTML")
+
+
+@router.message(F.text == "❓ FAQ")
+async def cmd_faq(message: Message) -> None:
+ await message.answer(
+ faq_intro(),
+ reply_markup=faq_root_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "faq:root")
+async def cb_faq_root(callback: CallbackQuery) -> None:
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ faq_intro(),
+ reply_markup=faq_root_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data.startswith("faq:view:"))
+async def cb_faq_view(callback: CallbackQuery) -> None:
+ item_id = (callback.data or "").split(":", 2)[2]
+ item = get_faq(item_id)
+ if not item:
+ await callback.answer("Вопрос не найден", show_alert=True)
+ return
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ faq_answer_text(item),
+ reply_markup=faq_back_inline(),
+ parse_mode="HTML",
+ )
+
+
+@router.message(F.text == "📥 Скачать клиент")
+async def cmd_clients(message: Message) -> None:
+ await message.answer(
+ CLIENTS_INTRO,
+ reply_markup=clients_root_inline(),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ )
+
+
+@router.callback_query(F.data == "clients:root")
+async def cb_clients_root(callback: CallbackQuery) -> None:
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ CLIENTS_INTRO,
+ reply_markup=clients_root_inline(),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ )
+
+
+@router.callback_query(F.data.startswith("clients:view:"))
+async def cb_clients_view(callback: CallbackQuery) -> None:
+ client_id = (callback.data or "").split(":", 2)[2]
+ client = get_client(client_id)
+ if not client:
+ await callback.answer("Клиент не найден", show_alert=True)
+ return
+ await callback.answer()
+ if callback.message:
+ await callback.message.edit_text(
+ client_card(client),
+ reply_markup=clients_links_inline(client.links),
+ parse_mode="HTML",
+ disable_web_page_preview=True,
+ )
+
+
+@router.message(F.text == "📡 Статус серверов")
+async def cmd_servers(message: Message, sdk: RemnawaveSDK) -> None:
+ wait = await message.answer("⏳ Проверяю серверы…")
+ try:
+ nodes = await fetch_nodes_status(sdk)
+ text = format_nodes_status(nodes, detailed=False)
+ await wait.edit_text(text, reply_markup=servers_inline(), parse_mode="HTML")
+ except Exception as exc: # noqa: BLE001
+ await wait.edit_text(
+ f"Не удалось получить статус серверов.\n{exc}",
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "servers:refresh")
+async def cb_servers_refresh(callback: CallbackQuery, sdk: RemnawaveSDK) -> None:
+ await callback.answer("Обновляю…")
+ try:
+ nodes = await fetch_nodes_status(sdk)
+ text = format_nodes_status(nodes, detailed=False)
+ if callback.message:
+ await callback.message.edit_text(
+ text, reply_markup=servers_inline(), parse_mode="HTML"
+ )
+ except Exception as exc: # noqa: BLE001
+ if callback.message:
+ await callback.message.edit_text(
+ f"Ошибка обновления.\n{exc}",
+ parse_mode="HTML",
+ )
+
+
+@router.callback_query(F.data == "menu:home")
+async def cb_menu_home(callback: CallbackQuery, settings: Settings) -> None:
+ await callback.answer()
+ admin = _is_admin(settings, callback.from_user.id if callback.from_user else None)
+ if callback.message:
+ await callback.message.answer(
+ "🏠 Главное меню",
+ reply_markup=_kb(settings, admin),
+ )
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..c3f8254
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,574 @@
+#!/usr/bin/env bash
+# VPN Service Bot — интерактивный установщик (Docker + Postgres + Caddy SSL)
+# Запуск на Ubuntu 22.04 / 24.04:
+# cd /path/to/project && sudo bash install.sh
+#
+# Если видишь ошибку вроде «set: pipefail» / «invalid option name» —
+# файл сохранён с Windows-переносами (CRLF). Исправь так:
+# sed -i 's/\r$//' install.sh && sudo bash install.sh
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+log() { echo -e "${GREEN}✓${NC} $*"; }
+warn() { echo -e "${YELLOW}!${NC} $*"; }
+err() { echo -e "${RED}✗${NC} $*" >&2; }
+info() { echo -e "${CYAN}→${NC} $*"; }
+title() {
+ echo
+ echo -e "${BOLD}════════════════════════════════════════${NC}"
+ echo -e "${BOLD} $*${NC}"
+ echo -e "${BOLD}════════════════════════════════════════${NC}"
+}
+
+need_root() {
+ if [[ "${EUID}" -ne 0 ]]; then
+ err "Запусти от root: sudo bash install.sh"
+ exit 1
+ fi
+}
+
+ask() {
+ # ask "Prompt" "default" → sets REPLY
+ local prompt="$1"
+ local default="${2:-}"
+ if [[ -n "$default" ]]; then
+ read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${default}]: ")" REPLY
+ REPLY="${REPLY:-$default}"
+ else
+ read -r -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" REPLY
+ fi
+}
+
+ask_secret() {
+ local prompt="$1"
+ local default="${2:-}"
+ if [[ -n "$default" ]]; then
+ read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt} [скрыто / Enter = сгенерировать]: ")" REPLY
+ echo
+ REPLY="${REPLY:-$default}"
+ else
+ read -r -s -p "$(echo -e "${CYAN}?${NC} ${prompt}: ")" REPLY
+ echo
+ fi
+}
+
+ask_yn() {
+ local prompt="$1"
+ local default="${2:-y}"
+ local hint="y/n"
+ [[ "$default" == "y" ]] && hint="Y/n"
+ [[ "$default" == "n" ]] && hint="y/N"
+ while true; do
+ read -r -p "$(echo -e "${CYAN}?${NC} ${prompt} [${hint}]: ")" REPLY
+ REPLY="${REPLY:-$default}"
+ case "${REPLY,,}" in
+ y|yes|д|да) REPLY=y; return 0 ;;
+ n|no|н|нет) REPLY=n; return 0 ;;
+ esac
+ warn "Ответь y или n"
+ done
+}
+
+rand_pass() {
+ if command -v openssl >/dev/null 2>&1; then
+ openssl rand -base64 24 | tr -d '/+=' | head -c 24
+ else
+ head -c 48 /dev/urandom | base64 | tr -d '/+=' | head -c 24
+ fi
+}
+
+env_escape() {
+ # Значение для .env: кавычки если есть спецсимволы
+ local v="$1"
+ if [[ -z "$v" ]]; then
+ echo ""
+ return
+ fi
+ if [[ "$v" =~ [[:space:]\#\"\'\\\$\`] ]]; then
+ v="${v//\\/\\\\}"
+ v="${v//\"/\\\"}"
+ echo "\"$v\""
+ else
+ echo "$v"
+ fi
+}
+
+check_os() {
+ if [[ ! -f /etc/os-release ]]; then
+ warn "Не Linux? Скрипт рассчитан на Ubuntu 22.04/24.04"
+ return
+ fi
+ # shellcheck source=/dev/null
+ . /etc/os-release
+ info "ОС: ${PRETTY_NAME:-unknown}"
+ if [[ "${ID:-}" != "ubuntu" && "${ID:-}" != "debian" ]]; then
+ warn "Рекомендуется Ubuntu/Debian. Продолжаем на свой страх."
+ fi
+}
+
+install_docker() {
+ title "Docker"
+ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+ log "Docker уже установлен: $(docker --version)"
+ log "Compose: $(docker compose version)"
+ return
+ fi
+
+ ask_yn "Docker не найден. Установить Docker Engine + Compose?" "y"
+ if [[ "$REPLY" != "y" ]]; then
+ err "Без Docker установка невозможна"
+ exit 1
+ fi
+
+ info "Установка Docker…"
+ apt-get update -y
+ apt-get install -y ca-certificates curl gnupg
+ install -m 0755 -d /etc/apt/keyrings
+ if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
+ chmod a+r /etc/apt/keyrings/docker.asc
+ fi
+
+ local codename
+ # shellcheck source=/dev/null
+ . /etc/os-release
+ codename="${VERSION_CODENAME:-jammy}"
+ if [[ "${ID:-}" == "debian" ]]; then
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${codename} stable" \
+ > /etc/apt/sources.list.d/docker.list
+ else
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${codename} stable" \
+ > /etc/apt/sources.list.d/docker.list
+ fi
+
+ apt-get update -y
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+ systemctl enable --now docker
+ log "Docker установлен"
+}
+
+check_ports() {
+ title "Порты 80 / 443 (нужны для Caddy SSL)"
+ local busy=0
+ for port in 80 443; do
+ if ss -tlnp 2>/dev/null | grep -qE ":${port}\\b"; then
+ warn "Порт ${port} занят:"
+ ss -tlnp 2>/dev/null | grep -E ":${port}\\b" || true
+ busy=1
+ else
+ log "Порт ${port} свободен"
+ fi
+ done
+ if [[ "$busy" -eq 1 ]]; then
+ ask_yn "Порты заняты. Остановить конфликтующие сервисы (nginx/apache2/caddy)?" "n"
+ if [[ "$REPLY" == "y" ]]; then
+ systemctl stop nginx 2>/dev/null || true
+ systemctl disable nginx 2>/dev/null || true
+ systemctl stop apache2 2>/dev/null || true
+ systemctl disable apache2 2>/dev/null || true
+ systemctl stop caddy 2>/dev/null || true
+ # чужой docker caddy
+ docker ps --format '{{.Names}}' 2>/dev/null | while read -r name; do
+ if docker port "$name" 2>/dev/null | grep -qE ':80|:443'; then
+ warn "Останавливаю контейнер $name (держит 80/443)"
+ docker stop "$name" || true
+ fi
+ done
+ else
+ warn "Caddy может не получить SSL, пока 80/443 заняты"
+ fi
+ fi
+}
+
+configure_firewall() {
+ title "Firewall"
+ if ! command -v ufw >/dev/null 2>&1; then
+ ask_yn "UFW не установлен. Поставить и открыть 22/80/443?" "y"
+ if [[ "$REPLY" == "y" ]]; then
+ apt-get install -y ufw
+ else
+ return
+ fi
+ fi
+ ask_yn "Настроить UFW: разрешить 22, 80, 443?" "y"
+ if [[ "$REPLY" != "y" ]]; then
+ return
+ fi
+ ufw allow OpenSSH >/dev/null 2>&1 || ufw allow 22/tcp >/dev/null 2>&1 || true
+ ufw allow 80/tcp >/dev/null 2>&1 || true
+ ufw allow 443/tcp >/dev/null 2>&1 || true
+ ufw allow 443/udp >/dev/null 2>&1 || true
+ echo "y" | ufw enable >/dev/null 2>&1 || true
+ log "UFW: 22 / 80 / 443 открыты"
+}
+
+collect_config() {
+ title "Настройки проекта"
+
+ ask "Домен Mini App (A-запись → IP этого сервера)" "bot.example.com"
+ WEBAPP_DOMAIN="${REPLY,,}"
+ WEBAPP_DOMAIN="${WEBAPP_DOMAIN#https://}"
+ WEBAPP_DOMAIN="${WEBAPP_DOMAIN#http://}"
+ WEBAPP_DOMAIN="${WEBAPP_DOMAIN%%/*}"
+ WEBAPP_URL="https://${WEBAPP_DOMAIN}"
+
+ ask "Email для Let's Encrypt (Caddy, необязательно)" ""
+ ACME_EMAIL="$REPLY"
+
+ echo
+ info "Remnawave панель"
+ ask "REMNAWAVE_BASE_URL" "https://panel.example.com"
+ REMNAWAVE_BASE_URL="${REPLY%/}"
+
+ ask_yn "Использовать API Token панели? (иначе логин/пароль)" "y"
+ if [[ "$REPLY" == "y" ]]; then
+ ask_secret "REMNAWAVE_TOKEN"
+ REMNAWAVE_TOKEN="$REPLY"
+ REMNAWAVE_USERNAME=""
+ REMNAWAVE_PASSWORD=""
+ while [[ -z "$REMNAWAVE_TOKEN" ]]; do
+ warn "Токен обязателен"
+ ask_secret "REMNAWAVE_TOKEN"
+ REMNAWAVE_TOKEN="$REPLY"
+ done
+ else
+ REMNAWAVE_TOKEN=""
+ ask "REMNAWAVE_USERNAME" "admin"
+ REMNAWAVE_USERNAME="$REPLY"
+ ask_secret "REMNAWAVE_PASSWORD"
+ REMNAWAVE_PASSWORD="$REPLY"
+ fi
+
+ echo
+ info "Telegram бот (@BotFather)"
+ ask_secret "BOT_TOKEN"
+ BOT_TOKEN="$REPLY"
+ while [[ -z "$BOT_TOKEN" ]]; do
+ warn "BOT_TOKEN обязателен"
+ ask_secret "BOT_TOKEN"
+ BOT_TOKEN="$REPLY"
+ done
+
+ ask "ADMIN_IDS (Telegram ID через запятую)" ""
+ ADMIN_IDS="$REPLY"
+
+ echo
+ info "PostgreSQL (контейнер в Docker Compose)"
+ ask "POSTGRES_USER" "vpnbot"
+ POSTGRES_USER="$REPLY"
+ GEN_PG="$(rand_pass)"
+ ask_secret "POSTGRES_PASSWORD (Enter = сгенерировать)" "$GEN_PG"
+ POSTGRES_PASSWORD="$REPLY"
+ ask "POSTGRES_DB" "vpnbot"
+ POSTGRES_DB="$REPLY"
+
+ echo
+ info "Веб-админка https://${WEBAPP_DOMAIN}/admin"
+ ask "WEB_ADMIN_LOGIN" "admin"
+ WEB_ADMIN_LOGIN="$REPLY"
+ GEN_ADM="$(rand_pass)"
+ ask_secret "WEB_ADMIN_PASSWORD (Enter = сгенерировать)" "$GEN_ADM"
+ WEB_ADMIN_PASSWORD="$REPLY"
+
+ echo
+ ask_yn "Включить Telegram Stars для пополнения?" "y"
+ if [[ "$REPLY" == "y" ]]; then
+ STARS_ENABLED="true"
+ ask "Курс ₽ за 1⭐" "1.5"
+ STARS_RUB_PER_STAR="$REPLY"
+ else
+ STARS_ENABLED="false"
+ STARS_RUB_PER_STAR="1.5"
+ fi
+
+ ask "Ссылка покупки кода пополнения (TOPUP_CODE_PAY_URL, можно пусто)" ""
+ TOPUP_CODE_PAY_URL="$REPLY"
+
+ echo
+ ask_yn "Настроить SMTP сейчас? (можно позже в /admin → SMTP)" "n"
+ if [[ "$REPLY" == "y" ]]; then
+ ask "SMTP_HOST" ""
+ SMTP_HOST="$REPLY"
+ ask "SMTP_PORT" "587"
+ SMTP_PORT="$REPLY"
+ ask "SMTP_USER" ""
+ SMTP_USER="$REPLY"
+ ask_secret "SMTP_PASSWORD"
+ SMTP_PASSWORD="$REPLY"
+ ask "SMTP_FROM" ""
+ SMTP_FROM="$REPLY"
+ ask_yn "SMTP STARTTLS?" "y"
+ [[ "$REPLY" == "y" ]] && SMTP_TLS="true" || SMTP_TLS="false"
+ ask_yn "SMTP SSL (порт 465)?" "n"
+ [[ "$REPLY" == "y" ]] && SMTP_SSL="true" || SMTP_SSL="false"
+ else
+ SMTP_HOST=""
+ SMTP_PORT="587"
+ SMTP_USER=""
+ SMTP_PASSWORD=""
+ SMTP_FROM=""
+ SMTP_TLS="true"
+ SMTP_SSL="false"
+ fi
+
+ echo
+ warn "Платёжные ключи (Crypto/Lava/Heleket/…) можно заполнить позже в веб-админке."
+ ask_yn "Продолжить установку?" "y"
+ if [[ "$REPLY" != "y" ]]; then
+ err "Отменено"
+ exit 1
+ fi
+}
+
+write_env() {
+ title "Запись .env"
+ local env_file="${SCRIPT_DIR}/.env"
+ if [[ -f "$env_file" ]]; then
+ local bak="${env_file}.bak.$(date +%Y%m%d-%H%M%S)"
+ cp -a "$env_file" "$bak"
+ warn "Старый .env сохранён: $bak"
+ fi
+
+ cat > "$env_file" <users:* или *,\n"
+ "либо укажи REMNAWAVE_USERNAME / REMNAWAVE_PASSWORD админа в .env."
+ )
+ return False, f"Проверка записи не удалась: {exc}"
+
+
+def friendly_remnawave_error(exc: BaseException) -> str:
+ text = str(exc)
+ if "403" in text or "Forbidden" in text:
+ return (
+ "Remnawave запретил операцию (HTTP 403).\n\n"
+ "Скорее всего у API Token нет прав на запись пользователей.\n"
+ "Что сделать:\n"
+ "1) Панель → API Tokens → создай токен со scope users:* или *\n"
+ "2) Вставь новый токен в REMNAWAVE_TOKEN\n"
+ "либо задай логин/пароль админа:\n"
+ "REMNAWAVE_USERNAME / REMNAWAVE_PASSWORD\n\n"
+ f"{exc}"
+ )
+ return f"{exc}"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..666a971
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,9 @@
+PyJWT[crypto]>=2.8.0
+remnawave>=2.8.0
+python-dotenv>=1.0.0
+httpx>=0.27.0
+aiogram>=3.15.0
+aiohttp>=3.10.0
+psycopg[binary,pool]>=3.2.0
+qrcode>=7.4
+webauthn>=2.2.0
diff --git a/services/__init__.py b/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/admin_alerts.py b/services/admin_alerts.py
new file mode 100644
index 0000000..6e9976b
--- /dev/null
+++ b/services/admin_alerts.py
@@ -0,0 +1,37 @@
+"""Алерты админам в Telegram (health / ошибки оплат)."""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from aiogram import Bot
+ from config import Settings
+
+logger = logging.getLogger(__name__)
+
+_last_sent: dict[str, float] = {}
+
+
+async def alert_admins(
+ bot: Bot | None,
+ settings: Settings,
+ text: str,
+ *,
+ key: str = "generic",
+ cooldown_sec: float = 300.0,
+) -> None:
+ if bot is None or not settings.admin_ids:
+ return
+ now = time.monotonic()
+ last = _last_sent.get(key, 0.0)
+ if now - last < cooldown_sec:
+ return
+ _last_sent[key] = now
+ for admin_id in settings.admin_ids:
+ try:
+ await bot.send_message(admin_id, text, parse_mode="HTML")
+ except Exception: # noqa: BLE001
+ logger.warning("admin alert failed id=%s", admin_id)
diff --git a/services/anypay.py b/services/anypay.py
new file mode 100644
index 0000000..0782c75
--- /dev/null
+++ b/services/anypay.py
@@ -0,0 +1,289 @@
+"""Клиент AnyPay (https://anypay.io) — SCI + API create-payment."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import logging
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import urlencode
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+API_BASE = "https://anypay.io/api"
+MERCHANT_URL = "https://anypay.io/merchant"
+PAID_STATUSES = frozenset({"paid", "success"})
+ANYPAY_IPS = frozenset({"185.162.128.38", "185.162.128.39", "185.162.128.88"})
+
+
+class AnypayError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class AnypayInvoice:
+ pay_id: str
+ pay_url: str
+ transaction_id: str | None = None
+ status: str = "waiting"
+ amount: float = 0.0
+
+
+def _fmt_amount(amount: float) -> str:
+ return f"{float(amount):.2f}"
+
+
+def sign_create_payment(
+ *,
+ api_id: str,
+ project_id: str,
+ pay_id: str,
+ amount: str,
+ currency: str,
+ desc: str,
+ method: str,
+ api_key: str,
+) -> str:
+ raw = (
+ f"create-payment{api_id}{project_id}{pay_id}{amount}"
+ f"{currency}{desc}{method}{api_key}"
+ )
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+def sign_sci(
+ *,
+ project_id: str,
+ pay_id: str,
+ amount: str,
+ currency: str,
+ desc: str,
+ success_url: str,
+ fail_url: str,
+ api_key: str,
+) -> str:
+ raw = ":".join(
+ [
+ str(project_id),
+ str(pay_id),
+ amount,
+ currency,
+ desc,
+ success_url or "",
+ fail_url or "",
+ api_key,
+ ]
+ )
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+def verify_notification_sign(
+ data: dict[str, Any],
+ *,
+ project_id: str,
+ api_key: str,
+) -> bool:
+ """Проверка подписи IPN (SHA256 или MD5)."""
+ received = str(data.get("sign") or "").strip()
+ if not received or not api_key:
+ return False
+
+ currency = str(data.get("currency") or "")
+ amount = str(data.get("amount") or "")
+ pay_id = str(data.get("pay_id") or "")
+ merchant_id = str(data.get("merchant_id") or project_id)
+ status = str(data.get("status") or "paid")
+
+ # SHA256: currency:amount:pay_id:merchant_id:status:secret
+ sha_raw = ":".join([currency, amount, pay_id, str(merchant_id), status, api_key])
+ sha_expected = hashlib.sha256(sha_raw.encode("utf-8")).hexdigest()
+ if hmac.compare_digest(received.lower(), sha_expected.lower()):
+ return True
+
+ if status.lower() != "paid":
+ sha_paid = ":".join(
+ [currency, amount, pay_id, str(merchant_id), "paid", api_key]
+ )
+ sha_paid_expected = hashlib.sha256(sha_paid.encode("utf-8")).hexdigest()
+ if hmac.compare_digest(received.lower(), sha_paid_expected.lower()):
+ return True
+
+ # MD5: merchant_id:amount:pay_id:secret
+ md5_raw = f"{merchant_id}:{amount}:{pay_id}:{api_key}"
+ md5_expected = hashlib.md5(md5_raw.encode("utf-8")).hexdigest()
+ return hmac.compare_digest(received.lower(), md5_expected.lower())
+
+
+def client_ip_allowed(remote: str | None, *, forwarded: str | None = None) -> bool:
+ candidates: list[str] = []
+ if remote:
+ candidates.append(remote.strip())
+ if forwarded:
+ first = forwarded.split(",")[0].strip()
+ if first:
+ candidates.append(first)
+ return any(ip in ANYPAY_IPS for ip in candidates)
+
+
+class Anypay:
+ def __init__(
+ self,
+ api_id: str,
+ api_key: str,
+ project_id: str,
+ *,
+ currency: str = "RUB",
+ method: str = "",
+ success_url: str = "",
+ fail_url: str = "",
+ default_email: str = "pay@anypay.local",
+ check_ip: bool = False,
+ ) -> None:
+ self.api_id = str(api_id or "").strip()
+ self.api_key = str(api_key or "").strip()
+ self.project_id = str(project_id or "").strip()
+ self.currency = (currency or "RUB").strip().upper() or "RUB"
+ self.method = (method or "").strip().lower()
+ self.success_url = (success_url or "").strip()
+ self.fail_url = (fail_url or "").strip()
+ self.default_email = (default_email or "pay@anypay.local").strip()
+ self.check_ip = bool(check_ip)
+
+ @property
+ def enabled(self) -> bool:
+ return bool(self.api_id and self.api_key and self.project_id)
+
+ def build_sci_pay_url(
+ self,
+ *,
+ pay_id: str,
+ amount_rub: float,
+ desc: str,
+ email: str = "",
+ ) -> str:
+ if not self.enabled:
+ raise AnypayError("AnyPay не настроен")
+ amount = _fmt_amount(amount_rub)
+ currency = self.currency
+ description = (desc or "Topup")[:150]
+ sign = sign_sci(
+ project_id=self.project_id,
+ pay_id=str(pay_id),
+ amount=amount,
+ currency=currency,
+ desc=description,
+ success_url=self.success_url,
+ fail_url=self.fail_url,
+ api_key=self.api_key,
+ )
+ params: dict[str, str] = {
+ "merchant_id": self.project_id,
+ "pay_id": str(pay_id),
+ "amount": amount,
+ "currency": currency,
+ "desc": description,
+ "email": (email or self.default_email)[:120],
+ "sign": sign,
+ "lang": "ru",
+ }
+ if self.success_url:
+ params["success_url"] = self.success_url
+ if self.fail_url:
+ params["fail_url"] = self.fail_url
+ if self.method:
+ params["method"] = self.method
+ return f"{MERCHANT_URL}?{urlencode(params)}"
+
+ async def create_invoice(
+ self,
+ *,
+ pay_id: str,
+ amount_rub: float,
+ desc: str,
+ email: str = "",
+ ) -> AnypayInvoice:
+ """Создать счёт: API (если задан method) или SCI-ссылка."""
+ if not self.enabled:
+ raise AnypayError("AnyPay не настроен (ANYPAY_API_ID / KEY / PROJECT_ID)")
+
+ pay_id_s = "".join(ch for ch in str(pay_id) if ch.isdigit())
+ if not pay_id_s or len(pay_id_s) > 15:
+ raise AnypayError("pay_id: только цифры, до 15 символов")
+
+ amount = _fmt_amount(amount_rub)
+ description = (desc or "Topup")[:150]
+ mail = (email or self.default_email)[:120]
+
+ # Без method — SCI (покупатель выбирает способ на странице AnyPay)
+ if not self.method:
+ url = self.build_sci_pay_url(
+ pay_id=pay_id_s,
+ amount_rub=amount_rub,
+ desc=description,
+ email=mail,
+ )
+ return AnypayInvoice(
+ pay_id=pay_id_s,
+ pay_url=url,
+ amount=float(amount),
+ status="waiting",
+ )
+
+ sign = sign_create_payment(
+ api_id=self.api_id,
+ project_id=self.project_id,
+ pay_id=pay_id_s,
+ amount=amount,
+ currency=self.currency,
+ desc=description,
+ method=self.method,
+ api_key=self.api_key,
+ )
+ params: dict[str, Any] = {
+ "project_id": self.project_id,
+ "pay_id": pay_id_s,
+ "amount": amount,
+ "currency": self.currency,
+ "desc": description,
+ "email": mail,
+ "method": self.method,
+ "sign": sign,
+ "lang": "ru",
+ }
+ if self.success_url:
+ params["success_url"] = self.success_url
+ if self.fail_url:
+ params["fail_url"] = self.fail_url
+
+ url = f"{API_BASE}/create-payment/{self.api_id}"
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.get(
+ url, params=params, headers={"Accept": "application/json"}
+ )
+ try:
+ data = resp.json()
+ except Exception as exc: # noqa: BLE001
+ raise AnypayError(f"AnyPay bad JSON: {resp.text[:200]}") from exc
+
+ if not isinstance(data, dict):
+ raise AnypayError(f"AnyPay unexpected response: {data!r}")
+ if data.get("error"):
+ raise AnypayError(str(data.get("error") or data))
+ result = data.get("result")
+ if not isinstance(result, dict):
+ raise AnypayError(f"AnyPay: нет result ({data})")
+
+ pay_url = str(result.get("payment_url") or "").strip()
+ if not pay_url:
+ raise AnypayError("AnyPay: пустой payment_url")
+
+ return AnypayInvoice(
+ pay_id=str(result.get("pay_id") or pay_id_s),
+ pay_url=pay_url,
+ transaction_id=str(result.get("transaction_id") or "") or None,
+ status=str(result.get("status") or "waiting"),
+ amount=float(amount),
+ )
diff --git a/services/awg_client.py b/services/awg_client.py
new file mode 100644
index 0000000..655e25f
--- /dev/null
+++ b/services/awg_client.py
@@ -0,0 +1,297 @@
+"""Клиент Amnezia Web Panel (AWG / AWG 2.0) — Bearer API token."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import urlparse
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_PROTOCOL = "awg2"
+
+
+class AwgError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class AwgPanelServer:
+ id: int
+ name: str
+ host: str
+ protocols: list[str]
+
+ @property
+ def has_awg2(self) -> bool:
+ return any(
+ str(p).split("__", 1)[0] in {"awg2", "awg"} for p in self.protocols
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class AwgConnectionInfo:
+ ok: bool
+ alive: bool
+ ping_ms: int | None
+ protocols: list[str]
+ message: str = ""
+
+
+def _normalize_base_url(url: str) -> str:
+ raw = (url or "").strip().rstrip("/")
+ if not raw:
+ raise AwgError("API URL пуст")
+ if not raw.startswith(("http://", "https://")):
+ raw = "https://" + raw
+ parsed = urlparse(raw)
+ if not parsed.netloc:
+ raise AwgError("Некорректный API URL")
+ return raw
+
+
+class AwgClient:
+ """HTTP-клиент к Amnezia Web Panel (panel5 / PRVTPRO)."""
+
+ def __init__(
+ self,
+ api_url: str,
+ *,
+ api_token: str,
+ verify_ssl: bool = True,
+ timeout: float = 60.0,
+ ) -> None:
+ self.base_url = _normalize_base_url(api_url)
+ self.api_token = (api_token or "").strip()
+ if not self.api_token:
+ raise AwgError("Нужен API Token (Settings → API Tokens)")
+ self.verify_ssl = verify_ssl
+ self._client = httpx.AsyncClient(
+ base_url=self.base_url,
+ timeout=timeout,
+ verify=verify_ssl,
+ headers={
+ "Authorization": f"Bearer {self.api_token}",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ },
+ )
+
+ async def __aenter__(self) -> "AwgClient":
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ await self.aclose()
+
+ async def aclose(self) -> None:
+ await self._client.aclose()
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ json_body: dict[str, Any] | None = None,
+ params: dict[str, Any] | None = None,
+ ) -> Any:
+ try:
+ res = await self._client.request(
+ method, path, json=json_body, params=params
+ )
+ except httpx.HTTPError as exc:
+ raise AwgError(f"Сеть: {exc}") from exc
+
+ if res.status_code in (401, 403):
+ raise AwgError("Доступ запрещён — проверьте API Token")
+ if res.status_code == 404:
+ raise AwgError("Не найдено (проверьте server_id на панели)")
+
+ data: Any
+ ctype = res.headers.get("content-type", "")
+ if "application/json" in ctype or res.text[:1] in "{[":
+ try:
+ data = res.json()
+ except Exception:
+ data = {"raw": res.text[:500]}
+ else:
+ data = {"raw": res.text[:500]}
+
+ if res.status_code >= 400:
+ err = ""
+ if isinstance(data, dict):
+ err = str(data.get("error") or data.get("detail") or "")
+ raise AwgError(err or f"HTTP {res.status_code}")
+
+ return data
+
+ async def ping(self, panel_server_id: int) -> dict[str, Any]:
+ return await self._request("GET", f"/api/servers/{int(panel_server_id)}/ping")
+
+ async def list_panel_servers(self) -> list[AwgPanelServer]:
+ """Список нод панели через backup download (единственный полный inventory API)."""
+ try:
+ res = await self._client.get("/api/settings/backup/download")
+ except httpx.HTTPError as exc:
+ raise AwgError(f"Сеть: {exc}") from exc
+ if res.status_code in (401, 403):
+ raise AwgError("Доступ запрещён — проверьте API Token")
+ if res.status_code >= 400:
+ raise AwgError(f"Не удалось загрузить список серверов (HTTP {res.status_code})")
+ try:
+ data = res.json()
+ except Exception as exc:
+ raise AwgError("Ответ панели не JSON") from exc
+ servers = data.get("servers") if isinstance(data, dict) else None
+ if not isinstance(servers, list):
+ raise AwgError("В backup нет списка servers")
+ out: list[AwgPanelServer] = []
+ for i, s in enumerate(servers):
+ if not isinstance(s, dict):
+ continue
+ protos = s.get("protocols") or {}
+ keys = list(protos.keys()) if isinstance(protos, dict) else []
+ out.append(
+ AwgPanelServer(
+ id=i,
+ name=str(s.get("name") or s.get("host") or f"Server #{i}"),
+ host=str(s.get("host") or ""),
+ protocols=keys,
+ )
+ )
+ return out
+
+ async def probe(
+ self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL
+ ) -> AwgConnectionInfo:
+ try:
+ ping = await self.ping(panel_server_id)
+ except AwgError as exc:
+ return AwgConnectionInfo(
+ ok=False, alive=False, ping_ms=None, protocols=[], message=str(exc)
+ )
+ alive = bool(ping.get("alive"))
+ ms = ping.get("ms")
+ try:
+ ms_int = int(ms) if ms is not None else None
+ except (TypeError, ValueError):
+ ms_int = None
+ protocols: list[str] = []
+ try:
+ servers = await self.list_panel_servers()
+ for s in servers:
+ if s.id == int(panel_server_id):
+ protocols = s.protocols
+ break
+ except AwgError:
+ protocols = []
+ base = (protocol or DEFAULT_PROTOCOL).split("__", 1)[0]
+ has_proto = any(str(p).split("__", 1)[0] == base for p in protocols) if protocols else True
+ if not alive:
+ return AwgConnectionInfo(
+ ok=False,
+ alive=False,
+ ping_ms=ms_int,
+ protocols=protocols,
+ message=str(ping.get("error") or "SSH недоступен"),
+ )
+ if protocols and not has_proto:
+ return AwgConnectionInfo(
+ ok=False,
+ alive=True,
+ ping_ms=ms_int,
+ protocols=protocols,
+ message=f"Протокол {base} не найден на сервере",
+ )
+ return AwgConnectionInfo(
+ ok=True,
+ alive=True,
+ ping_ms=ms_int,
+ protocols=protocols,
+ message=f"OK · ping {ms_int or '—'} ms",
+ )
+
+ async def list_connections(
+ self, panel_server_id: int, *, protocol: str = DEFAULT_PROTOCOL
+ ) -> list[dict[str, Any]]:
+ data = await self._request(
+ "GET",
+ f"/api/servers/{int(panel_server_id)}/connections",
+ params={"protocol": protocol or DEFAULT_PROTOCOL},
+ )
+ clients = data.get("clients") if isinstance(data, dict) else None
+ return clients if isinstance(clients, list) else []
+
+ async def add_connection(
+ self,
+ panel_server_id: int,
+ *,
+ name: str,
+ protocol: str = DEFAULT_PROTOCOL,
+ ) -> dict[str, Any]:
+ data = await self._request(
+ "POST",
+ f"/api/servers/{int(panel_server_id)}/connections/add",
+ json_body={
+ "protocol": protocol or DEFAULT_PROTOCOL,
+ "name": (name or "Client").strip()[:80] or "Client",
+ },
+ )
+ if not isinstance(data, dict):
+ raise AwgError("Пустой ответ при создании клиента")
+ if not data.get("client_id") and not data.get("config"):
+ raise AwgError("Панель не вернула client_id/config")
+ return data
+
+ async def remove_connection(
+ self,
+ panel_server_id: int,
+ *,
+ client_id: str,
+ protocol: str = DEFAULT_PROTOCOL,
+ ) -> None:
+ await self._request(
+ "POST",
+ f"/api/servers/{int(panel_server_id)}/connections/remove",
+ json_body={
+ "protocol": protocol or DEFAULT_PROTOCOL,
+ "client_id": client_id,
+ },
+ )
+
+ async def get_connection_config(
+ self,
+ panel_server_id: int,
+ *,
+ client_id: str,
+ protocol: str = DEFAULT_PROTOCOL,
+ ) -> dict[str, Any]:
+ data = await self._request(
+ "POST",
+ f"/api/servers/{int(panel_server_id)}/connections/config",
+ json_body={
+ "protocol": protocol or DEFAULT_PROTOCOL,
+ "client_id": client_id,
+ },
+ )
+ return data if isinstance(data, dict) else {}
+
+ async def toggle_connection(
+ self,
+ panel_server_id: int,
+ *,
+ client_id: str,
+ enable: bool,
+ protocol: str = DEFAULT_PROTOCOL,
+ ) -> None:
+ await self._request(
+ "POST",
+ f"/api/servers/{int(panel_server_id)}/connections/toggle",
+ json_body={
+ "protocol": protocol or DEFAULT_PROTOCOL,
+ "client_id": client_id,
+ "enable": bool(enable),
+ },
+ )
diff --git a/services/awg_configs.py b/services/awg_configs.py
new file mode 100644
index 0000000..84a1603
--- /dev/null
+++ b/services/awg_configs.py
@@ -0,0 +1,488 @@
+"""Покупка и управление AmneziaWG 2.0 конфигами через Amnezia Web Panel."""
+
+from __future__ import annotations
+
+import logging
+import secrets
+import string
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+
+from remnawave import RemnawaveSDK
+
+from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError
+from services.db import Database
+from services.xui_wg import subscription_is_active, _subscription_limits
+
+logger = logging.getLogger(__name__)
+
+MAX_AWG_CONFIGS = 10
+SETTING_ENABLED = "awg_purchase_enabled"
+SETTING_PRICE = "awg_config_price"
+DEFAULT_PRICE = 0.0
+
+
+def _truthy(value: str) -> bool:
+ return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
+
+
+def _format_price(price: float) -> str:
+ if price <= 0:
+ return "бесплатно"
+ if price == int(price):
+ return f"{int(price)} ₽"
+ return f"{price:.2f} ₽"
+
+
+@dataclass(slots=True)
+class AwgPurchaseSettings:
+ enabled: bool = False
+ price: float = DEFAULT_PRICE
+ max_configs: int = MAX_AWG_CONFIGS
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "enabled": self.enabled,
+ "price": self.price,
+ "price_label": _format_price(self.price),
+ "max_configs": self.max_configs,
+ }
+
+
+@dataclass(slots=True)
+class AwgActionResult:
+ ok: bool
+ message: str
+ balance: float = 0.0
+ config: dict[str, Any] | None = None
+ configs: list[dict[str, Any]] | None = None
+
+
+def load_awg_purchase_settings(db: Database) -> AwgPurchaseSettings:
+ raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE])
+ price_raw = (raw.get(SETTING_PRICE) or "").strip()
+ try:
+ price = float(price_raw) if price_raw else DEFAULT_PRICE
+ except ValueError:
+ price = DEFAULT_PRICE
+ return AwgPurchaseSettings(
+ enabled=_truthy(raw.get(SETTING_ENABLED, "")),
+ price=max(0.0, price),
+ max_configs=MAX_AWG_CONFIGS,
+ )
+
+
+def save_awg_purchase_settings(
+ db: Database,
+ *,
+ enabled: bool | None = None,
+ price: float | None = None,
+) -> AwgPurchaseSettings:
+ data: dict[str, str] = {}
+ if enabled is not None:
+ data[SETTING_ENABLED] = "1" if enabled else "0"
+ if price is not None:
+ data[SETTING_PRICE] = str(max(0.0, float(price)))
+ if data:
+ db.set_settings_map(data)
+ return load_awg_purchase_settings(db)
+
+
+def _client_from_server(row: dict[str, Any]) -> AwgClient:
+ return AwgClient(
+ str(row["api_url"]),
+ api_token=str(row.get("api_token") or ""),
+ verify_ssl=bool(row.get("verify_ssl", True)),
+ )
+
+
+def _new_client_name(telegram_id: int, remark: str = "") -> str:
+ if remark.strip():
+ return remark.strip()[:80]
+ suffix = "".join(
+ secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4)
+ )
+ return f"awg_{telegram_id}_{suffix}"
+
+
+def serialize_awg_config(
+ row: dict[str, Any], server: dict[str, Any] | None = None
+) -> dict[str, Any]:
+ expire = row.get("expire_at")
+ return {
+ "id": int(row["id"]),
+ "server_id": int(row["server_id"]),
+ "server_name": (server or {}).get("name") or row.get("server_name") or "",
+ "client_id": row.get("client_id") or "",
+ "client_name": row.get("client_name") or "",
+ "remark": row.get("remark") or "",
+ "vpn_link": row.get("vpn_link") or "",
+ "config": row.get("config_text") or "",
+ "enabled": bool(row.get("enabled", True)),
+ "protocol": row.get("protocol") or DEFAULT_PROTOCOL,
+ "expire_at": expire.isoformat() if isinstance(expire, datetime) else expire,
+ "qr_url": f"/api/awg/configs/{int(row['id'])}/qr",
+ "created_at": row["created_at"].isoformat()
+ if isinstance(row.get("created_at"), datetime)
+ else row.get("created_at"),
+ }
+
+
+def list_available_awg_servers(db: Database) -> list[dict[str, Any]]:
+ rows = db.list_awg_servers(enabled_only=True)
+ out: list[dict[str, Any]] = []
+ for r in rows:
+ if not str(r.get("api_token") or "").strip():
+ continue
+ out.append(
+ {
+ "id": int(r["id"]),
+ "name": r.get("name") or f"AWG #{r['id']}",
+ "protocol": r.get("protocol") or DEFAULT_PROTOCOL,
+ "panel_server_id": int(r.get("panel_server_id") or 0),
+ }
+ )
+ return out
+
+
+def list_user_awg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]:
+ rows = db.list_awg_configs(telegram_id)
+ return [serialize_awg_config(r) for r in rows]
+
+
+async def purchase_awg_config(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ server_id: int,
+ remark: str = "",
+ login: str | None = None,
+) -> AwgActionResult:
+ settings = load_awg_purchase_settings(db)
+ bot_user = db.ensure_user(telegram_id)
+ balance = bot_user.balance
+ login = login or bot_user.login
+
+ if not settings.enabled:
+ return AwgActionResult(
+ ok=False,
+ message="Покупка AmneziaWG временно отключена.",
+ balance=balance,
+ )
+
+ active, reason = await subscription_is_active(
+ sdk, telegram_id, login=login, db=db
+ )
+ if not active:
+ return AwgActionResult(ok=False, message=reason, balance=balance)
+
+ count = db.count_awg_configs(telegram_id)
+ if count >= settings.max_configs:
+ return AwgActionResult(
+ ok=False,
+ message=f"Лимит: максимум {settings.max_configs} AWG-конфигов на аккаунт.",
+ balance=balance,
+ )
+
+ server = db.get_awg_server(server_id)
+ if not server or not server.get("enabled"):
+ return AwgActionResult(ok=False, message="Сервер недоступен.", balance=balance)
+
+ price = float(settings.price)
+ if price > 0:
+ ok, balance = db.try_charge(
+ telegram_id,
+ price,
+ kind="awg_config",
+ meta=f"awg:{server_id}",
+ )
+ if not ok:
+ return AwgActionResult(
+ ok=False,
+ message=(
+ f"Недостаточно средств.\n"
+ f"Нужно: {_format_price(price)}\n"
+ f"Баланс: {balance:.0f} ₽"
+ ),
+ balance=balance,
+ )
+
+ client_name = _new_client_name(telegram_id, remark)
+ remark = (remark or "").strip()[:80] or client_name
+ protocol = str(server.get("protocol") or DEFAULT_PROTOCOL)
+ panel_sid = int(server.get("panel_server_id") or 0)
+
+ try:
+ expire_at, _traffic = await _subscription_limits(
+ sdk, telegram_id, login=login, db=db
+ )
+ async with _client_from_server(server) as client:
+ created = await client.add_connection(
+ panel_sid, name=client_name, protocol=protocol
+ )
+ client_id = str(created.get("client_id") or "")
+ config_text = str(created.get("config") or "")
+ vpn_link = str(created.get("vpn_link") or "")
+ if not vpn_link and config_text:
+ import base64
+
+ vpn_link = "vpn://" + base64.b64encode(
+ config_text.strip().encode("utf-8")
+ ).decode("utf-8")
+ row = db.create_awg_config(
+ telegram_id=telegram_id,
+ server_id=server_id,
+ client_id=client_id,
+ client_name=client_name,
+ remark=remark,
+ config_text=config_text,
+ vpn_link=vpn_link,
+ protocol=protocol,
+ enabled=True,
+ expire_at=expire_at,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("AWG config create failed")
+ if price > 0:
+ balance = db.add_balance(
+ telegram_id,
+ price,
+ kind="refund",
+ meta=f"awg_refund:{server_id}",
+ )
+ return AwgActionResult(
+ ok=False,
+ message=f"Не удалось создать конфиг: {exc}",
+ balance=balance,
+ )
+
+ return AwgActionResult(
+ ok=True,
+ message="AmneziaWG-конфиг создан",
+ balance=balance,
+ config=serialize_awg_config(row, server),
+ )
+
+
+async def set_awg_config_enabled(
+ *,
+ db: Database,
+ telegram_id: int,
+ config_id: int,
+ enabled: bool,
+ sdk: RemnawaveSDK | None = None,
+ login: str | None = None,
+) -> AwgActionResult:
+ bot_user = db.ensure_user(telegram_id)
+ login = login or bot_user.login
+ row = db.get_awg_config(config_id, telegram_id=telegram_id)
+ if not row:
+ return AwgActionResult(ok=False, message="Конфиг не найден", balance=bot_user.balance)
+
+ if enabled:
+ if sdk is None:
+ return AwgActionResult(
+ ok=False,
+ message="Не удалось проверить подписку.",
+ balance=bot_user.balance,
+ )
+ active, reason = await subscription_is_active(
+ sdk, telegram_id, login=login, db=db
+ )
+ if not active:
+ return AwgActionResult(ok=False, message=reason, balance=bot_user.balance)
+
+ server = db.get_awg_server(int(row["server_id"]))
+ if not server:
+ return AwgActionResult(ok=False, message="Сервер не найден", balance=bot_user.balance)
+ try:
+ async with _client_from_server(server) as client:
+ await client.toggle_connection(
+ int(server["panel_server_id"]),
+ client_id=str(row["client_id"]),
+ enable=enabled,
+ protocol=str(row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL),
+ )
+ db.update_awg_config(config_id, enabled=enabled)
+ except AwgError as exc:
+ return AwgActionResult(ok=False, message=str(exc), balance=bot_user.balance)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("AWG toggle failed")
+ return AwgActionResult(ok=False, message=f"Ошибка: {exc}", balance=bot_user.balance)
+ refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
+ return AwgActionResult(
+ ok=True,
+ message="Включено" if enabled else "Выключено",
+ balance=bot_user.balance,
+ config=serialize_awg_config(refreshed, server) if refreshed else None,
+ )
+
+
+async def delete_awg_config(
+ *,
+ db: Database,
+ telegram_id: int,
+ config_id: int,
+) -> AwgActionResult:
+ row = db.get_awg_config(config_id, telegram_id=telegram_id)
+ if not row:
+ return AwgActionResult(ok=False, message="Конфиг не найден")
+ server = db.get_awg_server(int(row["server_id"]))
+ if server:
+ try:
+ async with _client_from_server(server) as client:
+ await client.remove_connection(
+ int(server["panel_server_id"]),
+ client_id=str(row["client_id"]),
+ protocol=str(
+ row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
+ ),
+ )
+ except Exception: # noqa: BLE001
+ logger.exception("AWG remove on panel failed (deleting local anyway)")
+ db.delete_awg_config(config_id, telegram_id=telegram_id)
+ return AwgActionResult(ok=True, message="Удалено")
+
+
+async def refresh_awg_config_payload(
+ *,
+ db: Database,
+ telegram_id: int,
+ config_id: int,
+) -> AwgActionResult:
+ row = db.get_awg_config(config_id, telegram_id=telegram_id)
+ if not row:
+ return AwgActionResult(ok=False, message="Конфиг не найден")
+ server = db.get_awg_server(int(row["server_id"]))
+ if not server:
+ return AwgActionResult(ok=False, message="Сервер не найден")
+ try:
+ async with _client_from_server(server) as client:
+ data = await client.get_connection_config(
+ int(server["panel_server_id"]),
+ client_id=str(row["client_id"]),
+ protocol=str(
+ row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
+ ),
+ )
+ config_text = str(data.get("config") or row.get("config_text") or "")
+ vpn_link = str(data.get("vpn_link") or "")
+ if not vpn_link and config_text:
+ import base64
+
+ vpn_link = "vpn://" + base64.b64encode(
+ config_text.strip().encode("utf-8")
+ ).decode("utf-8")
+ db.update_awg_config(
+ config_id,
+ config_text=config_text,
+ vpn_link=vpn_link or row.get("vpn_link") or "",
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("AWG refresh failed")
+ return AwgActionResult(ok=False, message=f"Не удалось обновить: {exc}")
+ refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
+ return AwgActionResult(
+ ok=True,
+ message="Обновлено",
+ config=serialize_awg_config(refreshed, server) if refreshed else None,
+ )
+
+
+async def sync_awg_configs_expiry(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ login: str | None = None,
+ restore_enabled: bool = False,
+) -> None:
+ """Обновляет expire_at у локальных AWG-конфигов по подписке Remnawave."""
+ rows = db.list_awg_configs(telegram_id)
+ if not rows:
+ return
+ bot_user = db.ensure_user(telegram_id)
+ login = login or bot_user.login
+ try:
+ expire_at, _ = await _subscription_limits(
+ sdk, telegram_id, login=login, db=db
+ )
+ except Exception: # noqa: BLE001
+ return
+ for row in rows:
+ db.update_awg_config(int(row["id"]), expire_at=expire_at)
+ if restore_enabled and not row.get("enabled"):
+ server = db.get_awg_server(int(row["server_id"]))
+ if not server:
+ continue
+ try:
+ async with _client_from_server(server) as client:
+ await client.toggle_connection(
+ int(server["panel_server_id"]),
+ client_id=str(row["client_id"]),
+ enable=True,
+ protocol=str(
+ row.get("protocol")
+ or server.get("protocol")
+ or DEFAULT_PROTOCOL
+ ),
+ )
+ db.update_awg_config(int(row["id"]), enabled=True)
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "AWG restore enable failed config=%s: %s",
+ row.get("id"),
+ exc,
+ )
+
+
+async def _disable_awg_row(db: Database, row: dict[str, Any]) -> bool:
+ if not row.get("enabled"):
+ return False
+ server = db.get_awg_server(int(row["server_id"]))
+ if server:
+ try:
+ async with _client_from_server(server) as client:
+ await client.toggle_connection(
+ int(server["panel_server_id"]),
+ client_id=str(row["client_id"]),
+ enable=False,
+ protocol=str(
+ row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
+ ),
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "AWG auto-disable panel failed config=%s: %s",
+ row.get("id"),
+ exc,
+ )
+ db.update_awg_config(int(row["id"]), enabled=False)
+ return True
+
+
+async def enforce_awg_configs_subscription(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ login: str | None = None,
+) -> dict[str, Any]:
+ """Нет активной подписки → выключить все AWG-конфиги на панели и в БД."""
+ bot_user = db.ensure_user(telegram_id)
+ login = login or bot_user.login
+ active, reason = await subscription_is_active(
+ sdk, telegram_id, login=login, db=db
+ )
+ disabled = 0
+ if not active:
+ for row in db.list_awg_configs(telegram_id):
+ if await _disable_awg_row(db, row):
+ disabled += 1
+ return {
+ "subscription_active": active,
+ "reason": "" if active else reason,
+ "disabled": disabled,
+ }
diff --git a/services/clients.py b/services/clients.py
new file mode 100644
index 0000000..2650c81
--- /dev/null
+++ b/services/clients.py
@@ -0,0 +1,216 @@
+"""Топ клиентов по https://docs.rw/clients (featured + самые популярные)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class ClientLink:
+ title: str
+ url: str
+
+
+@dataclass(frozen=True, slots=True)
+class ClientApp:
+ id: str
+ name: str
+ core: str
+ description: str
+ featured: bool
+ hwid: bool
+ links: tuple[ClientLink, ...]
+
+
+# Источник: https://docs.rw/clients + src/data/clients.ts (featured первыми)
+TOP_CLIENTS: tuple[ClientApp, ...] = (
+ ClientApp(
+ id="happ",
+ name="Happ",
+ core="Xray",
+ description="Современный клиент для Android, iOS, macOS, Windows, Linux.",
+ featured=True,
+ hwid=True,
+ links=(
+ ClientLink("Android · Google Play", "https://play.google.com/store/apps/details?id=com.happproxy"),
+ ClientLink(
+ "Android · APK",
+ "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
+ ),
+ ClientLink("iOS · App Store", "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215"),
+ ClientLink(
+ "Windows",
+ "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe",
+ ),
+ ClientLink("Linux · релизы", "https://github.com/Happ-proxy/happ-desktop/releases/latest"),
+ ClientLink("Сайт", "https://happ.su/main"),
+ ),
+ ),
+ ClientApp(
+ id="flclashx",
+ name="FlClashX",
+ core="Mihomo",
+ description="Форк FlClash с улучшениями. Android / Windows / macOS / Linux.",
+ featured=True,
+ hwid=True,
+ links=(
+ ClientLink("GitHub · релизы", "https://github.com/pluralplay/FlClashX/releases/latest"),
+ ClientLink("Telegram", "https://t.me/flclashx"),
+ ),
+ ),
+ ClientApp(
+ id="koala-clash",
+ name="Koala Clash",
+ core="Mihomo",
+ description="Форк Clash Verge Rev (community Remnawave). Windows / macOS / Linux.",
+ featured=True,
+ hwid=True,
+ links=(
+ ClientLink(
+ "GitHub · релизы",
+ "https://github.com/coolcoala/clash-verge-rev-lite/releases/latest",
+ ),
+ ClientLink("Telegram", "https://t.me/+WCL__GOFzZJkYjZi"),
+ ),
+ ),
+ ClientApp(
+ id="flowvy",
+ name="Flowvy",
+ core="Mihomo",
+ description="Удобный Mihomo-клиент «в два клика». Windows / macOS / Linux.",
+ featured=True,
+ hwid=True,
+ links=(
+ ClientLink(
+ "Windows",
+ "https://github.com/flowvy-proxy/desktop/releases/latest/download/Flowvy_x64.exe",
+ ),
+ ClientLink(
+ "Linux · deb",
+ "https://github.com/flowvy-proxy/desktop/releases/latest/download/Flowvy_x64.deb",
+ ),
+ ClientLink("macOS · релизы", "https://github.com/flowvy-proxy/desktop/releases/latest"),
+ ClientLink("Docs", "https://docs.flowvy.io"),
+ ),
+ ),
+ ClientApp(
+ id="rabbit-hole",
+ name="Rabbit Hole",
+ core="Mihomo",
+ description="Чистый UX для iOS и macOS.",
+ featured=True,
+ hwid=True,
+ links=(
+ ClientLink(
+ "iOS / macOS · App Store",
+ "https://apps.apple.com/app/rabbithole-vpn-client/id6683309629",
+ ),
+ ClientLink("Docs", "https://rabbit-hole-1.gitbook.io/rabbit-hole-docs/"),
+ ClientLink("Telegram", "https://t.me/rabbit_hole_vpn"),
+ ),
+ ),
+ ClientApp(
+ id="v2raytun",
+ name="V2rayTun",
+ core="Xray",
+ description="Лёгкий Xray-клиент. Android / iOS / Windows / macOS.",
+ featured=False,
+ hwid=True,
+ links=(
+ ClientLink(
+ "Android · Google Play",
+ "https://play.google.com/store/apps/details?id=com.v2raytun.android",
+ ),
+ ClientLink("iOS / macOS", "https://apps.apple.com/en/app/v2raytun/id6476628951"),
+ ClientLink("Windows / сайт", "https://v2raytun.com/"),
+ ClientLink("Telegram", "https://t.me/v2raytun"),
+ ),
+ ),
+ ClientApp(
+ id="v2rayng",
+ name="V2rayNG",
+ core="Xray",
+ description="Самый популярный V2Ray/Xray клиент для Android.",
+ featured=False,
+ hwid=False,
+ links=(
+ ClientLink("Android · релизы", "https://github.com/2dust/v2rayNG/releases/latest"),
+ ClientLink("GitHub", "https://github.com/2dust/v2rayNG"),
+ ),
+ ),
+ ClientApp(
+ id="v2rayn",
+ name="V2rayN",
+ core="Xray",
+ description="Популярный клиент для Windows / macOS / Linux.",
+ featured=False,
+ hwid=False,
+ links=(
+ ClientLink("Релизы", "https://github.com/2dust/v2rayN/releases/latest"),
+ ClientLink("Wiki", "https://github.com/2dust/v2rayN/wiki"),
+ ),
+ ),
+ ClientApp(
+ id="clash-verge",
+ name="Clash Verge",
+ core="Mihomo",
+ description="Clash Meta GUI на Tauri. Windows / macOS / Linux.",
+ featured=False,
+ hwid=False,
+ links=(
+ ClientLink(
+ "Релизы",
+ "https://github.com/clash-verge-rev/clash-verge-rev/releases/latest",
+ ),
+ ClientLink("Telegram", "https://t.me/clash_verge_rev"),
+ ),
+ ),
+ ClientApp(
+ id="karing",
+ name="Karing",
+ core="Sing-box",
+ description="Мультиплатформенный клиент на Sing-box.",
+ featured=False,
+ hwid=True,
+ links=(
+ ClientLink("Android / Desktop · релизы", "https://github.com/KaringX/karing/releases/latest"),
+ ClientLink("iOS · App Store", "https://apps.apple.com/us/app/karing/id6472431552"),
+ ClientLink("Сайт", "https://karing.app/"),
+ ClientLink("Telegram", "https://t.me/KaringApp"),
+ ),
+ ),
+)
+
+DOCS_CLIENTS_URL = "https://docs.rw/clients"
+
+CLIENTS_INTRO = (
+ "📥 Скачать клиент\n\n"
+ "Топ‑10 по docs.rw/clients "
+ "(featured Remnawave + самые популярные).\n\n"
+ "Установи приложение и вставь ссылку из «Моя подписка».\n"
+ "Выбери клиент:"
+)
+
+
+def get_client(client_id: str) -> ClientApp | None:
+ for client in TOP_CLIENTS:
+ if client.id == client_id:
+ return client
+ return None
+
+
+def client_card(client: ClientApp) -> str:
+ badges: list[str] = []
+ if client.featured:
+ badges.append("⭐ featured")
+ if client.hwid:
+ badges.append("🪪 HWID")
+ badge_line = " · ".join(badges)
+ lines = [
+ f"{client.name}",
+ f"Core: {client.core}",
+ ]
+ if badge_line:
+ lines.append(badge_line)
+ lines.extend(["", client.description, "", "Выбери платформу / ссылку:"])
+ return "\n".join(lines)
diff --git a/services/cryptopay.py b/services/cryptopay.py
new file mode 100644
index 0000000..45434f5
--- /dev/null
+++ b/services/cryptopay.py
@@ -0,0 +1,107 @@
+"""Клиент Crypto Pay API (@CryptoBot)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+
+class CryptoPayError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class Invoice:
+ invoice_id: int
+ status: str
+ amount: str
+ pay_url: str
+ bot_invoice_url: str
+ payload: str | None = None
+ fiat: str | None = None
+
+
+class CryptoPay:
+ def __init__(self, token: str, *, testnet: bool = False) -> None:
+ self.token = token.strip()
+ self.base_url = (
+ "https://testnet-pay.crypt.bot/api"
+ if testnet
+ else "https://pay.crypt.bot/api"
+ )
+
+ @property
+ def enabled(self) -> bool:
+ return bool(self.token)
+
+ async def _call(self, method: str, **params: Any) -> Any:
+ if not self.enabled:
+ raise CryptoPayError("CRYPTO_BOT_TOKEN не задан")
+ headers = {"Crypto-Pay-API-Token": self.token}
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{self.base_url}/{method}",
+ headers=headers,
+ json={k: v for k, v in params.items() if v is not None},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ if not data.get("ok"):
+ raise CryptoPayError(str(data.get("error") or data))
+ return data["result"]
+
+ async def get_me(self) -> dict[str, Any]:
+ return await self._call("getMe")
+
+ async def create_fiat_invoice(
+ self,
+ *,
+ amount_rub: float,
+ description: str,
+ payload: str,
+ expires_in: int = 1800,
+ accepted_assets: str = "USDT,TON,BTC,ETH,LTC",
+ ) -> Invoice:
+ result = await self._call(
+ "createInvoice",
+ currency_type="fiat",
+ fiat="RUB",
+ amount=f"{amount_rub:.2f}",
+ description=description[:1024],
+ payload=payload[:4096],
+ accepted_assets=accepted_assets,
+ expires_in=expires_in,
+ allow_comments=False,
+ allow_anonymous=True,
+ )
+ return self._parse_invoice(result)
+
+ async def get_invoice(self, invoice_id: int) -> Invoice | None:
+ result = await self._call(
+ "getInvoices",
+ invoice_ids=str(invoice_id),
+ )
+ items = result if isinstance(result, list) else result.get("items") or []
+ if not items:
+ return None
+ return self._parse_invoice(items[0])
+
+ @staticmethod
+ def _parse_invoice(raw: dict[str, Any]) -> Invoice:
+ pay_url = (
+ raw.get("bot_invoice_url")
+ or raw.get("pay_url")
+ or raw.get("mini_app_invoice_url")
+ or ""
+ )
+ return Invoice(
+ invoice_id=int(raw["invoice_id"]),
+ status=str(raw.get("status") or "active"),
+ amount=str(raw.get("amount") or ""),
+ pay_url=pay_url,
+ bot_invoice_url=str(raw.get("bot_invoice_url") or pay_url),
+ payload=raw.get("payload"),
+ fiat=raw.get("fiat"),
+ )
diff --git a/services/db.py b/services/db.py
new file mode 100644
index 0000000..bc36dbb
--- /dev/null
+++ b/services/db.py
@@ -0,0 +1,3229 @@
+"""PostgreSQL БД бота: пользователи, баланс, покупки, инвойсы, сессии."""
+
+from __future__ import annotations
+
+import json
+import re
+import secrets
+from dataclasses import dataclass
+from datetime import date, datetime, timedelta, timezone
+from typing import Any
+
+from psycopg import errors as pg_errors
+from psycopg.rows import dict_row
+from psycopg_pool import ConnectionPool
+
+from services.passwords import hash_password, new_session_token, verify_password
+
+LOGIN_RE = re.compile(r"^[a-zA-Z0-9_]{3,32}$")
+EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")
+QR_LOGIN_TTL = timedelta(minutes=5)
+WEBAUTHN_CHALLENGE_TTL = timedelta(minutes=5)
+SESSION_DAYS = 30
+# Блокировка миграций схемы (bot + webapp стартуют вместе)
+_SCHEMA_ADVISORY_LOCK = 872_014_401
+
+
+def _utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+@dataclass(frozen=True, slots=True)
+class BotUser:
+ telegram_id: int
+ username: str | None
+ full_name: str | None
+ balance: float
+ created_at: datetime | str
+ login: str | None = None
+ has_password: bool = False
+ is_telegram: bool = True
+ extra_devices: int = 0
+ device_blocked: bool = False
+ extra_traffic_packs: int = 0
+ extras_next_billing_at: datetime | None = None
+ extras_active: bool = True
+ auto_renew: bool = True
+ email: str | None = None
+ banned: bool = False
+ referral_code: str | None = None
+ referred_by: int | None = None
+ trial_used_at: datetime | None = None
+ notify_telegram: bool = True
+ notify_email: bool = True
+
+ @property
+ def display_name(self) -> str:
+ if self.full_name:
+ return self.full_name
+ if self.login:
+ return self.login
+ if self.username:
+ return f"@{self.username}"
+ return f"ID {self.telegram_id}"
+
+
+class AuthError(ValueError):
+ pass
+
+
+class Database:
+ def __init__(self, database_url: str) -> None:
+ if not database_url:
+ raise ValueError("DATABASE_URL пуст")
+ self.database_url = database_url
+ self.pool = ConnectionPool(
+ conninfo=database_url,
+ min_size=1,
+ max_size=10,
+ kwargs={"row_factory": dict_row},
+ open=True,
+ )
+ self.init_schema()
+
+ def close(self) -> None:
+ self.pool.close()
+
+ def init_schema(self) -> None:
+ statements = [
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ telegram_id BIGINT PRIMARY KEY,
+ username TEXT,
+ full_name TEXT,
+ balance NUMERIC(12, 2) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS login TEXT
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS session_token TEXT
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS session_expires TIMESTAMPTZ
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS extra_devices INTEGER NOT NULL DEFAULT 0
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS device_blocked BOOLEAN NOT NULL DEFAULT FALSE
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS extra_traffic_packs INTEGER NOT NULL DEFAULT 0
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS extras_next_billing_at TIMESTAMPTZ
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS extras_active BOOLEAN NOT NULL DEFAULT TRUE
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS expiry_reminder_sent_on DATE
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT TRUE
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_login_unique
+ ON users (login) WHERE login IS NOT NULL
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
+ ON users (lower(email)) WHERE email IS NOT NULL AND email <> ''
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_session_token
+ ON users (session_token) WHERE session_token IS NOT NULL
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS login_qr (
+ token TEXT PRIMARY KEY,
+ status TEXT NOT NULL DEFAULT 'pending',
+ telegram_id BIGINT,
+ session_token TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ approved_at TIMESTAMPTZ
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_login_qr_expires
+ ON login_qr (expires_at)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS webauthn_credentials (
+ credential_id TEXT PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE,
+ public_key BYTEA NOT NULL,
+ sign_count BIGINT NOT NULL DEFAULT 0,
+ transports TEXT NOT NULL DEFAULT '[]',
+ device_name TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_used_at TIMESTAMPTZ
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
+ ON webauthn_credentials (telegram_id)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS webauthn_challenges (
+ challenge TEXT PRIMARY KEY,
+ telegram_id BIGINT,
+ purpose TEXT NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
+ ON webauthn_challenges (expires_at)
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN NOT NULL DEFAULT FALSE
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code TEXT
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by BIGINT
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS trial_used_at TIMESTAMPTZ
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_telegram BOOLEAN NOT NULL DEFAULT TRUE
+ """,
+ """
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_email BOOLEAN NOT NULL DEFAULT TRUE
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_referral_code
+ ON users (referral_code) WHERE referral_code IS NOT NULL
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS user_sessions (
+ token TEXT PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ user_agent TEXT,
+ ip TEXT
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_user_sessions_user
+ ON user_sessions (telegram_id, expires_at DESC)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS promo_codes (
+ id BIGSERIAL PRIMARY KEY,
+ code TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ value NUMERIC(12, 2) NOT NULL,
+ max_uses INTEGER,
+ used_count INTEGER NOT NULL DEFAULT 0,
+ tariff_id TEXT,
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ expires_at TIMESTAMPTZ,
+ note TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_promo_codes_code
+ ON promo_codes (lower(code))
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS promo_redemptions (
+ id BIGSERIAL PRIMARY KEY,
+ promo_id BIGINT NOT NULL REFERENCES promo_codes(id) ON DELETE CASCADE,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE,
+ amount_saved NUMERIC(12, 2) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (promo_id, telegram_id)
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ token TEXT PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id) ON DELETE CASCADE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS payment_events (
+ id BIGSERIAL PRIMARY KEY,
+ provider TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ telegram_id BIGINT,
+ amount NUMERIC(12, 2),
+ invoice_ref TEXT,
+ status TEXT NOT NULL DEFAULT 'ok',
+ meta TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_payment_events_created
+ ON payment_events (created_at DESC)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS referral_rewards (
+ id BIGSERIAL PRIMARY KEY,
+ referrer_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ referee_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ amount NUMERIC(12, 2) NOT NULL,
+ kind TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (referee_id, kind)
+ )
+ """,
+ """
+ CREATE SEQUENCE IF NOT EXISTS web_user_id_seq START WITH 1 INCREMENT BY 1
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS transactions (
+ id BIGSERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ amount NUMERIC(12, 2) NOT NULL,
+ kind TEXT NOT NULL,
+ meta TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_transactions_user_id
+ ON transactions (telegram_id, id DESC)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS purchases (
+ id BIGSERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ tariff_id TEXT NOT NULL,
+ price NUMERIC(12, 2) NOT NULL,
+ remnawave_uuid TEXT,
+ subscription_url TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS invoices (
+ id BIGSERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ invoice_id BIGINT NOT NULL,
+ kind TEXT NOT NULL,
+ amount_rub NUMERIC(12, 2) NOT NULL,
+ tariff_id TEXT,
+ status TEXT NOT NULL DEFAULT 'active',
+ pay_url TEXT,
+ payload TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ paid_at TIMESTAMPTZ
+ )
+ """,
+ """
+ CREATE SEQUENCE IF NOT EXISTS local_invoice_id_seq START WITH 900000000001
+ """,
+ """
+ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'cryptobot'
+ """,
+ """
+ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_id TEXT
+ """,
+ """
+ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS external_uuid TEXT
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_provider_invoice
+ ON invoices (provider, invoice_id)
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_order_id
+ ON invoices (order_id) WHERE order_id IS NOT NULL
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS digiseller_payments (
+ inv BIGINT PRIMARY KEY,
+ unique_code TEXT NOT NULL,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ amount_rub NUMERIC(12, 2) NOT NULL,
+ product_id BIGINT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_digiseller_unique_code
+ ON digiseller_payments (unique_code)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS topup_codes (
+ code TEXT PRIMARY KEY,
+ amount_rub NUMERIC(12, 2) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_by TEXT,
+ used_at TIMESTAMPTZ,
+ used_by BIGINT REFERENCES users(telegram_id)
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_topup_codes_used
+ ON topup_codes (used_at NULLS FIRST, created_at DESC)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS tickets (
+ id BIGSERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ subject TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_tickets_user
+ ON tickets (telegram_id, updated_at DESC)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_tickets_status
+ ON tickets (status, updated_at DESC)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS ticket_messages (
+ id BIGSERIAL PRIMARY KEY,
+ ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
+ author_role TEXT NOT NULL,
+ author_id BIGINT,
+ body TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket
+ ON ticket_messages (ticket_id, id ASC)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL DEFAULT '',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS xui_servers (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ api_url TEXT NOT NULL,
+ username TEXT NOT NULL DEFAULT '',
+ password TEXT NOT NULL DEFAULT '',
+ api_key TEXT NOT NULL DEFAULT '',
+ inbound_id INTEGER,
+ inbound_remark TEXT,
+ subscription_url TEXT NOT NULL DEFAULT '',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ verify_ssl BOOLEAN NOT NULL DEFAULT TRUE,
+ last_check_at TIMESTAMPTZ,
+ last_check_ok BOOLEAN,
+ last_check_msg TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ ALTER TABLE xui_servers ADD COLUMN IF NOT EXISTS api_key TEXT NOT NULL DEFAULT ''
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS xui_wg_configs (
+ id BIGSERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ server_id BIGINT NOT NULL REFERENCES xui_servers(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ sub_id TEXT NOT NULL,
+ subscription_url TEXT NOT NULL,
+ remark TEXT,
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ expire_at TIMESTAMPTZ,
+ traffic_bytes BIGINT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (server_id, email)
+ )
+ """,
+ """
+ ALTER TABLE xui_wg_configs ADD COLUMN IF NOT EXISTS expire_at TIMESTAMPTZ
+ """,
+ """
+ ALTER TABLE xui_wg_configs ADD COLUMN IF NOT EXISTS traffic_bytes BIGINT NOT NULL DEFAULT 0
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_xui_wg_configs_user
+ ON xui_wg_configs (telegram_id, id DESC)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_xui_servers_enabled
+ ON xui_servers (enabled, id)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS awg_servers (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ api_url TEXT NOT NULL,
+ api_token TEXT NOT NULL DEFAULT '',
+ panel_server_id INTEGER NOT NULL DEFAULT 0,
+ protocol TEXT NOT NULL DEFAULT 'awg2',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ verify_ssl BOOLEAN NOT NULL DEFAULT TRUE,
+ last_check_at TIMESTAMPTZ,
+ last_check_ok BOOLEAN,
+ last_check_msg TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS awg_configs (
+ id BIGSERIAL PRIMARY KEY,
+ telegram_id BIGINT NOT NULL REFERENCES users(telegram_id),
+ server_id BIGINT NOT NULL REFERENCES awg_servers(id) ON DELETE CASCADE,
+ client_id TEXT NOT NULL,
+ client_name TEXT NOT NULL DEFAULT '',
+ remark TEXT,
+ config_text TEXT NOT NULL DEFAULT '',
+ vpn_link TEXT NOT NULL DEFAULT '',
+ protocol TEXT NOT NULL DEFAULT 'awg2',
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ expire_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (server_id, client_id)
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_awg_configs_user
+ ON awg_configs (telegram_id, id DESC)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_awg_servers_enabled
+ ON awg_servers (enabled, id)
+ """,
+ ]
+ with self.pool.connection() as conn:
+ # Один процесс за раз — иначе гонка CREATE IF NOT EXISTS → 42P07
+ conn.execute("SELECT pg_advisory_lock(%s)", (_SCHEMA_ADVISORY_LOCK,))
+ try:
+ for stmt in statements:
+ try:
+ with conn.transaction():
+ conn.execute(stmt)
+ except (pg_errors.DuplicateTable, pg_errors.DuplicateObject):
+ # Параллельный старт / повторный apply — таблица уже есть
+ continue
+ # Старый UNIQUE(invoice_id) мешает разным провайдерам — убираем, если есть
+ with conn.transaction():
+ conn.execute(
+ """
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'invoices_invoice_id_key'
+ ) THEN
+ ALTER TABLE invoices DROP CONSTRAINT invoices_invoice_id_key;
+ END IF;
+ END $$;
+ """
+ )
+ with conn.transaction():
+ conn.execute(
+ """
+ UPDATE users
+ SET extras_next_billing_at = NOW() + INTERVAL '30 days'
+ WHERE extras_next_billing_at IS NULL
+ AND extra_devices > 0
+ """
+ )
+ conn.commit()
+ finally:
+ conn.execute("SELECT pg_advisory_unlock(%s)", (_SCHEMA_ADVISORY_LOCK,))
+ conn.commit()
+
+ def ensure_user(
+ self,
+ telegram_id: int,
+ *,
+ username: str | None = None,
+ full_name: str | None = None,
+ ) -> BotUser:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ if row:
+ conn.execute(
+ """
+ UPDATE users
+ SET username = COALESCE(%s, username),
+ full_name = COALESCE(%s, full_name)
+ WHERE telegram_id = %s
+ """,
+ (username, full_name, telegram_id),
+ )
+ else:
+ conn.execute(
+ """
+ INSERT INTO users (telegram_id, username, full_name, balance, created_at)
+ VALUES (%s, %s, %s, 0, %s)
+ """,
+ (telegram_id, username, full_name, _utc_now()),
+ )
+ conn.commit()
+ row = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ assert row is not None
+ return self._to_user(row)
+
+ def get_user(self, telegram_id: int) -> BotUser | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ return self._to_user(row) if row else None
+
+ def get_user_by_login(self, login: str) -> BotUser | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM users WHERE lower(login) = lower(%s)",
+ (login.strip(),),
+ ).fetchone()
+ return self._to_user(row) if row else None
+
+ def get_user_by_session(self, token: str | None) -> BotUser | None:
+ if not token:
+ return None
+ now = _utc_now()
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT u.*
+ FROM user_sessions s
+ JOIN users u ON u.telegram_id = s.telegram_id
+ WHERE s.token = %s AND s.expires_at > %s
+ """,
+ (token, now),
+ ).fetchone()
+ if row:
+ user = self._to_user(row)
+ if user.banned:
+ return None
+ return user
+ row = conn.execute(
+ """
+ SELECT * FROM users
+ WHERE session_token = %s
+ AND session_expires IS NOT NULL
+ AND session_expires > %s
+ """,
+ (token, now),
+ ).fetchone()
+ if not row:
+ return None
+ user = self._to_user(row)
+ if user.banned:
+ return None
+ return user
+
+ def create_session(
+ self,
+ telegram_id: int,
+ *,
+ user_agent: str | None = None,
+ ip: str | None = None,
+ ) -> str:
+ token = new_session_token()
+ expires = _utc_now() + timedelta(days=SESSION_DAYS)
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO user_sessions (token, telegram_id, created_at, expires_at, user_agent, ip)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ """,
+ (
+ token,
+ telegram_id,
+ _utc_now(),
+ expires,
+ (user_agent or "")[:300] or None,
+ (ip or "")[:80] or None,
+ ),
+ )
+ # legacy single-token column (совместимость)
+ conn.execute(
+ """
+ UPDATE users
+ SET session_token = %s, session_expires = %s
+ WHERE telegram_id = %s
+ """,
+ (token, expires, telegram_id),
+ )
+ conn.commit()
+ return token
+
+ def clear_session(self, token: str | None) -> None:
+ if not token:
+ return
+ with self.pool.connection() as conn:
+ conn.execute("DELETE FROM user_sessions WHERE token = %s", (token,))
+ conn.execute(
+ """
+ UPDATE users
+ SET session_token = NULL, session_expires = NULL
+ WHERE session_token = %s
+ """,
+ (token,),
+ )
+ conn.commit()
+
+ def clear_all_sessions(self, telegram_id: int, *, keep_token: str | None = None) -> int:
+ with self.pool.connection() as conn:
+ if keep_token:
+ row = conn.execute(
+ """
+ DELETE FROM user_sessions
+ WHERE telegram_id = %s AND token <> %s
+ """,
+ (telegram_id, keep_token),
+ )
+ else:
+ row = conn.execute(
+ "DELETE FROM user_sessions WHERE telegram_id = %s",
+ (telegram_id,),
+ )
+ deleted = row.rowcount if row is not None else 0
+ if not keep_token:
+ conn.execute(
+ """
+ UPDATE users
+ SET session_token = NULL, session_expires = NULL
+ WHERE telegram_id = %s
+ """,
+ (telegram_id,),
+ )
+ conn.commit()
+ return int(deleted or 0)
+
+ def list_sessions(self, telegram_id: int) -> list[dict[str, Any]]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT token, created_at, expires_at, user_agent, ip
+ FROM user_sessions
+ WHERE telegram_id = %s AND expires_at > %s
+ ORDER BY created_at DESC
+ LIMIT 20
+ """,
+ (telegram_id, _utc_now()),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def set_user_email(self, telegram_id: int, email: str | None) -> BotUser:
+ raw = (email or "").strip().lower()
+ if raw and not EMAIL_RE.fullmatch(raw):
+ raise AuthError("Некорректный email")
+ value = raw or None
+ with self.pool.connection() as conn:
+ if value:
+ clash = conn.execute(
+ """
+ SELECT telegram_id FROM users
+ WHERE lower(email) = %s AND telegram_id <> %s
+ LIMIT 1
+ """,
+ (value, telegram_id),
+ ).fetchone()
+ if clash:
+ raise AuthError("Этот email уже привязан к другому аккаунту")
+ conn.execute(
+ "UPDATE users SET email = %s WHERE telegram_id = %s",
+ (value, telegram_id),
+ )
+ conn.commit()
+ user = self.get_user(telegram_id)
+ if not user:
+ raise AuthError("Пользователь не найден")
+ return user
+
+ def create_login_qr(self) -> dict[str, Any]:
+ token = secrets.token_urlsafe(24)
+ now = _utc_now()
+ expires = now + QR_LOGIN_TTL
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO login_qr (token, status, created_at, expires_at)
+ VALUES (%s, 'pending', %s, %s)
+ """,
+ (token, now, expires),
+ )
+ conn.commit()
+ return {"token": token, "expires_at": expires, "expires_in": int(QR_LOGIN_TTL.total_seconds())}
+
+ def approve_login_qr(self, token: str, telegram_id: int) -> None:
+ token = (token or "").strip()
+ if not token:
+ raise AuthError("Некорректный QR-токен")
+ now = _utc_now()
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM login_qr WHERE token = %s",
+ (token,),
+ ).fetchone()
+ if not row:
+ raise AuthError("QR-сессия не найдена")
+ if row["status"] != "pending":
+ raise AuthError("QR уже использован или подтверждён")
+ if row["expires_at"] <= now:
+ conn.execute(
+ "UPDATE login_qr SET status = 'expired' WHERE token = %s",
+ (token,),
+ )
+ conn.commit()
+ raise AuthError("QR истёк — обновите код на сайте")
+ updated = conn.execute(
+ """
+ UPDATE login_qr
+ SET status = 'approved',
+ telegram_id = %s,
+ approved_at = %s
+ WHERE token = %s AND status = 'pending' AND expires_at > %s
+ RETURNING token
+ """,
+ (telegram_id, now, token, now),
+ ).fetchone()
+ conn.commit()
+ if not updated:
+ raise AuthError("Не удалось подтвердить QR")
+
+ def poll_login_qr(self, token: str) -> dict[str, Any]:
+ token = (token or "").strip()
+ if not token:
+ raise AuthError("Некорректный QR-токен")
+ now = _utc_now()
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM login_qr WHERE token = %s",
+ (token,),
+ ).fetchone()
+ if not row:
+ raise AuthError("QR-сессия не найдена")
+ if row["expires_at"] <= now and row["status"] == "pending":
+ conn.execute(
+ "UPDATE login_qr SET status = 'expired' WHERE token = %s",
+ (token,),
+ )
+ conn.commit()
+ return {"status": "expired"}
+ if row["status"] == "pending":
+ return {"status": "pending"}
+ if row["status"] in {"expired", "consumed"}:
+ return {"status": row["status"]}
+ if row["status"] != "approved":
+ return {"status": str(row["status"])}
+
+ telegram_id = int(row["telegram_id"])
+ claimed = conn.execute(
+ """
+ UPDATE login_qr
+ SET status = 'consumed'
+ WHERE token = %s AND status = 'approved'
+ RETURNING telegram_id
+ """,
+ (token,),
+ ).fetchone()
+ conn.commit()
+ if not claimed:
+ return {"status": "consumed"}
+
+ session = self.create_session(telegram_id)
+ with self.pool.connection() as conn:
+ conn.execute(
+ "UPDATE login_qr SET session_token = %s WHERE token = %s",
+ (session, token),
+ )
+ user_row = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ conn.commit()
+ if not user_row:
+ raise AuthError("Пользователь не найден")
+ return {
+ "status": "approved",
+ "token": session,
+ "user": self._to_user(user_row),
+ }
+
+ def save_webauthn_challenge(
+ self,
+ *,
+ challenge_b64: str,
+ purpose: str,
+ telegram_id: int | None = None,
+ ) -> None:
+ expires = _utc_now() + WEBAUTHN_CHALLENGE_TTL
+ with self.pool.connection() as conn:
+ conn.execute(
+ "DELETE FROM webauthn_challenges WHERE expires_at < %s",
+ (_utc_now(),),
+ )
+ conn.execute(
+ """
+ INSERT INTO webauthn_challenges (challenge, telegram_id, purpose, expires_at)
+ VALUES (%s, %s, %s, %s)
+ ON CONFLICT (challenge) DO UPDATE
+ SET telegram_id = EXCLUDED.telegram_id,
+ purpose = EXCLUDED.purpose,
+ expires_at = EXCLUDED.expires_at
+ """,
+ (challenge_b64, telegram_id, purpose, expires),
+ )
+ conn.commit()
+
+ def take_webauthn_challenge(
+ self, challenge_b64: str, *, purpose: str
+ ) -> dict[str, Any] | None:
+ now = _utc_now()
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ DELETE FROM webauthn_challenges
+ WHERE challenge = %s AND purpose = %s AND expires_at > %s
+ RETURNING challenge, telegram_id, purpose
+ """,
+ (challenge_b64, purpose, now),
+ ).fetchone()
+ conn.commit()
+ return dict(row) if row else None
+
+ def list_webauthn_credentials(self, telegram_id: int) -> list[dict[str, Any]]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT credential_id, telegram_id, public_key, sign_count,
+ transports, device_name, created_at, last_used_at
+ FROM webauthn_credentials
+ WHERE telegram_id = %s
+ ORDER BY created_at DESC
+ """,
+ (telegram_id,),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_webauthn_credential(self, credential_id: str) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT credential_id, telegram_id, public_key, sign_count,
+ transports, device_name, created_at, last_used_at
+ FROM webauthn_credentials
+ WHERE credential_id = %s
+ """,
+ (credential_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def add_webauthn_credential(
+ self,
+ *,
+ telegram_id: int,
+ credential_id: str,
+ public_key: bytes,
+ sign_count: int,
+ transports: list[str] | None = None,
+ device_name: str | None = None,
+ ) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO webauthn_credentials (
+ credential_id, telegram_id, public_key, sign_count,
+ transports, device_name, created_at
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ credential_id,
+ telegram_id,
+ public_key,
+ int(sign_count),
+ json.dumps(transports or []),
+ (device_name or "").strip() or None,
+ _utc_now(),
+ ),
+ )
+ conn.commit()
+
+ def update_webauthn_sign_count(self, credential_id: str, sign_count: int) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE webauthn_credentials
+ SET sign_count = %s, last_used_at = %s
+ WHERE credential_id = %s
+ """,
+ (int(sign_count), _utc_now(), credential_id),
+ )
+ conn.commit()
+
+ def delete_webauthn_credential(self, telegram_id: int, credential_id: str) -> bool:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ DELETE FROM webauthn_credentials
+ WHERE telegram_id = %s AND credential_id = %s
+ RETURNING credential_id
+ """,
+ (telegram_id, credential_id),
+ ).fetchone()
+ conn.commit()
+ return row is not None
+
+ def register_with_password(
+ self,
+ *,
+ login: str,
+ password: str,
+ full_name: str | None = None,
+ auto_renew: bool = True,
+ ) -> tuple[BotUser, str]:
+ login_norm = login.strip()
+ if not LOGIN_RE.fullmatch(login_norm):
+ raise AuthError("Логин: 3–32 символа (латиница, цифры, _)")
+ if len(password) < 6:
+ raise AuthError("Пароль минимум 6 символов")
+ if self.get_user_by_login(login_norm):
+ raise AuthError("Такой логин уже занят")
+
+ pwd_hash = hash_password(password)
+ with self.pool.connection() as conn:
+ web_id = conn.execute("SELECT nextval('web_user_id_seq') AS id").fetchone()
+ assert web_id is not None
+ telegram_id = -int(web_id["id"])
+ try:
+ conn.execute(
+ """
+ INSERT INTO users (
+ telegram_id, username, full_name, balance, created_at,
+ login, password_hash, auto_renew
+ ) VALUES (%s, %s, %s, 0, %s, %s, %s, %s)
+ """,
+ (
+ telegram_id,
+ login_norm,
+ full_name or login_norm,
+ _utc_now(),
+ login_norm,
+ pwd_hash,
+ bool(auto_renew),
+ ),
+ )
+ except Exception as exc: # noqa: BLE001
+ conn.rollback()
+ raise AuthError("Не удалось зарегистрировать") from exc
+ conn.commit()
+
+ token = self.create_session(telegram_id)
+ user = self.get_user(telegram_id)
+ assert user is not None
+ return user, token
+
+ def login_with_password(self, login: str, password: str) -> tuple[BotUser, str]:
+ login_norm = login.strip()
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM users WHERE lower(login) = lower(%s)",
+ (login_norm,),
+ ).fetchone()
+ if not row or not row.get("password_hash"):
+ raise AuthError("Неверный логин или пароль")
+ if not verify_password(password, row["password_hash"]):
+ raise AuthError("Неверный логин или пароль")
+
+ user = self._to_user(row)
+ token = self.create_session(user.telegram_id)
+ return user, token
+
+ def login_or_register_telegram(
+ self,
+ telegram_id: int,
+ *,
+ username: str | None = None,
+ full_name: str | None = None,
+ link_from: int | None = None,
+ ) -> tuple[BotUser, str]:
+ if telegram_id <= 0:
+ raise AuthError("Некорректный Telegram ID")
+
+ if link_from is not None and link_from < 0 and link_from != telegram_id:
+ user = self._merge_web_into_telegram(
+ web_id=link_from,
+ telegram_id=telegram_id,
+ username=username,
+ full_name=full_name,
+ )
+ else:
+ user = self.ensure_user(
+ telegram_id,
+ username=username,
+ full_name=full_name,
+ )
+ token = self.create_session(user.telegram_id)
+ return user, token
+
+ def _merge_web_into_telegram(
+ self,
+ *,
+ web_id: int,
+ telegram_id: int,
+ username: str | None,
+ full_name: str | None,
+ ) -> BotUser:
+ with self.pool.connection() as conn:
+ web = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s FOR UPDATE",
+ (web_id,),
+ ).fetchone()
+ if not web:
+ raise AuthError("Веб-аккаунт не найден")
+
+ tg = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s FOR UPDATE",
+ (telegram_id,),
+ ).fetchone()
+
+ if tg is None:
+ # Переносим PK: создаём tg-строку и перекидываем FK
+ conn.execute(
+ """
+ INSERT INTO users (
+ telegram_id, username, full_name, balance, created_at,
+ login, password_hash
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ telegram_id,
+ username or web["username"],
+ full_name or web["full_name"],
+ web["balance"],
+ web["created_at"],
+ web["login"],
+ web["password_hash"],
+ ),
+ )
+ for table in ("transactions", "purchases", "invoices"):
+ conn.execute(
+ f"UPDATE {table} SET telegram_id = %s WHERE telegram_id = %s",
+ (telegram_id, web_id),
+ )
+ conn.execute("DELETE FROM users WHERE telegram_id = %s", (web_id,))
+ else:
+ # Telegram уже есть — сливаем баланс и логин в него
+ new_balance = float(tg["balance"]) + float(web["balance"])
+ login = tg["login"] or web["login"]
+ pwd = tg["password_hash"] or web["password_hash"]
+ conn.execute(
+ """
+ UPDATE users
+ SET balance = %s,
+ login = %s,
+ password_hash = %s,
+ username = COALESCE(%s, username),
+ full_name = COALESCE(%s, full_name)
+ WHERE telegram_id = %s
+ """,
+ (
+ new_balance,
+ login,
+ pwd,
+ username,
+ full_name,
+ telegram_id,
+ ),
+ )
+ for table in ("transactions", "purchases", "invoices"):
+ conn.execute(
+ f"UPDATE {table} SET telegram_id = %s WHERE telegram_id = %s",
+ (telegram_id, web_id),
+ )
+ conn.execute("DELETE FROM users WHERE telegram_id = %s", (web_id,))
+
+ conn.commit()
+ row = conn.execute(
+ "SELECT * FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ assert row is not None
+ return self._to_user(row)
+
+ def get_balance(self, telegram_id: int) -> float:
+ return self.ensure_user(telegram_id).balance
+
+ def add_balance(
+ self,
+ telegram_id: int,
+ amount: float,
+ *,
+ kind: str = "topup",
+ meta: str | None = None,
+ ) -> float:
+ if amount == 0:
+ return self.get_balance(telegram_id)
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ conn.execute(
+ "UPDATE users SET balance = balance + %s WHERE telegram_id = %s",
+ (amount, telegram_id),
+ )
+ conn.execute(
+ """
+ INSERT INTO transactions (telegram_id, amount, kind, meta, created_at)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (telegram_id, amount, kind, meta, _utc_now()),
+ )
+ row = conn.execute(
+ "SELECT balance FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ conn.commit()
+ assert row is not None
+ return float(row["balance"])
+
+ def try_charge(
+ self,
+ telegram_id: int,
+ amount: float,
+ *,
+ kind: str = "purchase",
+ meta: str | None = None,
+ ) -> tuple[bool, float]:
+ if amount < 0:
+ raise ValueError("amount must be >= 0")
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ row = conn.execute(
+ "SELECT balance FROM users WHERE telegram_id = %s FOR UPDATE",
+ (telegram_id,),
+ ).fetchone()
+ assert row is not None
+ balance = float(row["balance"])
+ if balance < amount:
+ conn.commit()
+ return False, balance
+ conn.execute(
+ "UPDATE users SET balance = balance - %s WHERE telegram_id = %s",
+ (amount, telegram_id),
+ )
+ conn.execute(
+ """
+ INSERT INTO transactions (telegram_id, amount, kind, meta, created_at)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (telegram_id, -amount, kind, meta, _utc_now()),
+ )
+ conn.commit()
+ return True, balance - amount
+
+ def record_purchase(
+ self,
+ telegram_id: int,
+ *,
+ tariff_id: str,
+ price: float,
+ remnawave_uuid: str | None,
+ subscription_url: str | None,
+ ) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO purchases (
+ telegram_id, tariff_id, price, remnawave_uuid, subscription_url, created_at
+ ) VALUES (%s, %s, %s, %s, %s, %s)
+ """,
+ (
+ telegram_id,
+ tariff_id,
+ price,
+ remnawave_uuid,
+ subscription_url,
+ _utc_now(),
+ ),
+ )
+ conn.commit()
+
+ def recent_transactions(self, telegram_id: int, limit: int = 5) -> list[dict[str, Any]]:
+ items, _total = self.list_transactions(telegram_id, limit=limit, offset=0)
+ return items
+
+ def list_transactions(
+ self,
+ telegram_id: int,
+ *,
+ limit: int = 10,
+ offset: int = 0,
+ ) -> tuple[list[dict[str, Any]], int]:
+ limit = max(1, min(int(limit), 50))
+ offset = max(0, int(offset))
+ with self.pool.connection() as conn:
+ total_row = conn.execute(
+ "SELECT COUNT(*) AS c FROM transactions WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ total = int(total_row["c"] if total_row else 0)
+ rows = conn.execute(
+ """
+ SELECT amount, kind, meta, created_at
+ FROM transactions
+ WHERE telegram_id = %s
+ ORDER BY id DESC
+ LIMIT %s OFFSET %s
+ """,
+ (telegram_id, limit, offset),
+ ).fetchall()
+ return [dict(r) for r in rows], total
+
+ def list_users_admin(
+ self,
+ *,
+ query: str = "",
+ limit: int = 50,
+ offset: int = 0,
+ ) -> tuple[list[dict[str, Any]], int]:
+ q = (query or "").strip()
+ limit = max(1, min(int(limit), 200))
+ offset = max(0, int(offset))
+ with self.pool.connection() as conn:
+ if q:
+ pattern = f"%{q}%"
+ tid: int | None = None
+ if q.lstrip("-").isdigit():
+ try:
+ tid = int(q)
+ except ValueError:
+ tid = None
+ if tid is not None:
+ rows = conn.execute(
+ """
+ SELECT telegram_id, username, full_name, login, balance, created_at, banned
+ FROM users
+ WHERE telegram_id = %s
+ OR login ILIKE %s
+ OR username ILIKE %s
+ OR full_name ILIKE %s
+ ORDER BY created_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (tid, pattern, pattern, pattern, limit, offset),
+ ).fetchall()
+ count_row = conn.execute(
+ """
+ SELECT COUNT(*) AS c FROM users
+ WHERE telegram_id = %s
+ OR login ILIKE %s
+ OR username ILIKE %s
+ OR full_name ILIKE %s
+ """,
+ (tid, pattern, pattern, pattern),
+ ).fetchone()
+ else:
+ rows = conn.execute(
+ """
+ SELECT telegram_id, username, full_name, login, balance, created_at, banned
+ FROM users
+ WHERE login ILIKE %s
+ OR username ILIKE %s
+ OR full_name ILIKE %s
+ OR CAST(telegram_id AS TEXT) LIKE %s
+ ORDER BY created_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (pattern, pattern, pattern, pattern, limit, offset),
+ ).fetchall()
+ count_row = conn.execute(
+ """
+ SELECT COUNT(*) AS c FROM users
+ WHERE login ILIKE %s
+ OR username ILIKE %s
+ OR full_name ILIKE %s
+ OR CAST(telegram_id AS TEXT) LIKE %s
+ """,
+ (pattern, pattern, pattern, pattern),
+ ).fetchone()
+ else:
+ rows = conn.execute(
+ """
+ SELECT telegram_id, username, full_name, login, balance, created_at, banned
+ FROM users
+ ORDER BY created_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (limit, offset),
+ ).fetchall()
+ count_row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
+ total = int(count_row["c"] or 0) if count_row else 0
+ return [dict(r) for r in rows], total
+
+ def admin_credit_balance(
+ self,
+ telegram_id: int,
+ amount: float,
+ *,
+ comment: str,
+ admin_note: str | None = None,
+ ) -> float:
+ """Пополнение с баланса админом; comment виден пользователю в истории."""
+ if amount <= 0:
+ raise ValueError("amount must be > 0")
+ text = (comment or "").strip()[:200] or "Зачисление администратором"
+ meta = text
+ if admin_note:
+ meta = f"{text} | admin:{admin_note[:80]}"
+ return self.add_balance(
+ telegram_id,
+ amount,
+ kind="admin_credit",
+ meta=meta,
+ )
+
+ def next_local_invoice_id(self) -> int:
+ with self.pool.connection() as conn:
+ row = conn.execute("SELECT nextval('local_invoice_id_seq') AS id").fetchone()
+ conn.commit()
+ assert row is not None
+ return int(row["id"])
+
+ def create_invoice_record(
+ self,
+ *,
+ telegram_id: int,
+ invoice_id: int,
+ kind: str,
+ amount_rub: float,
+ pay_url: str | None,
+ payload: str | None,
+ tariff_id: str | None = None,
+ provider: str = "cryptobot",
+ order_id: str | None = None,
+ external_uuid: str | None = None,
+ ) -> None:
+ self.ensure_user(telegram_id)
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO invoices (
+ telegram_id, invoice_id, kind, amount_rub, tariff_id,
+ status, pay_url, payload, created_at,
+ provider, order_id, external_uuid
+ ) VALUES (%s, %s, %s, %s, %s, 'active', %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ telegram_id,
+ invoice_id,
+ kind,
+ amount_rub,
+ tariff_id,
+ pay_url,
+ payload,
+ _utc_now(),
+ provider,
+ order_id,
+ external_uuid,
+ ),
+ )
+ conn.commit()
+
+ def get_invoice(
+ self,
+ invoice_id: int,
+ *,
+ provider: str | None = None,
+ ) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ if provider:
+ row = conn.execute(
+ """
+ SELECT * FROM invoices
+ WHERE invoice_id = %s AND provider = %s
+ """,
+ (invoice_id, provider),
+ ).fetchone()
+ else:
+ row = conn.execute(
+ "SELECT * FROM invoices WHERE invoice_id = %s",
+ (invoice_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def get_invoice_by_order_id(self, order_id: str) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM invoices WHERE order_id = %s",
+ (order_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def digiseller_inv_used(self, inv: int) -> bool:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT 1 FROM digiseller_payments WHERE inv = %s",
+ (inv,),
+ ).fetchone()
+ return row is not None
+
+ def digiseller_code_used(self, unique_code: str) -> bool:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT 1 FROM digiseller_payments WHERE unique_code = %s",
+ (unique_code,),
+ ).fetchone()
+ return row is not None
+
+ def record_digiseller_payment(
+ self,
+ *,
+ inv: int,
+ unique_code: str,
+ telegram_id: int,
+ amount_rub: float,
+ product_id: int | None,
+ ) -> bool:
+ """True если записали впервые, False если уже был такой inv/code."""
+ self.ensure_user(telegram_id)
+ with self.pool.connection() as conn:
+ try:
+ conn.execute(
+ """
+ INSERT INTO digiseller_payments (
+ inv, unique_code, telegram_id, amount_rub, product_id, created_at
+ ) VALUES (%s, %s, %s, %s, %s, %s)
+ """,
+ (
+ inv,
+ unique_code,
+ telegram_id,
+ amount_rub,
+ product_id,
+ _utc_now(),
+ ),
+ )
+ conn.commit()
+ return True
+ except Exception: # noqa: BLE001
+ conn.rollback()
+ return False
+
+ def create_topup_codes(
+ self,
+ *,
+ amount_rub: float,
+ count: int,
+ created_by: str = "",
+ codes: list[str],
+ ) -> int:
+ if amount_rub <= 0 or not codes:
+ return 0
+ inserted = 0
+ with self.pool.connection() as conn:
+ for code in codes:
+ try:
+ conn.execute(
+ """
+ INSERT INTO topup_codes (code, amount_rub, created_by, created_at)
+ VALUES (%s, %s, %s, %s)
+ """,
+ (code, amount_rub, created_by or None, _utc_now()),
+ )
+ inserted += 1
+ except Exception: # noqa: BLE001
+ continue
+ conn.commit()
+ return inserted
+
+ def redeem_topup_code(
+ self,
+ *,
+ code: str,
+ telegram_id: int,
+ ) -> tuple[float, bool]:
+ """(сумма, уже_использован). NotFound -> ValueError."""
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT code, amount_rub, used_at
+ FROM topup_codes
+ WHERE code = %s
+ FOR UPDATE
+ """,
+ (code,),
+ ).fetchone()
+ if not row:
+ raise ValueError("Код не найден")
+ amount = float(row["amount_rub"])
+ if row["used_at"] is not None:
+ conn.commit()
+ return amount, True
+ conn.execute(
+ """
+ UPDATE topup_codes
+ SET used_at = %s, used_by = %s
+ WHERE code = %s AND used_at IS NULL
+ """,
+ (_utc_now(), telegram_id, code),
+ )
+ conn.commit()
+ return amount, False
+
+ def list_topup_codes(
+ self,
+ *,
+ status: str = "all",
+ limit: int = 50,
+ offset: int = 0,
+ ) -> list[dict[str, Any]]:
+ limit = max(1, min(int(limit), 200))
+ offset = max(0, int(offset))
+ where = ""
+ if status == "unused":
+ where = "WHERE used_at IS NULL"
+ elif status == "used":
+ where = "WHERE used_at IS NOT NULL"
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT code, amount_rub, created_at, created_by, used_at, used_by
+ FROM topup_codes
+ {where}
+ ORDER BY created_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (limit, offset),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def topup_code_stats(self) -> dict[str, int]:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT
+ COUNT(*) FILTER (WHERE used_at IS NULL) AS unused,
+ COUNT(*) FILTER (WHERE used_at IS NOT NULL) AS used,
+ COUNT(*) AS total
+ FROM topup_codes
+ """
+ ).fetchone()
+ return {
+ "unused": int(row["unused"] or 0),
+ "used": int(row["used"] or 0),
+ "total": int(row["total"] or 0),
+ }
+
+ def mark_invoice_paid(self, invoice_id: int, *, provider: str | None = None) -> bool:
+ with self.pool.connection() as conn:
+ if provider:
+ row = conn.execute(
+ """
+ UPDATE invoices
+ SET status = 'paid', paid_at = %s
+ WHERE invoice_id = %s AND provider = %s
+ AND status IS DISTINCT FROM 'paid'
+ RETURNING invoice_id
+ """,
+ (_utc_now(), invoice_id, provider),
+ ).fetchone()
+ else:
+ row = conn.execute(
+ """
+ UPDATE invoices
+ SET status = 'paid', paid_at = %s
+ WHERE invoice_id = %s AND status IS DISTINCT FROM 'paid'
+ RETURNING invoice_id
+ """,
+ (_utc_now(), invoice_id),
+ ).fetchone()
+ conn.commit()
+ return row is not None
+
+ @staticmethod
+ def _to_user(row: dict[str, Any]) -> BotUser:
+ tid = int(row["telegram_id"])
+ return BotUser(
+ telegram_id=tid,
+ username=row["username"],
+ full_name=row["full_name"],
+ balance=float(row["balance"]),
+ created_at=row["created_at"],
+ login=row.get("login"),
+ has_password=bool(row.get("password_hash")),
+ is_telegram=tid > 0,
+ extra_devices=int(row.get("extra_devices") or 0),
+ device_blocked=bool(row.get("device_blocked")),
+ extra_traffic_packs=int(row.get("extra_traffic_packs") or 0),
+ extras_next_billing_at=row.get("extras_next_billing_at"),
+ extras_active=bool(row.get("extras_active", True)),
+ auto_renew=bool(row["auto_renew"]) if row.get("auto_renew") is not None else True,
+ email=(str(row["email"]).strip() if row.get("email") else None) or None,
+ banned=bool(row.get("banned")),
+ referral_code=(str(row["referral_code"]).strip() if row.get("referral_code") else None)
+ or None,
+ referred_by=int(row["referred_by"]) if row.get("referred_by") is not None else None,
+ trial_used_at=row.get("trial_used_at"),
+ notify_telegram=bool(row["notify_telegram"])
+ if row.get("notify_telegram") is not None
+ else True,
+ notify_email=bool(row["notify_email"])
+ if row.get("notify_email") is not None
+ else True,
+ )
+
+ def set_device_blocked(self, telegram_id: int, blocked: bool) -> None:
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ conn.execute(
+ "UPDATE users SET device_blocked = %s WHERE telegram_id = %s",
+ (bool(blocked), telegram_id),
+ )
+ conn.commit()
+
+ def add_extra_devices(self, telegram_id: int, slots: int) -> int:
+ if slots <= 0:
+ return self.ensure_user(telegram_id).extra_devices
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ row = conn.execute(
+ """
+ UPDATE users
+ SET extra_devices = extra_devices + %s
+ WHERE telegram_id = %s
+ RETURNING extra_devices
+ """,
+ (int(slots), telegram_id),
+ ).fetchone()
+ conn.commit()
+ assert row is not None
+ return int(row["extra_devices"])
+
+ def add_extra_traffic_packs(self, telegram_id: int, packs: int) -> int:
+ if packs <= 0:
+ return self.ensure_user(telegram_id).extra_traffic_packs
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ row = conn.execute(
+ """
+ UPDATE users
+ SET extra_traffic_packs = extra_traffic_packs + %s
+ WHERE telegram_id = %s
+ RETURNING extra_traffic_packs
+ """,
+ (int(packs), telegram_id),
+ ).fetchone()
+ conn.commit()
+ assert row is not None
+ return int(row["extra_traffic_packs"])
+
+ def set_extras_active(self, telegram_id: int, active: bool) -> None:
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ conn.execute(
+ "UPDATE users SET extras_active = %s WHERE telegram_id = %s",
+ (bool(active), telegram_id),
+ )
+ conn.commit()
+
+ def set_extras_billing(
+ self,
+ telegram_id: int,
+ *,
+ next_billing_at: datetime | None,
+ active: bool,
+ ) -> None:
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ conn.execute(
+ """
+ UPDATE users
+ SET extras_next_billing_at = %s,
+ extras_active = %s
+ WHERE telegram_id = %s
+ """,
+ (next_billing_at, bool(active), telegram_id),
+ )
+ conn.commit()
+
+ def list_users_for_extras_billing(self, *, limit: int = 200) -> list[int]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT telegram_id FROM users
+ WHERE (extra_devices > 0 OR extra_traffic_packs > 0)
+ AND (
+ extras_active = FALSE
+ OR extras_next_billing_at IS NULL
+ OR extras_next_billing_at <= NOW()
+ )
+ ORDER BY extras_next_billing_at NULLS FIRST
+ LIMIT %s
+ """,
+ (int(limit),),
+ ).fetchall()
+ return [int(r["telegram_id"]) for r in rows]
+
+ def list_telegram_ids_with_purchases(self, *, limit: int = 500) -> list[int]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT DISTINCT p.telegram_id
+ FROM purchases p
+ WHERE p.telegram_id > 0
+ ORDER BY p.telegram_id
+ LIMIT %s
+ """,
+ (int(limit),),
+ ).fetchall()
+ return [int(r["telegram_id"]) for r in rows]
+
+ def set_auto_renew(self, telegram_id: int, enabled: bool) -> BotUser:
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ row = conn.execute(
+ """
+ UPDATE users
+ SET auto_renew = %s
+ WHERE telegram_id = %s
+ RETURNING *
+ """,
+ (bool(enabled), int(telegram_id)),
+ ).fetchone()
+ conn.commit()
+ assert row is not None
+ return self._to_user(row)
+
+ def get_last_tariff_id(self, telegram_id: int) -> str | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT tariff_id FROM purchases
+ WHERE telegram_id = %s
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (telegram_id,),
+ ).fetchone()
+ return str(row["tariff_id"]) if row else None
+
+ def expiry_reminder_sent_on(self, telegram_id: int) -> date | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT expiry_reminder_sent_on FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ if not row:
+ return None
+ val = row.get("expiry_reminder_sent_on")
+ if val is None:
+ return None
+ if isinstance(val, date):
+ return val
+ return val.date() if hasattr(val, "date") else None
+
+ def mark_expiry_reminder_sent(self, telegram_id: int, on: date) -> None:
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ conn.execute(
+ """
+ UPDATE users SET expiry_reminder_sent_on = %s
+ WHERE telegram_id = %s
+ """,
+ (on, telegram_id),
+ )
+ conn.commit()
+
+ def count_open_tickets(self, telegram_id: int) -> int:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT COUNT(*) AS c FROM tickets
+ WHERE telegram_id = %s AND status <> 'closed'
+ """,
+ (telegram_id,),
+ ).fetchone()
+ return int(row["c"]) if row else 0
+
+ def create_ticket(
+ self,
+ telegram_id: int,
+ *,
+ subject: str,
+ body: str,
+ ) -> dict[str, Any]:
+ subject = (subject or "").strip()
+ body = (body or "").strip()
+ if not subject or not body:
+ raise ValueError("Тема и сообщение обязательны")
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ row = conn.execute(
+ """
+ INSERT INTO tickets (telegram_id, subject, status, created_at, updated_at)
+ VALUES (%s, %s, 'open', NOW(), NOW())
+ RETURNING *
+ """,
+ (telegram_id, subject),
+ ).fetchone()
+ assert row is not None
+ ticket_id = int(row["id"])
+ conn.execute(
+ """
+ INSERT INTO ticket_messages (ticket_id, author_role, author_id, body)
+ VALUES (%s, 'user', %s, %s)
+ """,
+ (ticket_id, telegram_id, body),
+ )
+ conn.commit()
+ return dict(row)
+
+ def list_tickets_for_user(
+ self,
+ telegram_id: int,
+ *,
+ limit: int = 30,
+ offset: int = 0,
+ include_closed: bool = True,
+ ) -> list[dict[str, Any]]:
+ limit = max(1, min(int(limit), 100))
+ offset = max(0, int(offset))
+ with self.pool.connection() as conn:
+ if include_closed:
+ rows = conn.execute(
+ """
+ SELECT t.*,
+ (
+ SELECT COUNT(*) FROM ticket_messages m
+ WHERE m.ticket_id = t.id
+ ) AS message_count
+ FROM tickets t
+ WHERE t.telegram_id = %s
+ ORDER BY t.updated_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (telegram_id, limit, offset),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ """
+ SELECT t.*,
+ (
+ SELECT COUNT(*) FROM ticket_messages m
+ WHERE m.ticket_id = t.id
+ ) AS message_count
+ FROM tickets t
+ WHERE t.telegram_id = %s AND t.status <> 'closed'
+ ORDER BY t.updated_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (telegram_id, limit, offset),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def list_tickets_admin(
+ self,
+ *,
+ status: str = "open",
+ limit: int = 50,
+ offset: int = 0,
+ ) -> tuple[list[dict[str, Any]], int]:
+ limit = max(1, min(int(limit), 100))
+ offset = max(0, int(offset))
+ status = (status or "open").strip().lower()
+ with self.pool.connection() as conn:
+ if status == "all":
+ total_row = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone()
+ rows = conn.execute(
+ """
+ SELECT t.*,
+ u.username, u.full_name, u.login,
+ (
+ SELECT COUNT(*) FROM ticket_messages m
+ WHERE m.ticket_id = t.id
+ ) AS message_count
+ FROM tickets t
+ LEFT JOIN users u ON u.telegram_id = t.telegram_id
+ ORDER BY
+ CASE t.status
+ WHEN 'open' THEN 0
+ WHEN 'answered' THEN 1
+ ELSE 2
+ END,
+ t.updated_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (limit, offset),
+ ).fetchall()
+ else:
+ total_row = conn.execute(
+ "SELECT COUNT(*) AS c FROM tickets WHERE status = %s",
+ (status,),
+ ).fetchone()
+ rows = conn.execute(
+ """
+ SELECT t.*,
+ u.username, u.full_name, u.login,
+ (
+ SELECT COUNT(*) FROM ticket_messages m
+ WHERE m.ticket_id = t.id
+ ) AS message_count
+ FROM tickets t
+ LEFT JOIN users u ON u.telegram_id = t.telegram_id
+ WHERE t.status = %s
+ ORDER BY t.updated_at DESC
+ LIMIT %s OFFSET %s
+ """,
+ (status, limit, offset),
+ ).fetchall()
+ total = int(total_row["c"]) if total_row else 0
+ return [dict(r) for r in rows], total
+
+ def ticket_stats(self) -> dict[str, int]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT status, COUNT(*) AS c
+ FROM tickets
+ GROUP BY status
+ """
+ ).fetchall()
+ stats = {"open": 0, "answered": 0, "closed": 0, "total": 0}
+ for r in rows:
+ st = str(r["status"])
+ c = int(r["c"])
+ if st in stats:
+ stats[st] = c
+ stats["total"] += c
+ return stats
+
+ def get_ticket(self, ticket_id: int) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT t.*, u.username, u.full_name, u.login
+ FROM tickets t
+ LEFT JOIN users u ON u.telegram_id = t.telegram_id
+ WHERE t.id = %s
+ """,
+ (int(ticket_id),),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def list_ticket_messages(
+ self,
+ ticket_id: int,
+ *,
+ limit: int = 200,
+ ) -> list[dict[str, Any]]:
+ limit = max(1, min(int(limit), 500))
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM ticket_messages
+ WHERE ticket_id = %s
+ ORDER BY id ASC
+ LIMIT %s
+ """,
+ (int(ticket_id), limit),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def add_ticket_message(
+ self,
+ ticket_id: int,
+ *,
+ author_role: str,
+ author_id: int | None,
+ body: str,
+ new_status: str | None = None,
+ ) -> dict[str, Any]:
+ body = (body or "").strip()
+ if not body:
+ raise ValueError("Пустое сообщение")
+ role = (author_role or "").strip().lower()
+ if role not in ("user", "admin", "ai"):
+ raise ValueError("Некорректная роль")
+ with self.pool.connection() as conn:
+ ticket = conn.execute(
+ "SELECT * FROM tickets WHERE id = %s FOR UPDATE",
+ (int(ticket_id),),
+ ).fetchone()
+ if not ticket:
+ raise ValueError("Тикет не найден")
+ if str(ticket["status"]) == "closed":
+ raise ValueError("Тикет закрыт")
+ status = new_status or str(ticket["status"])
+ if new_status is None:
+ status = "answered" if role in ("admin", "ai") else "open"
+ conn.execute(
+ """
+ INSERT INTO ticket_messages (ticket_id, author_role, author_id, body)
+ VALUES (%s, %s, %s, %s)
+ """,
+ (int(ticket_id), role, author_id, body),
+ )
+ conn.execute(
+ """
+ UPDATE tickets
+ SET status = %s, updated_at = NOW()
+ WHERE id = %s
+ """,
+ (status, int(ticket_id)),
+ )
+ conn.commit()
+ row = conn.execute(
+ "SELECT * FROM tickets WHERE id = %s",
+ (int(ticket_id),),
+ ).fetchone()
+ assert row is not None
+ return dict(row)
+
+ def set_ticket_status(self, ticket_id: int, status: str) -> dict[str, Any] | None:
+ status = (status or "").strip().lower()
+ if status not in ("open", "answered", "closed"):
+ raise ValueError("Некорректный статус")
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE tickets
+ SET status = %s, updated_at = NOW()
+ WHERE id = %s
+ """,
+ (status, int(ticket_id)),
+ )
+ conn.commit()
+ row = conn.execute(
+ "SELECT * FROM tickets WHERE id = %s",
+ (int(ticket_id),),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def get_setting(self, key: str, default: str = "") -> str:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT value FROM app_settings WHERE key = %s",
+ (key,),
+ ).fetchone()
+ if not row:
+ return default
+ return str(row["value"] if row["value"] is not None else default)
+
+ def set_setting(self, key: str, value: str) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO app_settings (key, value, updated_at)
+ VALUES (%s, %s, NOW())
+ ON CONFLICT (key) DO UPDATE
+ SET value = EXCLUDED.value, updated_at = NOW()
+ """,
+ (key, value if value is not None else ""),
+ )
+ conn.commit()
+
+ def get_settings_map(self, keys: list[str]) -> dict[str, str]:
+ if not keys:
+ return {}
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ "SELECT key, value FROM app_settings WHERE key = ANY(%s)",
+ (list(keys),),
+ ).fetchall()
+ out = {k: "" for k in keys}
+ for r in rows:
+ out[str(r["key"])] = str(r["value"] if r["value"] is not None else "")
+ return out
+
+ def set_settings_map(self, data: dict[str, str]) -> None:
+ if not data:
+ return
+ with self.pool.connection() as conn:
+ for key, value in data.items():
+ conn.execute(
+ """
+ INSERT INTO app_settings (key, value, updated_at)
+ VALUES (%s, %s, NOW())
+ ON CONFLICT (key) DO UPDATE
+ SET value = EXCLUDED.value, updated_at = NOW()
+ """,
+ (str(key), "" if value is None else str(value)),
+ )
+ conn.commit()
+
+ # ─── 3x-ui servers ─────────────────────────────────────────────────────
+
+ def list_xui_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]:
+ where = "WHERE enabled = TRUE" if enabled_only else ""
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT id, name, api_url, username, password, api_key, inbound_id, inbound_remark,
+ subscription_url, enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ FROM xui_servers
+ {where}
+ ORDER BY id ASC
+ """
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_xui_server(self, server_id: int) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT id, name, api_url, username, password, api_key, inbound_id, inbound_remark,
+ subscription_url, enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ FROM xui_servers
+ WHERE id = %s
+ """,
+ (int(server_id),),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def create_xui_server(
+ self,
+ *,
+ name: str,
+ api_url: str,
+ username: str = "",
+ password: str = "",
+ api_key: str = "",
+ subscription_url: str = "",
+ inbound_id: int | None = None,
+ inbound_remark: str | None = None,
+ enabled: bool = True,
+ verify_ssl: bool = True,
+ ) -> dict[str, Any]:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ INSERT INTO xui_servers (
+ name, api_url, username, password, api_key, subscription_url,
+ inbound_id, inbound_remark, enabled, verify_ssl
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id, name, api_url, username, password, api_key, inbound_id, inbound_remark,
+ subscription_url, enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ """,
+ (
+ name.strip(),
+ api_url.strip(),
+ (username or "").strip(),
+ password or "",
+ (api_key or "").strip(),
+ (subscription_url or "").strip(),
+ inbound_id,
+ (inbound_remark or "").strip() or None,
+ bool(enabled),
+ bool(verify_ssl),
+ ),
+ ).fetchone()
+ conn.commit()
+ return dict(row)
+
+ def update_xui_server(self, server_id: int, **fields: Any) -> dict[str, Any] | None:
+ allowed = {
+ "name",
+ "api_url",
+ "username",
+ "password",
+ "api_key",
+ "subscription_url",
+ "inbound_id",
+ "inbound_remark",
+ "enabled",
+ "verify_ssl",
+ "last_check_at",
+ "last_check_ok",
+ "last_check_msg",
+ }
+ updates = {k: v for k, v in fields.items() if k in allowed}
+ if not updates:
+ return self.get_xui_server(server_id)
+
+ cols = []
+ vals: list[Any] = []
+ for key, value in updates.items():
+ cols.append(f"{key} = %s")
+ if key in {"name", "api_url", "username", "subscription_url", "api_key"} and isinstance(
+ value, str
+ ):
+ vals.append(value.strip())
+ elif key == "inbound_remark":
+ vals.append((value or "").strip() or None if isinstance(value, str) else value)
+ else:
+ vals.append(value)
+ cols.append("updated_at = NOW()")
+ vals.append(int(server_id))
+
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ f"""
+ UPDATE xui_servers
+ SET {", ".join(cols)}
+ WHERE id = %s
+ RETURNING id, name, api_url, username, password, api_key, inbound_id, inbound_remark,
+ subscription_url, enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ """,
+ tuple(vals),
+ ).fetchone()
+ conn.commit()
+ return dict(row) if row else None
+
+ def delete_xui_server(self, server_id: int) -> bool:
+ with self.pool.connection() as conn:
+ cur = conn.execute(
+ "DELETE FROM xui_servers WHERE id = %s",
+ (int(server_id),),
+ )
+ conn.commit()
+ return cur.rowcount > 0
+
+ def set_xui_server_check(
+ self,
+ server_id: int,
+ *,
+ ok: bool,
+ message: str,
+ ) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE xui_servers
+ SET last_check_at = NOW(),
+ last_check_ok = %s,
+ last_check_msg = %s,
+ updated_at = NOW()
+ WHERE id = %s
+ """,
+ (bool(ok), (message or "")[:500], int(server_id)),
+ )
+ conn.commit()
+
+ # ─── 3x-ui WireGuard configs ───────────────────────────────────────────
+
+ def count_xui_wg_configs(self, telegram_id: int) -> int:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT COUNT(*) AS c FROM xui_wg_configs WHERE telegram_id = %s",
+ (int(telegram_id),),
+ ).fetchone()
+ return int(row["c"] if row else 0)
+
+ def list_xui_wg_configs(self, telegram_id: int) -> list[dict[str, Any]]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT c.id, c.telegram_id, c.server_id, c.email, c.sub_id,
+ c.subscription_url, c.remark, c.enabled, c.expire_at,
+ c.traffic_bytes, c.created_at,
+ s.name AS server_name
+ FROM xui_wg_configs c
+ LEFT JOIN xui_servers s ON s.id = c.server_id
+ WHERE c.telegram_id = %s
+ ORDER BY c.id DESC
+ """,
+ (int(telegram_id),),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_xui_wg_config(
+ self, config_id: int, *, telegram_id: int | None = None
+ ) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ if telegram_id is None:
+ row = conn.execute(
+ """
+ SELECT id, telegram_id, server_id, email, sub_id,
+ subscription_url, remark, enabled, expire_at,
+ traffic_bytes, created_at
+ FROM xui_wg_configs
+ WHERE id = %s
+ """,
+ (int(config_id),),
+ ).fetchone()
+ else:
+ row = conn.execute(
+ """
+ SELECT id, telegram_id, server_id, email, sub_id,
+ subscription_url, remark, enabled, expire_at,
+ traffic_bytes, created_at
+ FROM xui_wg_configs
+ WHERE id = %s AND telegram_id = %s
+ """,
+ (int(config_id), int(telegram_id)),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def create_xui_wg_config(
+ self,
+ *,
+ telegram_id: int,
+ server_id: int,
+ email: str,
+ sub_id: str,
+ subscription_url: str,
+ remark: str = "",
+ enabled: bool = True,
+ expire_at: datetime | None = None,
+ traffic_bytes: int = 0,
+ ) -> dict[str, Any]:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ INSERT INTO xui_wg_configs (
+ telegram_id, server_id, email, sub_id, subscription_url, remark,
+ enabled, expire_at, traffic_bytes
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id, telegram_id, server_id, email, sub_id,
+ subscription_url, remark, enabled, expire_at,
+ traffic_bytes, created_at
+ """,
+ (
+ int(telegram_id),
+ int(server_id),
+ email,
+ sub_id,
+ subscription_url,
+ (remark or "").strip() or None,
+ bool(enabled),
+ expire_at,
+ int(traffic_bytes or 0),
+ ),
+ ).fetchone()
+ conn.commit()
+ return dict(row)
+
+ def set_xui_wg_config_enabled(
+ self, config_id: int, telegram_id: int, enabled: bool
+ ) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ UPDATE xui_wg_configs
+ SET enabled = %s
+ WHERE id = %s AND telegram_id = %s
+ RETURNING id, telegram_id, server_id, email, sub_id,
+ subscription_url, remark, enabled, expire_at,
+ traffic_bytes, created_at
+ """,
+ (bool(enabled), int(config_id), int(telegram_id)),
+ ).fetchone()
+ conn.commit()
+ return dict(row) if row else None
+
+ def update_xui_wg_config_limits(
+ self,
+ config_id: int,
+ *,
+ expire_at: datetime | None = None,
+ traffic_bytes: int | None = None,
+ ) -> None:
+ sets: list[str] = []
+ vals: list[Any] = []
+ if expire_at is not None:
+ sets.append("expire_at = %s")
+ vals.append(expire_at)
+ if traffic_bytes is not None:
+ sets.append("traffic_bytes = %s")
+ vals.append(int(traffic_bytes))
+ if not sets:
+ return
+ vals.append(int(config_id))
+ with self.pool.connection() as conn:
+ conn.execute(
+ f"UPDATE xui_wg_configs SET {', '.join(sets)} WHERE id = %s",
+ tuple(vals),
+ )
+ conn.commit()
+
+ def delete_xui_wg_config(self, config_id: int, telegram_id: int) -> bool:
+ with self.pool.connection() as conn:
+ cur = conn.execute(
+ "DELETE FROM xui_wg_configs WHERE id = %s AND telegram_id = %s",
+ (int(config_id), int(telegram_id)),
+ )
+ conn.commit()
+ return cur.rowcount > 0
+
+ # ─── AmneziaWG (AWG 2.0) via Amnezia Web Panel ─────────────────────────
+
+ def list_awg_servers(self, *, enabled_only: bool = False) -> list[dict[str, Any]]:
+ where = "WHERE enabled = TRUE" if enabled_only else ""
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT id, name, api_url, api_token, panel_server_id, protocol,
+ enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ FROM awg_servers
+ {where}
+ ORDER BY id ASC
+ """
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_awg_server(self, server_id: int) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ SELECT id, name, api_url, api_token, panel_server_id, protocol,
+ enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ FROM awg_servers
+ WHERE id = %s
+ """,
+ (int(server_id),),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def create_awg_server(
+ self,
+ *,
+ name: str,
+ api_url: str,
+ api_token: str = "",
+ panel_server_id: int = 0,
+ protocol: str = "awg2",
+ enabled: bool = True,
+ verify_ssl: bool = True,
+ ) -> dict[str, Any]:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ INSERT INTO awg_servers (
+ name, api_url, api_token, panel_server_id, protocol,
+ enabled, verify_ssl
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING id, name, api_url, api_token, panel_server_id, protocol,
+ enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ """,
+ (
+ name.strip(),
+ api_url.strip(),
+ (api_token or "").strip(),
+ int(panel_server_id),
+ (protocol or "awg2").strip() or "awg2",
+ bool(enabled),
+ bool(verify_ssl),
+ ),
+ ).fetchone()
+ conn.commit()
+ return dict(row)
+
+ def update_awg_server(self, server_id: int, **fields: Any) -> dict[str, Any] | None:
+ allowed = {
+ "name",
+ "api_url",
+ "api_token",
+ "panel_server_id",
+ "protocol",
+ "enabled",
+ "verify_ssl",
+ "last_check_at",
+ "last_check_ok",
+ "last_check_msg",
+ }
+ updates = {k: v for k, v in fields.items() if k in allowed}
+ if not updates:
+ return self.get_awg_server(server_id)
+ cols = []
+ vals: list[Any] = []
+ for k, v in updates.items():
+ cols.append(f"{k} = %s")
+ vals.append(v)
+ cols.append("updated_at = NOW()")
+ vals.append(int(server_id))
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ f"""
+ UPDATE awg_servers
+ SET {', '.join(cols)}
+ WHERE id = %s
+ RETURNING id, name, api_url, api_token, panel_server_id, protocol,
+ enabled, verify_ssl,
+ last_check_at, last_check_ok, last_check_msg,
+ created_at, updated_at
+ """,
+ tuple(vals),
+ ).fetchone()
+ conn.commit()
+ return dict(row) if row else None
+
+ def delete_awg_server(self, server_id: int) -> bool:
+ with self.pool.connection() as conn:
+ cur = conn.execute(
+ "DELETE FROM awg_servers WHERE id = %s",
+ (int(server_id),),
+ )
+ conn.commit()
+ return cur.rowcount > 0
+
+ def set_awg_server_check(
+ self,
+ server_id: int,
+ *,
+ ok: bool,
+ message: str,
+ ) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE awg_servers
+ SET last_check_at = NOW(),
+ last_check_ok = %s,
+ last_check_msg = %s,
+ updated_at = NOW()
+ WHERE id = %s
+ """,
+ (bool(ok), (message or "")[:500], int(server_id)),
+ )
+ conn.commit()
+
+ def count_awg_configs(self, telegram_id: int) -> int:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT COUNT(*) AS c FROM awg_configs WHERE telegram_id = %s",
+ (int(telegram_id),),
+ ).fetchone()
+ return int(row["c"] if row else 0)
+
+ def list_awg_configs(self, telegram_id: int) -> list[dict[str, Any]]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT c.id, c.telegram_id, c.server_id, c.client_id, c.client_name,
+ c.remark, c.config_text, c.vpn_link, c.protocol, c.enabled,
+ c.expire_at, c.created_at,
+ s.name AS server_name
+ FROM awg_configs c
+ LEFT JOIN awg_servers s ON s.id = c.server_id
+ WHERE c.telegram_id = %s
+ ORDER BY c.id DESC
+ """,
+ (int(telegram_id),),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def get_awg_config(
+ self, config_id: int, *, telegram_id: int | None = None
+ ) -> dict[str, Any] | None:
+ with self.pool.connection() as conn:
+ if telegram_id is None:
+ row = conn.execute(
+ """
+ SELECT id, telegram_id, server_id, client_id, client_name,
+ remark, config_text, vpn_link, protocol, enabled,
+ expire_at, created_at
+ FROM awg_configs
+ WHERE id = %s
+ """,
+ (int(config_id),),
+ ).fetchone()
+ else:
+ row = conn.execute(
+ """
+ SELECT id, telegram_id, server_id, client_id, client_name,
+ remark, config_text, vpn_link, protocol, enabled,
+ expire_at, created_at
+ FROM awg_configs
+ WHERE id = %s AND telegram_id = %s
+ """,
+ (int(config_id), int(telegram_id)),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def create_awg_config(
+ self,
+ *,
+ telegram_id: int,
+ server_id: int,
+ client_id: str,
+ client_name: str = "",
+ remark: str = "",
+ config_text: str = "",
+ vpn_link: str = "",
+ protocol: str = "awg2",
+ enabled: bool = True,
+ expire_at: datetime | None = None,
+ ) -> dict[str, Any]:
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ INSERT INTO awg_configs (
+ telegram_id, server_id, client_id, client_name, remark,
+ config_text, vpn_link, protocol, enabled, expire_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id, telegram_id, server_id, client_id, client_name,
+ remark, config_text, vpn_link, protocol, enabled,
+ expire_at, created_at
+ """,
+ (
+ int(telegram_id),
+ int(server_id),
+ client_id,
+ (client_name or "").strip(),
+ (remark or "").strip() or None,
+ config_text or "",
+ vpn_link or "",
+ (protocol or "awg2").strip() or "awg2",
+ bool(enabled),
+ expire_at,
+ ),
+ ).fetchone()
+ conn.commit()
+ return dict(row)
+
+ def update_awg_config(self, config_id: int, **fields: Any) -> dict[str, Any] | None:
+ allowed = {
+ "client_name",
+ "remark",
+ "config_text",
+ "vpn_link",
+ "protocol",
+ "enabled",
+ "expire_at",
+ }
+ updates = {k: v for k, v in fields.items() if k in allowed}
+ if not updates:
+ return self.get_awg_config(config_id)
+ cols = []
+ vals: list[Any] = []
+ for k, v in updates.items():
+ cols.append(f"{k} = %s")
+ vals.append(v)
+ vals.append(int(config_id))
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ f"""
+ UPDATE awg_configs
+ SET {', '.join(cols)}
+ WHERE id = %s
+ RETURNING id, telegram_id, server_id, client_id, client_name,
+ remark, config_text, vpn_link, protocol, enabled,
+ expire_at, created_at
+ """,
+ tuple(vals),
+ ).fetchone()
+ conn.commit()
+ return dict(row) if row else None
+
+ def delete_awg_config(self, config_id: int, telegram_id: int) -> bool:
+ with self.pool.connection() as conn:
+ cur = conn.execute(
+ "DELETE FROM awg_configs WHERE id = %s AND telegram_id = %s",
+ (int(config_id), int(telegram_id)),
+ )
+ conn.commit()
+ return cur.rowcount > 0
+
+ def list_telegram_ids_with_enabled_vpn_configs(self, *, limit: int = 500) -> list[int]:
+ """Пользователи с включёнными WG или AWG конфигами (для фонового enforce)."""
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT DISTINCT telegram_id FROM (
+ SELECT telegram_id FROM xui_wg_configs WHERE enabled = TRUE
+ UNION
+ SELECT telegram_id FROM awg_configs WHERE enabled = TRUE
+ ) t
+ ORDER BY telegram_id
+ LIMIT %s
+ """,
+ (int(limit),),
+ ).fetchall()
+ return [int(r["telegram_id"]) for r in rows]
+
+ # ─── Growth: ban / referral / promo / trial / password reset / audit ───
+
+ def set_banned(self, telegram_id: int, banned: bool) -> BotUser:
+ with self.pool.connection() as conn:
+ self._ensure_user_conn(conn, telegram_id)
+ conn.execute(
+ "UPDATE users SET banned = %s WHERE telegram_id = %s",
+ (bool(banned), telegram_id),
+ )
+ if banned:
+ conn.execute(
+ "DELETE FROM user_sessions WHERE telegram_id = %s",
+ (telegram_id,),
+ )
+ conn.execute(
+ """
+ UPDATE users SET session_token = NULL, session_expires = NULL
+ WHERE telegram_id = %s
+ """,
+ (telegram_id,),
+ )
+ conn.commit()
+ user = self.get_user(telegram_id)
+ assert user is not None
+ return user
+
+ def delete_user(self, telegram_id: int) -> bool:
+ """Полностью удалить пользователя и связанные записи. False если не найден."""
+ tid = int(telegram_id)
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT telegram_id FROM users WHERE telegram_id = %s",
+ (tid,),
+ ).fetchone()
+ if not row:
+ return False
+
+ # Снять ссылки с других пользователей
+ conn.execute(
+ "UPDATE users SET referred_by = NULL WHERE referred_by = %s",
+ (tid,),
+ )
+ conn.execute(
+ "UPDATE topup_codes SET used_by = NULL WHERE used_by = %s",
+ (tid,),
+ )
+ conn.execute(
+ "UPDATE login_qr SET telegram_id = NULL WHERE telegram_id = %s",
+ (tid,),
+ )
+
+ # Зависимые таблицы без ON DELETE CASCADE
+ conn.execute(
+ "DELETE FROM referral_rewards WHERE referrer_id = %s OR referee_id = %s",
+ (tid, tid),
+ )
+ conn.execute("DELETE FROM tickets WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM xui_wg_configs WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM awg_configs WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM transactions WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM purchases WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM invoices WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM digiseller_payments WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM payment_events WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM webauthn_challenges WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM user_sessions WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM webauthn_credentials WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM promo_redemptions WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM password_reset_tokens WHERE telegram_id = %s", (tid,))
+ conn.execute("DELETE FROM users WHERE telegram_id = %s", (tid,))
+ conn.commit()
+ return True
+
+ def set_notify_prefs(
+ self,
+ telegram_id: int,
+ *,
+ notify_telegram: bool | None = None,
+ notify_email: bool | None = None,
+ ) -> BotUser:
+ user = self.ensure_user(telegram_id)
+ tg = user.notify_telegram if notify_telegram is None else bool(notify_telegram)
+ em = user.notify_email if notify_email is None else bool(notify_email)
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET notify_telegram = %s, notify_email = %s
+ WHERE telegram_id = %s
+ """,
+ (tg, em, telegram_id),
+ )
+ conn.commit()
+ updated = self.get_user(telegram_id)
+ assert updated is not None
+ return updated
+
+ def ensure_referral_code(self, telegram_id: int) -> str:
+ user = self.ensure_user(telegram_id)
+ if user.referral_code:
+ return user.referral_code
+ for _ in range(8):
+ code = secrets.token_urlsafe(6).replace("-", "").replace("_", "")[:10].lower()
+ with self.pool.connection() as conn:
+ try:
+ conn.execute(
+ """
+ UPDATE users SET referral_code = %s
+ WHERE telegram_id = %s AND referral_code IS NULL
+ """,
+ (code, telegram_id),
+ )
+ conn.commit()
+ except Exception: # noqa: BLE001
+ conn.rollback()
+ continue
+ fresh = self.get_user(telegram_id)
+ if fresh and fresh.referral_code:
+ return fresh.referral_code
+ raise AuthError("Не удалось создать реферальный код")
+
+ def get_user_by_referral_code(self, code: str) -> BotUser | None:
+ raw = (code or "").strip().lower()
+ if not raw:
+ return None
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM users WHERE lower(referral_code) = %s",
+ (raw,),
+ ).fetchone()
+ return self._to_user(row) if row else None
+
+ def attach_referrer(self, telegram_id: int, referrer_code: str) -> bool:
+ """Привязать реферера только новым пользователям без referred_by."""
+ referrer = self.get_user_by_referral_code(referrer_code)
+ if not referrer or referrer.telegram_id == telegram_id:
+ return False
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ UPDATE users
+ SET referred_by = %s
+ WHERE telegram_id = %s
+ AND referred_by IS NULL
+ AND created_at > NOW() - INTERVAL '7 days'
+ RETURNING telegram_id
+ """,
+ (referrer.telegram_id, telegram_id),
+ ).fetchone()
+ conn.commit()
+ return row is not None
+
+ def grant_referral_rewards_on_first_purchase(
+ self,
+ referee_id: int,
+ *,
+ referrer_bonus: float,
+ referee_bonus: float,
+ ) -> None:
+ user = self.get_user(referee_id)
+ if not user or not user.referred_by:
+ return
+ referrer_id = int(user.referred_by)
+ with self.pool.connection() as conn:
+ exists = conn.execute(
+ """
+ SELECT 1 FROM referral_rewards
+ WHERE referee_id = %s AND kind = 'first_purchase'
+ """,
+ (referee_id,),
+ ).fetchone()
+ if exists:
+ return
+ try:
+ conn.execute(
+ """
+ INSERT INTO referral_rewards (referrer_id, referee_id, amount, kind)
+ VALUES (%s, %s, %s, 'first_purchase')
+ """,
+ (referrer_id, referee_id, float(referrer_bonus)),
+ )
+ except Exception: # noqa: BLE001
+ conn.rollback()
+ return
+ conn.commit()
+ if referrer_bonus > 0:
+ self.add_balance(
+ referrer_id,
+ referrer_bonus,
+ kind="referral_bonus",
+ meta=f"ref:{referee_id}",
+ )
+ if referee_bonus > 0:
+ self.add_balance(
+ referee_id,
+ referee_bonus,
+ kind="referral_welcome",
+ meta=f"from:{referrer_id}",
+ )
+
+ def mark_trial_used(self, telegram_id: int) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE users SET trial_used_at = %s
+ WHERE telegram_id = %s AND trial_used_at IS NULL
+ """,
+ (_utc_now(), telegram_id),
+ )
+ conn.commit()
+
+ def create_promo_code(
+ self,
+ *,
+ code: str,
+ kind: str,
+ value: float,
+ max_uses: int | None = None,
+ tariff_id: str | None = None,
+ expires_at: datetime | None = None,
+ note: str | None = None,
+ ) -> dict[str, Any]:
+ code_norm = (code or "").strip().upper()
+ if len(code_norm) < 3:
+ raise AuthError("Код слишком короткий")
+ kind_n = (kind or "").strip().lower()
+ if kind_n not in {"percent", "fixed", "days"}:
+ raise AuthError("kind: percent | fixed | days")
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ INSERT INTO promo_codes (
+ code, kind, value, max_uses, tariff_id, expires_at, note
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (
+ code_norm,
+ kind_n,
+ float(value),
+ max_uses,
+ tariff_id or None,
+ expires_at,
+ (note or "")[:200] or None,
+ ),
+ ).fetchone()
+ conn.commit()
+ assert row is not None
+ return dict(row)
+
+ def list_promo_codes(self, *, limit: int = 100) -> list[dict[str, Any]]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM promo_codes
+ ORDER BY id DESC
+ LIMIT %s
+ """,
+ (max(1, min(limit, 200)),),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ def set_promo_active(self, promo_id: int, active: bool) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ "UPDATE promo_codes SET active = %s WHERE id = %s",
+ (bool(active), int(promo_id)),
+ )
+ conn.commit()
+
+ def apply_promo_to_price(
+ self,
+ *,
+ code: str,
+ telegram_id: int,
+ tariff_id: str,
+ price: float,
+ days: int,
+ ) -> tuple[float, int, dict[str, Any]]:
+ """Вернуть (new_price, bonus_days, promo_row)."""
+ code_norm = (code or "").strip().upper()
+ if not code_norm:
+ raise AuthError("Укажите промокод")
+ now = _utc_now()
+ with self.pool.connection() as conn:
+ promo = conn.execute(
+ "SELECT * FROM promo_codes WHERE lower(code) = lower(%s)",
+ (code_norm,),
+ ).fetchone()
+ if not promo or not promo.get("active"):
+ raise AuthError("Промокод не найден или выключен")
+ if promo.get("expires_at") and promo["expires_at"] <= now:
+ raise AuthError("Промокод истёк")
+ if promo.get("tariff_id") and str(promo["tariff_id"]) != str(tariff_id):
+ raise AuthError("Промокод не для этого тарифа")
+ max_uses = promo.get("max_uses")
+ if max_uses is not None and int(promo.get("used_count") or 0) >= int(max_uses):
+ raise AuthError("Лимит использований промокода исчерпан")
+ used = conn.execute(
+ """
+ SELECT 1 FROM promo_redemptions
+ WHERE promo_id = %s AND telegram_id = %s
+ """,
+ (promo["id"], telegram_id),
+ ).fetchone()
+ if used:
+ raise AuthError("Вы уже использовали этот промокод")
+
+ kind = str(promo["kind"])
+ value = float(promo["value"])
+ new_price = float(price)
+ bonus_days = 0
+ if kind == "percent":
+ new_price = max(0.0, price * (1.0 - min(100.0, value) / 100.0))
+ elif kind == "fixed":
+ new_price = max(0.0, price - value)
+ elif kind == "days":
+ bonus_days = max(0, int(value))
+ return round(new_price, 2), bonus_days, dict(promo)
+
+ def redeem_promo(self, *, promo_id: int, telegram_id: int, amount_saved: float) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO promo_redemptions (promo_id, telegram_id, amount_saved)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (promo_id, telegram_id) DO NOTHING
+ """,
+ (int(promo_id), telegram_id, float(amount_saved)),
+ )
+ conn.execute(
+ """
+ UPDATE promo_codes SET used_count = used_count + 1
+ WHERE id = %s
+ """,
+ (int(promo_id),),
+ )
+ conn.commit()
+
+ def create_password_reset_token(self, telegram_id: int) -> str:
+ token = secrets.token_urlsafe(32)
+ expires = _utc_now() + timedelta(hours=1)
+ with self.pool.connection() as conn:
+ conn.execute(
+ "DELETE FROM password_reset_tokens WHERE telegram_id = %s OR expires_at < %s",
+ (telegram_id, _utc_now()),
+ )
+ conn.execute(
+ """
+ INSERT INTO password_reset_tokens (token, telegram_id, expires_at)
+ VALUES (%s, %s, %s)
+ """,
+ (token, telegram_id, expires),
+ )
+ conn.commit()
+ return token
+
+ def consume_password_reset_token(self, token: str) -> int:
+ token = (token or "").strip()
+ if not token:
+ raise AuthError("Некорректный токен")
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ """
+ UPDATE password_reset_tokens
+ SET used_at = %s
+ WHERE token = %s AND used_at IS NULL AND expires_at > %s
+ RETURNING telegram_id
+ """,
+ (_utc_now(), token, _utc_now()),
+ ).fetchone()
+ conn.commit()
+ if not row:
+ raise AuthError("Ссылка недействительна или истекла")
+ return int(row["telegram_id"])
+
+ def set_password_hash(self, telegram_id: int, password: str) -> None:
+ if len(password) < 6:
+ raise AuthError("Пароль минимум 6 символов")
+ pwd_hash = hash_password(password)
+ with self.pool.connection() as conn:
+ conn.execute(
+ "UPDATE users SET password_hash = %s WHERE telegram_id = %s",
+ (pwd_hash, telegram_id),
+ )
+ conn.commit()
+
+ def get_user_by_email(self, email: str) -> BotUser | None:
+ raw = (email or "").strip().lower()
+ if not raw:
+ return None
+ with self.pool.connection() as conn:
+ row = conn.execute(
+ "SELECT * FROM users WHERE lower(email) = %s",
+ (raw,),
+ ).fetchone()
+ return self._to_user(row) if row else None
+
+ def log_payment_event(
+ self,
+ *,
+ provider: str,
+ event_type: str,
+ telegram_id: int | None = None,
+ amount: float | None = None,
+ invoice_ref: str | None = None,
+ status: str = "ok",
+ meta: str | None = None,
+ ) -> None:
+ with self.pool.connection() as conn:
+ conn.execute(
+ """
+ INSERT INTO payment_events (
+ provider, event_type, telegram_id, amount, invoice_ref, status, meta
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ (provider or "")[:40],
+ (event_type or "")[:60],
+ telegram_id,
+ amount,
+ (invoice_ref or "")[:120] or None,
+ (status or "ok")[:40],
+ (meta or "")[:500] or None,
+ ),
+ )
+ conn.commit()
+
+ def list_payment_events(self, *, limit: int = 50) -> list[dict[str, Any]]:
+ with self.pool.connection() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM payment_events
+ ORDER BY id DESC
+ LIMIT %s
+ """,
+ (max(1, min(limit, 200)),),
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+ @staticmethod
+ def _ensure_user_conn(conn, telegram_id: int) -> None:
+ row = conn.execute(
+ "SELECT 1 FROM users WHERE telegram_id = %s",
+ (telegram_id,),
+ ).fetchone()
+ if not row:
+ conn.execute(
+ """
+ INSERT INTO users (telegram_id, username, full_name, balance, created_at)
+ VALUES (%s, NULL, NULL, 0, %s)
+ ON CONFLICT (telegram_id) DO NOTHING
+ """,
+ (telegram_id, _utc_now()),
+ )
diff --git a/services/devices.py b/services/devices.py
new file mode 100644
index 0000000..a0f51cf
--- /dev/null
+++ b/services/devices.py
@@ -0,0 +1,91 @@
+"""Лимит устройств (HWID): база 10, каждое сверх — 50 ₽."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+BASE_DEVICES = 10
+EXTRA_DEVICE_PRICE_RUB = 50.0
+
+# Сколько устройств панель может зарегистрировать максимум (мягкий потолок).
+# Реальная квота: BASE_DEVICES + оплаченные слоты; при превышении — DISABLE.
+PANEL_SOFT_CAP = 100
+
+
+@dataclass(frozen=True, slots=True)
+class DevicePack:
+ slots: int
+ price: float
+
+ @property
+ def id(self) -> str:
+ return f"dev_{self.slots}"
+
+ @property
+ def title(self) -> str:
+ return f"+{self.slots} устр."
+
+ def format_price(self) -> str:
+ if self.price == int(self.price):
+ return f"{int(self.price)} ₽/мес"
+ return f"{self.price:.2f} ₽/мес"
+
+
+DEVICE_PACKS: tuple[DevicePack, ...] = tuple(
+ DevicePack(slots=n, price=EXTRA_DEVICE_PRICE_RUB * n) for n in (1, 2, 3, 5)
+)
+
+
+def get_device_pack(pack_id: str) -> DevicePack | None:
+ for p in DEVICE_PACKS:
+ if p.id == pack_id or str(p.slots) == pack_id:
+ return p
+ return None
+
+
+@dataclass(frozen=True, slots=True)
+class DeviceUsage:
+ used: int
+ allowed: int
+ extra_paid: int
+ over_limit: bool
+
+ @property
+ def excess(self) -> int:
+ return max(0, self.used - self.allowed)
+
+ @property
+ def excess_price(self) -> float:
+ return self.excess * EXTRA_DEVICE_PRICE_RUB
+
+ def format_line(self) -> str:
+ line = (
+ f"📱 Устройства: {self.used} / {self.allowed} "
+ f"(база {BASE_DEVICES}"
+ + (f" + {self.extra_paid} доп." if self.extra_paid else "")
+ + ")"
+ )
+ if self.over_limit:
+ line += (
+ f"\n⚠️ Превышение: +{self.excess} · "
+ f"к оплате {int(self.excess_price)} ₽\n"
+ "Подписка отключена, пока не оплатишь лишние устройства "
+ f"({int(EXTRA_DEVICE_PRICE_RUB)} ₽ за каждое)."
+ )
+ return line
+
+ def as_dict(self) -> dict:
+ return {
+ "used": self.used,
+ "allowed": self.allowed,
+ "base": BASE_DEVICES,
+ "extra_paid": self.extra_paid,
+ "over_limit": self.over_limit,
+ "excess": self.excess,
+ "excess_price": self.excess_price,
+ "slot_price": EXTRA_DEVICE_PRICE_RUB,
+ }
+
+
+def allowed_devices(extra_paid: int) -> int:
+ return BASE_DEVICES + max(0, int(extra_paid or 0))
diff --git a/services/digiseller.py b/services/digiseller.py
new file mode 100644
index 0000000..fb22fbc
--- /dev/null
+++ b/services/digiseller.py
@@ -0,0 +1,273 @@
+"""Клиент Digiseller: проверка оплаты по уникальному коду."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import logging
+import time
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+API_BASE = "https://api.digiseller.com"
+API_BASE_FALLBACK = "https://oplata.info"
+
+
+class DigisellerError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class DigisellerPurchase:
+ unique_code: str
+ inv: int
+ product_id: int
+ amount: float
+ currency: str
+ cnt_goods: float
+ email: str | None
+ state: int
+ raw: dict[str, Any]
+
+ @property
+ def delivered(self) -> bool:
+ # 2/3/4 — уже отмечено как выдано
+ return self.state in {2, 3, 4}
+
+
+class Digiseller:
+ def __init__(
+ self,
+ seller_id: str | int,
+ api_key: str,
+ *,
+ product_ids: tuple[int, ...] = (),
+ pay_url: str = "",
+ credit_from: str = "cnt_goods",
+ ) -> None:
+ self.seller_id = str(seller_id or "").strip()
+ self.api_key = (api_key or "").strip()
+ self.product_ids = product_ids
+ self.pay_url = (pay_url or "").strip()
+ self.credit_from = (credit_from or "cnt_goods").strip().lower()
+ self._token: str | None = None
+ self._token_expires: float = 0.0
+
+ @property
+ def enabled(self) -> bool:
+ return bool(self.seller_id and self.api_key)
+
+ def _sign(self, timestamp: int) -> str:
+ raw = f"{self.api_key}{timestamp}".encode("utf-8")
+ return hashlib.sha256(raw).hexdigest()
+
+ async def _login(self) -> str:
+ now = time.time()
+ if self._token and now < self._token_expires - 60:
+ return self._token
+
+ timestamp = int(now)
+ payload = {
+ "seller_id": int(self.seller_id),
+ "timestamp": timestamp,
+ "sign": self._sign(timestamp),
+ }
+ data = await self._request("POST", "/api/apilogin", json_body=payload, auth=False)
+ if int(data.get("retval") or -1) != 0 or not data.get("token"):
+ raise DigisellerError(data.get("desc") or data.get("retdesc") or "login failed")
+ self._token = str(data["token"])
+ # токен живёт ~2 часа
+ self._token_expires = now + 110 * 60
+ return self._token
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ json_body: dict[str, Any] | None = None,
+ params: dict[str, Any] | None = None,
+ auth: bool = True,
+ ) -> dict[str, Any]:
+ if not self.enabled:
+ raise DigisellerError("Digiseller не настроен")
+
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
+ query = dict(params or {})
+ if auth:
+ query["token"] = await self._login()
+
+ last_exc: Exception | None = None
+ for base in (API_BASE, API_BASE_FALLBACK):
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.request(
+ method,
+ f"{base}{path}",
+ headers=headers,
+ params=query or None,
+ json=json_body,
+ )
+ if resp.status_code == 401 and auth:
+ self._token = None
+ query["token"] = await self._login()
+ resp = await client.request(
+ method,
+ f"{base}{path}",
+ headers=headers,
+ params=query,
+ json=json_body,
+ )
+ data = resp.json()
+ if not isinstance(data, dict):
+ raise DigisellerError(f"bad response: {resp.text[:200]}")
+ return data
+ except Exception as exc: # noqa: BLE001
+ last_exc = exc
+ logger.warning("Digiseller %s %s via %s failed: %s", method, path, base, exc)
+ raise DigisellerError(str(last_exc or "request failed"))
+
+ async def get_by_unique_code(self, unique_code: str) -> DigisellerPurchase:
+ code = "".join(ch for ch in unique_code.strip() if ch.isalnum())
+ if len(code) < 10:
+ raise DigisellerError("Некорректный уникальный код")
+
+ data = await self._request(
+ "GET",
+ f"/api/purchases/unique-code/{code}",
+ )
+ retval = int(data.get("retval") if data.get("retval") is not None else -1)
+ if retval != 0:
+ raise DigisellerError(
+ str(data.get("retdesc") or data.get("desc") or f"код ошибки {retval}")
+ )
+
+ state_obj = data.get("unique_code_state") or {}
+ state = int(state_obj.get("state") or 0)
+ inv = int(data.get("inv") or 0)
+ product_id = int(data.get("id_goods") or 0)
+ if not inv:
+ raise DigisellerError("В ответе Digiseller нет номера счёта (inv)")
+
+ return DigisellerPurchase(
+ unique_code=code,
+ inv=inv,
+ product_id=product_id,
+ amount=float(data.get("amount") or 0),
+ currency=str(data.get("type_curr") or ""),
+ cnt_goods=float(data.get("cnt_goods") or 0),
+ email=(str(data.get("email")).strip() if data.get("email") else None),
+ state=state,
+ raw=data,
+ )
+
+ def assert_payable(self, purchase: DigisellerPurchase) -> None:
+ """Проверка, что код можно принять к зачислению."""
+ # 1 — ещё не верифицирован (первый запрос), 5 — верифицирован, товар не выдан
+ # 2/3 — уже отмечен выданным (допускаем для дозачисления после сбоя)
+ # 4 — оспорен
+ if purchase.state == 4:
+ raise DigisellerError("Оплата по этому коду оспорена")
+ if purchase.state not in {1, 2, 3, 5}:
+ raise DigisellerError(
+ f"Код ещё не готов к выдаче (state={purchase.state}). "
+ "Оплати товар и попробуй снова."
+ )
+ date_pay = str((purchase.raw or {}).get("date_pay") or "").strip()
+ if not date_pay and purchase.state == 1:
+ raise DigisellerError("Код ещё не оплачен")
+
+ async def mark_delivered(self, unique_code: str) -> None:
+ code = "".join(ch for ch in unique_code.strip() if ch.isalnum())
+ data = await self._request(
+ "PUT",
+ f"/api/purchases/unique-code/{code}/deliver",
+ )
+ retval = int(data.get("retval") if data.get("retval") is not None else -1)
+ # 4 — уже выдано ранее (идемпотентно ок)
+ if retval not in (0, 4):
+ raise DigisellerError(
+ str(data.get("retdesc") or data.get("desc") or f"deliver error {retval}")
+ )
+
+ def credit_amount(self, purchase: DigisellerPurchase) -> float:
+ """Сколько рублей зачислить на баланс бота."""
+ if self.product_ids and purchase.product_id not in self.product_ids:
+ raise DigisellerError(
+ f"Товар id={purchase.product_id} не разрешён для пополнения"
+ )
+
+ if self.credit_from == "amount":
+ value = purchase.amount
+ else:
+ # Типичная схема: товар без фикс. цены, cnt_goods = сумма в ₽
+ value = purchase.cnt_goods if purchase.cnt_goods > 0 else purchase.amount
+
+ if value <= 0:
+ raise DigisellerError("Сумма пополнения равна 0")
+ return float(value)
+
+ def default_pay_url(self) -> str:
+ if self.pay_url:
+ return self.pay_url
+ if self.product_ids:
+ return f"https://oplata.info/asp2/pay.asp?id_d={self.product_ids[0]}"
+ return "https://my.digiseller.com/"
+
+ def pay_url_for(self, telegram_id: int) -> str:
+ """Ссылка на оплату с привязкой к Telegram ID (уходит в query_string)."""
+ base = self.default_pay_url()
+ parsed = urlparse(base)
+ q = parse_qs(parsed.query, keep_blank_values=True)
+ q["telegram_id"] = [str(int(telegram_id))]
+ # совместимость с разными интеграциями Digiseller
+ q["tg_id"] = [str(int(telegram_id))]
+ new_query = urlencode({k: v[-1] for k, v in q.items()})
+ return urlunparse(parsed._replace(query=new_query))
+
+ @staticmethod
+ def extract_telegram_id(purchase: DigisellerPurchase) -> int | None:
+ """Достать telegram_id из query_string / options платежа."""
+ raw = purchase.raw or {}
+
+ qs_b64 = str(raw.get("query_string") or "").strip()
+ if qs_b64:
+ try:
+ pad = "=" * (-len(qs_b64) % 4)
+ decoded = base64.b64decode(qs_b64 + pad).decode("utf-8", "ignore")
+ params = parse_qs(decoded, keep_blank_values=True)
+ for key in ("telegram_id", "tg_id", "tg", "user_id", "uid"):
+ vals = params.get(key) or []
+ if vals:
+ tid = int(str(vals[0]).strip())
+ if tid > 0:
+ return tid
+ except Exception: # noqa: BLE001
+ logger.debug("Digiseller query_string parse failed", exc_info=True)
+
+ for opt in raw.get("options") or []:
+ if not isinstance(opt, dict):
+ continue
+ name = str(opt.get("name") or "").strip().lower()
+ value = str(opt.get("value") or "").strip()
+ if not value:
+ continue
+ if any(
+ x in name
+ for x in ("telegram", "телеграм", "tg id", "tg_id", "userid", "user id")
+ ):
+ digits = "".join(ch for ch in value if ch.isdigit())
+ if digits:
+ tid = int(digits)
+ if tid > 0:
+ return tid
+ # иногда покупатель просто вписывает числовой ID
+ if value.isdigit() and len(value) >= 5:
+ return int(value)
+
+ return None
diff --git a/services/digiseller_fulfill.py b/services/digiseller_fulfill.py
new file mode 100644
index 0000000..92578a5
--- /dev/null
+++ b/services/digiseller_fulfill.py
@@ -0,0 +1,127 @@
+"""Автозачисление по уникальному коду Digiseller."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from aiogram import Bot
+
+from config import get_settings
+from services.db import Database
+from services.digiseller import Digiseller, DigisellerError
+from services.mailer import notify_important
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class DigisellerFulfillResult:
+ ok: bool
+ message: str
+ amount: float = 0.0
+ balance: float = 0.0
+ telegram_id: int | None = None
+ already: bool = False
+ inv: int | None = None
+
+
+async def fulfill_digiseller_unique_code(
+ *,
+ db: Database,
+ digiseller: Digiseller,
+ unique_code: str,
+ telegram_id_hint: int | None = None,
+ bot: Bot | None = None,
+ notify: bool = True,
+) -> DigisellerFulfillResult:
+ """Проверить код в Digiseller API и зачислить баланс. Идемпотентно."""
+ purchase = await digiseller.get_by_unique_code(unique_code)
+ digiseller.assert_payable(purchase)
+ amount = digiseller.credit_amount(purchase)
+
+ telegram_id = digiseller.extract_telegram_id(purchase) or telegram_id_hint
+ if not telegram_id:
+ raise DigisellerError(
+ "В платеже нет telegram_id. Открой оплату из бота "
+ "(ссылка с твоим ID) или введи код вручную в боте."
+ )
+
+ if db.digiseller_inv_used(purchase.inv) or db.digiseller_code_used(
+ purchase.unique_code
+ ):
+ balance = db.get_balance(telegram_id)
+ return DigisellerFulfillResult(
+ ok=True,
+ already=True,
+ message="Этот платёж уже был зачислен ранее.",
+ amount=amount,
+ balance=balance,
+ telegram_id=telegram_id,
+ inv=purchase.inv,
+ )
+
+ recorded = db.record_digiseller_payment(
+ inv=purchase.inv,
+ unique_code=purchase.unique_code,
+ telegram_id=telegram_id,
+ amount_rub=amount,
+ product_id=purchase.product_id,
+ )
+ if not recorded:
+ balance = db.get_balance(telegram_id)
+ return DigisellerFulfillResult(
+ ok=True,
+ already=True,
+ message="Этот платёж уже был зачислен ранее.",
+ amount=amount,
+ balance=balance,
+ telegram_id=telegram_id,
+ inv=purchase.inv,
+ )
+
+ balance = db.add_balance(
+ telegram_id,
+ amount,
+ kind="digiseller_topup",
+ meta=f"digi:{purchase.inv}:{purchase.unique_code}",
+ )
+
+ try:
+ await digiseller.mark_delivered(purchase.unique_code)
+ except DigisellerError as exc:
+ logger.warning(
+ "Digiseller deliver failed inv=%s: %s", purchase.inv, exc
+ )
+
+ text = (
+ f"✅ Баланс пополнен через Digiseller\n\n"
+ f"+{amount:.0f} ₽\n"
+ f"Счёт Digiseller: {purchase.inv}\n"
+ f"Доступно: {balance:.0f} ₽"
+ )
+
+ if notify:
+ try:
+ settings = get_settings(require_bot_token=False)
+ await notify_important(
+ db=db,
+ settings=settings,
+ telegram_id=telegram_id,
+ html=text,
+ subject="Баланс пополнен — VPN Service",
+ bot=bot,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "Digiseller notify failed tg=%s: %s", telegram_id, exc
+ )
+
+ return DigisellerFulfillResult(
+ ok=True,
+ message=text,
+ amount=amount,
+ balance=balance,
+ telegram_id=telegram_id,
+ inv=purchase.inv,
+ )
diff --git a/services/env_file.py b/services/env_file.py
new file mode 100644
index 0000000..fb5f290
--- /dev/null
+++ b/services/env_file.py
@@ -0,0 +1,194 @@
+"""Чтение/запись allowlist-ключей в .env (админка платежей)."""
+
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+
+from dotenv import dotenv_values
+
+from config import ROOT_DIR
+
+# Ключи, которыми можно управлять из админки
+PAYMENT_ENV_KEYS: tuple[str, ...] = (
+ "CRYPTO_BOT_TOKEN",
+ "CRYPTO_PAY_TESTNET",
+ "HELEKET_MERCHANT_ID",
+ "HELEKET_API_KEY",
+ "HELEKET_CALLBACK_URL",
+ "HELEKET_CURRENCY",
+ "LAVA_SHOP_ID",
+ "LAVA_SECRET_KEY",
+ "LAVA_ADDITIONAL_KEY",
+ "LAVA_HOOK_URL",
+ "LAVA_SUCCESS_URL",
+ "LAVA_FAIL_URL",
+ "LAVA_EXPIRE_MINUTES",
+ "DIGISELLER_SELLER_ID",
+ "DIGISELLER_API_KEY",
+ "DIGISELLER_PRODUCT_IDS",
+ "DIGISELLER_PAY_URL",
+ "DIGISELLER_CREDIT_FROM",
+ "DIGISELLER_CALLBACK_URL",
+ "ANYPAY_API_ID",
+ "ANYPAY_API_KEY",
+ "ANYPAY_PROJECT_ID",
+ "ANYPAY_CURRENCY",
+ "ANYPAY_METHOD",
+ "ANYPAY_SUCCESS_URL",
+ "ANYPAY_FAIL_URL",
+ "ANYPAY_EMAIL",
+ "ANYPAY_CHECK_IP",
+ "STARS_ENABLED",
+ "STARS_RUB_PER_STAR",
+ "TOPUP_CODE_PAY_URL",
+)
+
+SECRET_ENV_KEYS = frozenset(
+ {
+ "CRYPTO_BOT_TOKEN",
+ "HELEKET_API_KEY",
+ "LAVA_SECRET_KEY",
+ "LAVA_ADDITIONAL_KEY",
+ "DIGISELLER_API_KEY",
+ "ANYPAY_API_KEY",
+ "SMTP_PASSWORD",
+ }
+)
+
+BOOL_ENV_KEYS = frozenset(
+ {"CRYPTO_PAY_TESTNET", "STARS_ENABLED", "ANYPAY_CHECK_IP", "SMTP_TLS", "SMTP_SSL"}
+)
+
+_KEY_LINE_RE = re.compile(
+ r"^(?P
Address: {html.escape(OPERATOR_ADDRESS)}"
+ if OPERATOR_ADDRESS
+ else ""
+ )
+ return f"""
+
+
+
+
+ {title_e}
+
+ Operator: {html.escape(OPERATOR_NAME)}{addr}
+ Support: {html.escape(SUPPORT_EMAIL)}
+ 1. Термины
+2. Контакты исполнителя
+
+
+3. Предмет договора
+4. Заключение договора и акцепт
+
+
+5. Оказание услуги и «доставка»
+6. Цена и оплата
+7. Право на отказ (ЕС) и немедленное предоставление цифрового контента
+
+
+8. Возврат и отмена (США и общие положения)
+9. Правовая рамка и обязанности Заказчика
+
+
+10. Обработка персональных данных
+11. Качество услуги и отказ от гарантий
+12. Ограничение ответственности
+13. Санкции и экспортный контроль
+14. Приостановка и прекращение
+15. Изменение условий
+16. Применимое право и споры
+
+
+17. Заключительные положения
+1. Общие положения
+2. Оператор и контакты
+
+
+3. Какие данные обрабатываем
+
+
+4. Источники данных
+
+
+
+5. Cookies и аналогичные технологии (ePrivacy / GDPR)
+
+
+cookie_consent).
+ Основание: согласие (ст. 6(1)(a) GDPR) через «Принять все» или «Только необходимые».6. Цели и правовые основания (GDPR)
+
+
+
+7. VPN и минимизация данных
+8. Сроки хранения
+
+
+9. Передачи и обработчики
+10. Международные передачи
+11. Меры безопасности
+12. Дети
+13. Автоматизированные решения
+14. Ваши права
+15. Изменения политики
+16. Контакты
+1. Общие положения
+2. Регистрация и аккаунт
+3. Оплата и использование
+4. Лимит устройств
+5. Запрещённое использование
+6. Поддержка и споры
+7. Возвраты, вред и блокировки
+8. Блокировки VPN и простой серверов
+9. Изменение правил
+1. Назначение сервиса
+2. Тарифы и трафик: условно безлимитный
+3. Fair use и распределение ресурсов
+4. Меры при злоупотреблении трафиком или нагрузкой
+
+
+5. Применимое право и правовая рамка сервиса
+6. Законное использование и запрещённые действия
+
+
+
+7. Санкции, экспортный контроль и ограниченные лица
+8. Учётные данные и передача доступа
+9. Лимит устройств на подписку
+10. Стабильность и технические ограничения
+11. Запросы государственных органов
+12. Изменение документа
+1. Цифровой характер услуги
+2. Право на отказ — потребители ЕС (14 дней)
+3. Потребители США
+4. Возврат до использования
+5. Ошибочный платёж и chargeback
+6. Отказ в возврате
+7. Государственные и сетевые блокировки
+8. Недоступность по вине сервиса
+9. Сроки и способ возврата
+10. Споры (ЕС / США)
+1. Способ «доставки»
+2. Стоимость доставки
+3. Сроки
+4. Регионы
+5. Подтверждение получения
+6. Информация до покупки (ЕС / США)
+
{e(OPERATOR_NAME)}
+ Общие вопросы: {e(SUPPORT_EMAIL)}
+ Приватность и GDPR/CCPA: {e(SUPPORT_EMAIL)}
+ (тема: «Privacy request»)
+ {e(SUPPORT_EMAIL)}
+ Обращения через раздел Поддержка (тикеты) в кабинете.
+ Зарегистрированные пользователи могут создать тикет и получить ответ на email.Права потребителей и споры (ЕС / США)
+
+
+ С чего начать
+
+
+
+Регистрация и вход
+Баланс и оплата
+Подписка Remnawave
+https://… для мультисерверного доступа.
+У подписки есть лимит трафика и устройств (по тарифу и настройкам панели).
+При необходимости докупите трафик или слоты устройств в соответствующих разделах кабинета.Устройства и правила
+Поддержка
+cookie_consent) до 12 месяцев
+или до очистки данных сайта.
", "\n").replace("
", "\n").replace("
", "\n")
+ text = text.replace("
| + + {_e(cta_label)} + + | +
|
+
+ + © VPN Service + + |
+
| + {html_inner} + | +
Мы получили запрос на сброс пароля для вашего аккаунта VPN Service.
+Нажмите кнопку ниже, чтобы задать новый пароль. Ссылка действует 1 час.
+ {render_info_box( + "Безопасность. Если вы не запрашивали сброс — просто проигнорируйте это письмо. " + "Пароль не изменится, пока вы сами не подтвердите действие по ссылке.", + kind="warn", + )} +
+ Кнопка не работает? Скопируйте ссылку в браузер:
+ {_e(reset_url)}
+
Поздравляем — SMTP настроен правильно.
+Это тестовое письмо из админ-панели VPN Service. Теперь можно отправлять + сброс пароля и важные уведомления пользователям.
+ {render_info_box( + "Что дальше. Убедитесь, что у пользователей сохранён email в кабинете, " + "и проверьте папку «Спам», если письмо не видно во входящих.", + kind="info", + )} + """ + return render_email_layout( + title="SMTP работает", + preheader="Тестовое письмо — почта настроена верно", + body_html=body, + webapp_url=webapp_url, + cta_url=(webapp_url or "").rstrip("/") + "/admin" if webapp_url else "", + cta_label="Открыть админку" if webapp_url else "", + footer_note="Тест из раздела Админка → SMTP.", + ) + + +def render_notification_email( + *, + title: str, + body_html: str, + webapp_url: str = "", + preheader: str = "", +) -> str: + """Обернуть произвольное HTML-уведомление в фирменный шаблон.""" + return render_email_layout( + title=title, + preheader=preheader or title, + body_html=f'{sub_url}"
+ ),
+ balance=balance,
+ subscription_url=sub_url,
+ expire_at=expire_at,
+ )
+
+ paid_how = "с баланса" if charge_balance else "криптой"
+ return PurchaseResult(
+ ok=True,
+ message=(
+ f"✅ Тариф «{tariff.title}» активирован\n\n"
+ f"Оплата: {tariff.format_price()} ({paid_how})\n"
+ f"Баланс: {balance:.0f} ₽\n"
+ f"Действует до: {expire_str}\n\n"
+ f"🔗 Подписка:\n{sub_url}"
+ ),
+ balance=balance,
+ subscription_url=sub_url,
+ expire_at=expire_at,
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class PurchaseResult:
+ ok: bool
+ message: str
+ balance: float
+ subscription_url: str | None = None
+ expire_at: datetime | None = None
+ traffic: TrafficUsage | None = None
+ devices: DeviceUsage | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class SubscriptionInfo:
+ text: str
+ has_subscription: bool
+ expire_at: datetime | None = None
+ subscription_url: str | None = None
+ traffic: TrafficUsage | None = None
+ devices: DeviceUsage | None = None
+
+
+async def get_my_subscription_info(
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ *,
+ login: str | None = None,
+ db: Database | None = None,
+ tariffs: list[Tariff] | None = None,
+ renewal_bot=None,
+) -> SubscriptionInfo:
+ user = await _find_panel_user(sdk, telegram_id, login=login)
+ if user is None:
+ return SubscriptionInfo(
+ text=(
+ "Моя подписка\n\n"
+ "У тебя пока нет подписки.\n"
+ "Купи тариф в разделе «Тарифы».\n\n"
+ f"База: {format_gb(BASE_TRAFFIC_GB, digits=0)} · "
+ f"{BASE_DEVICES} устройств\n"
+ f"Докупка: +500 ГБ = 50 ₽/мес · +1 устройство = "
+ f"{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес\n"
+ "Доп. опции списываются с баланса каждые 30 дней."
+ ),
+ has_subscription=False,
+ )
+
+ bot_user = db.ensure_user(telegram_id) if db is not None else None
+ if db is not None:
+ await process_user_extras_billing(
+ db, sdk, telegram_id, login=login
+ )
+ if tariffs:
+ from services.subscription_renewal import (
+ process_user_subscription_renewal,
+ )
+
+ await process_user_subscription_renewal(
+ db,
+ sdk,
+ tariffs,
+ telegram_id,
+ bot=renewal_bot,
+ login=login,
+ )
+ bot_user = db.get_user(telegram_id)
+
+ extra_paid = effective_extra_devices(bot_user) if bot_user else 0
+ device_blocked = bot_user.device_blocked if bot_user else False
+
+ # Старые «безлимит» (0) → база 1000 ГБ
+ if float(getattr(user, "traffic_limit_bytes", 0) or 0) <= 0:
+ try:
+ user = await sdk.users.update_user(
+ UpdateUserRequestDto(
+ uuid=user.uuid,
+ traffic_limit_bytes=gb_to_bytes(BASE_TRAFFIC_GB),
+ traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
+ )
+ )
+ except Exception: # noqa: BLE001
+ pass
+
+ devices: DeviceUsage | None = None
+ if db is not None:
+ user, devices = await sync_device_policy(
+ sdk,
+ user,
+ db=db,
+ telegram_id=telegram_id,
+ extra_paid=extra_paid,
+ device_blocked=device_blocked,
+ )
+
+ expire = _as_utc(user.expire_at)
+ now = datetime.now(timezone.utc)
+ if devices and devices.over_limit:
+ status = "отключена (превышение устройств)"
+ elif expire > now and user.status == UserStatus.ACTIVE:
+ status = "активна"
+ else:
+ status = "истекла / неактивна"
+ traffic = traffic_from_panel_user(user)
+ devices_line = devices.format_line() if devices else f"📱 Устройства: база {BASE_DEVICES}"
+ extras_line = extras_billing_line(bot_user) if bot_user else ""
+ text = (
+ f"Моя подписка\n\n"
+ f"Статус: {status}\n"
+ f"До: {expire.strftime('%d.%m.%Y %H:%M UTC')}\n"
+ f"{devices_line}\n"
+ f"{traffic.format_line()}\n"
+ f"{extras_line}\n\n"
+ f"🔗 {user.subscription_url}\n\n"
+ f"Докупка: трафик +500 ГБ / 50 ₽/мес · устройство +1 / "
+ f"{int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес"
+ )
+ return SubscriptionInfo(
+ text=text,
+ has_subscription=True,
+ expire_at=expire,
+ subscription_url=user.subscription_url,
+ traffic=traffic,
+ devices=devices,
+ )
+
+
+async def purchase_device_pack(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ pack: DevicePack,
+ charge_balance: bool = True,
+ login: str | None = None,
+ payment_meta: str | None = None,
+) -> PurchaseResult:
+ bot_user = db.ensure_user(telegram_id)
+ balance = bot_user.balance
+ login = login or bot_user.login
+
+ existing = await _find_panel_user(sdk, telegram_id, login=login)
+ if existing is None:
+ return PurchaseResult(
+ ok=False,
+ message="Нет подписки. Сначала купи тариф (база 10 устройств).",
+ balance=balance,
+ )
+
+ if charge_balance:
+ ok, balance = db.try_charge(
+ telegram_id,
+ pack.price,
+ kind="devices",
+ meta=payment_meta or f"devices:{pack.id}",
+ )
+ if not ok:
+ return PurchaseResult(
+ ok=False,
+ message=(
+ f"Недостаточно средств.\n"
+ f"Нужно: {pack.format_price()}\n"
+ f"Баланс: {balance:.0f} ₽"
+ ),
+ balance=balance,
+ )
+
+ new_extra = db.add_extra_devices(telegram_id, pack.slots)
+ on_extras_purchased(db, telegram_id)
+ try:
+ updated, devices = await sync_device_policy(
+ sdk,
+ existing,
+ db=db,
+ telegram_id=telegram_id,
+ extra_paid=new_extra,
+ device_blocked=False,
+ )
+ except Exception as exc: # noqa: BLE001
+ if charge_balance:
+ # откат слотов и денег
+ with db.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET extra_devices = GREATEST(extra_devices - %s, 0)
+ WHERE telegram_id = %s
+ """,
+ (pack.slots, telegram_id),
+ )
+ conn.commit()
+ balance = db.add_balance(
+ telegram_id,
+ pack.price,
+ kind="refund",
+ meta=f"refund:devices:{pack.id}:{exc}",
+ )
+ return PurchaseResult(
+ ok=False,
+ message=(
+ "Не удалось обновить лимит устройств. Средства возвращены.\n\n"
+ + friendly_remnawave_error(exc)
+ ),
+ balance=balance,
+ )
+ return PurchaseResult(
+ ok=False,
+ message="Оплата прошла, но лимит не обновился. Напиши админу.",
+ balance=balance,
+ )
+
+ return PurchaseResult(
+ ok=True,
+ message=(
+ f"✅ Лимит устройств увеличен\n\n"
+ f"Добавлено: +{pack.slots}\n"
+ f"Списано: {pack.format_price()} (первый месяц)\n"
+ f"Далее — {int(EXTRA_DEVICE_PRICE_RUB)} ₽/мес за каждое доп. устройство с баланса.\n"
+ f"Баланс: {balance:.0f} ₽\n"
+ f"{devices.format_line()}"
+ ),
+ balance=balance,
+ subscription_url=getattr(updated, "subscription_url", None),
+ devices=devices,
+ )
+
+
+async def purchase_traffic_pack(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ pack: TrafficPack,
+ charge_balance: bool = True,
+ login: str | None = None,
+ payment_meta: str | None = None,
+) -> PurchaseResult:
+ bot_user = db.ensure_user(telegram_id)
+ balance = bot_user.balance
+ login = login or bot_user.login
+
+ existing = await _find_panel_user(sdk, telegram_id, login=login)
+ if existing is None:
+ return PurchaseResult(
+ ok=False,
+ message=(
+ "Нет активной подписки.\n"
+ "Сначала купи тариф — база 1000 ГБ уже входит."
+ ),
+ balance=balance,
+ )
+
+ if charge_balance:
+ ok, balance = db.try_charge(
+ telegram_id,
+ pack.price,
+ kind="traffic",
+ meta=payment_meta or f"traffic:{pack.id}",
+ )
+ if not ok:
+ return PurchaseResult(
+ ok=False,
+ message=(
+ f"Недостаточно средств.\n"
+ f"Нужно: {pack.format_price()} (первый месяц)\n"
+ f"Баланс: {balance:.0f} ₽"
+ ),
+ balance=balance,
+ )
+
+ db.add_extra_traffic_packs(telegram_id, pack.packs)
+ on_extras_purchased(db, telegram_id)
+
+ current_limit = ensure_base_limit_bytes(existing.traffic_limit_bytes)
+ new_limit = current_limit + gb_to_bytes(pack.gb)
+
+ try:
+ updated = await sdk.users.update_user(
+ UpdateUserRequestDto(
+ uuid=existing.uuid,
+ traffic_limit_bytes=new_limit,
+ traffic_limit_strategy=TrafficLimitStrategy.NO_RESET,
+ status=UserStatus.ACTIVE,
+ )
+ )
+ except Exception as exc: # noqa: BLE001
+ with db.pool.connection() as conn:
+ conn.execute(
+ """
+ UPDATE users
+ SET extra_traffic_packs = GREATEST(extra_traffic_packs - %s, 0)
+ WHERE telegram_id = %s
+ """,
+ (pack.packs, telegram_id),
+ )
+ conn.commit()
+ if charge_balance:
+ balance = db.add_balance(
+ telegram_id,
+ pack.price,
+ kind="refund",
+ meta=f"refund:traffic:{pack.id}:{exc}",
+ )
+ return PurchaseResult(
+ ok=False,
+ message=(
+ "Не удалось увеличить трафик. Средства возвращены.\n\n"
+ + friendly_remnawave_error(exc)
+ ),
+ balance=balance,
+ )
+ return PurchaseResult(
+ ok=False,
+ message="Оплата прошла, но лимит не обновился. Напиши админу.\n\n"
+ + friendly_remnawave_error(exc),
+ balance=balance,
+ )
+
+ traffic = traffic_from_panel_user(updated)
+ try:
+ from services.xui_wg import sync_wg_configs_limits
+ from services.awg_configs import sync_awg_configs_expiry
+
+ await sync_wg_configs_limits(
+ db=db,
+ telegram_id=telegram_id,
+ traffic_bytes=int(new_limit),
+ expire_at=_as_utc(updated.expire_at) if updated.expire_at else None,
+ restore_enabled=True,
+ )
+ await sync_awg_configs_expiry(
+ db=db,
+ sdk=sdk,
+ telegram_id=telegram_id,
+ login=login,
+ restore_enabled=True,
+ )
+ except Exception: # noqa: BLE001
+ logger.exception("WG/AWG sync after pack purchase failed")
+
+ return PurchaseResult(
+ ok=True,
+ message=(
+ f"✅ Трафик увеличен\n\n"
+ f"Добавлено: {format_gb(pack.gb, digits=0)}\n"
+ f"Списано: {pack.format_price()} (первый месяц)\n"
+ f"Далее — {int(pack.price / pack.packs)} ₽/мес за каждые 500 ГБ с баланса.\n"
+ f"Баланс: {balance:.0f} ₽\n"
+ f"{traffic.format_line()}"
+ ),
+ balance=balance,
+ subscription_url=updated.subscription_url,
+ expire_at=_as_utc(updated.expire_at) if updated.expire_at else None,
+ traffic=traffic,
+ )
+
+
+async def get_my_subscription(sdk: RemnawaveSDK, telegram_id: int) -> str:
+ return (await get_my_subscription_info(sdk, telegram_id)).text
diff --git a/services/qrcode_svg.py b/services/qrcode_svg.py
new file mode 100644
index 0000000..f4b82a2
--- /dev/null
+++ b/services/qrcode_svg.py
@@ -0,0 +1,26 @@
+"""Генерация QR (SVG) без Pillow — только пакет qrcode."""
+
+from __future__ import annotations
+
+import io
+
+import qrcode
+import qrcode.image.svg
+
+
+def make_qr_svg(data: str, *, scale: int = 4, border: int = 2) -> bytes:
+ """Возвращает SVG QR как bytes (image/svg+xml)."""
+ text = (data or "").strip()
+ if not text:
+ raise ValueError("empty qr data")
+ factory = qrcode.image.svg.SvgPathImage
+ img = qrcode.make(
+ text,
+ error_correction=qrcode.constants.ERROR_CORRECT_M,
+ box_size=max(2, int(scale)),
+ border=max(1, int(border)),
+ image_factory=factory,
+ )
+ buf = io.BytesIO()
+ img.save(buf)
+ return buf.getvalue()
diff --git a/services/rate_limit.py b/services/rate_limit.py
new file mode 100644
index 0000000..7ac7312
--- /dev/null
+++ b/services/rate_limit.py
@@ -0,0 +1,41 @@
+"""Простой in-memory rate limit (на процесс)."""
+
+from __future__ import annotations
+
+import time
+from collections import defaultdict
+from threading import Lock
+
+
+class RateLimiter:
+ def __init__(self) -> None:
+ self._hits: dict[str, list[float]] = defaultdict(list)
+ self._lock = Lock()
+
+ def hit(self, key: str, *, limit: int, window_sec: float) -> bool:
+ """True если запрос разрешён, False если превышен лимит."""
+ now = time.monotonic()
+ with self._lock:
+ bucket = self._hits[key]
+ cutoff = now - window_sec
+ self._hits[key] = [t for t in bucket if t >= cutoff]
+ if len(self._hits[key]) >= limit:
+ return False
+ self._hits[key].append(now)
+ return True
+
+
+limiter = RateLimiter()
+
+
+def client_ip(request) -> str:
+ forwarded = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
+ if forwarded:
+ return forwarded
+ peer = request.remote
+ return str(peer or "unknown")
+
+
+def allow(request, bucket: str, *, limit: int = 20, window_sec: float = 60.0) -> bool:
+ ip = client_ip(request)
+ return limiter.hit(f"{bucket}:{ip}", limit=limit, window_sec=window_sec)
diff --git a/services/remnawave_users.py b/services/remnawave_users.py
new file mode 100644
index 0000000..dd683c4
--- /dev/null
+++ b/services/remnawave_users.py
@@ -0,0 +1,115 @@
+"""Remnawave: поиск пользователя и синхронизация лимита устройств."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from remnawave import RemnawaveSDK
+from remnawave.enums import UserStatus
+from remnawave.models import UpdateUserRequestDto
+
+from services.db import Database
+from services.devices import (
+ BASE_DEVICES,
+ PANEL_SOFT_CAP,
+ DeviceUsage,
+ allowed_devices,
+)
+
+
+def as_utc(dt: datetime) -> datetime:
+ if dt.tzinfo is None:
+ return dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+
+
+def username_for(telegram_id: int, *, login: str | None = None) -> str:
+ if telegram_id > 0:
+ return f"tg_{telegram_id}"
+ if login:
+ safe = "".join(ch for ch in login if ch.isalnum() or ch == "_")[:24] or "user"
+ return f"web_{safe}"
+ return f"web_{abs(telegram_id)}"
+
+
+async def find_panel_user(
+ sdk: RemnawaveSDK, telegram_id: int, *, login: str | None = None
+):
+ if telegram_id > 0:
+ try:
+ users = await sdk.users.get_users_by_telegram_id(str(telegram_id))
+ if users:
+ return users[0]
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ return await sdk.users.get_user_by_username(
+ username_for(telegram_id, login=login)
+ )
+ except Exception: # noqa: BLE001
+ return None
+
+
+def device_limit_for_user(extra_paid: int, *, soft_cap: bool = True) -> int:
+ allowed = allowed_devices(extra_paid)
+ if soft_cap:
+ return max(allowed, min(PANEL_SOFT_CAP, max(allowed * 2, BASE_DEVICES + 20)))
+ return allowed
+
+
+async def count_hwid_devices(sdk: RemnawaveSDK, user_uuid: str) -> int:
+ try:
+ resp = await sdk.hwid.get_hwid_user(str(user_uuid))
+ return int(getattr(resp, "total", None) or len(getattr(resp, "devices", []) or []))
+ except Exception: # noqa: BLE001
+ return 0
+
+
+async def sync_device_policy(
+ sdk: RemnawaveSDK,
+ panel_user,
+ *,
+ db: Database,
+ telegram_id: int,
+ extra_paid: int,
+ device_blocked: bool,
+) -> tuple[object, DeviceUsage]:
+ """Синхронизация лимита HWID и отключение при превышении оплаченной квоты."""
+ used = await count_hwid_devices(sdk, str(panel_user.uuid))
+ allowed = allowed_devices(extra_paid)
+ over = used > allowed
+ soft_limit = device_limit_for_user(extra_paid, soft_cap=True)
+
+ updates: dict = {
+ "uuid": panel_user.uuid,
+ "hwid_device_limit": soft_limit,
+ }
+
+ if over:
+ updates["status"] = UserStatus.DISABLED
+ if not device_blocked:
+ db.set_device_blocked(telegram_id, True)
+ elif device_blocked:
+ expire = as_utc(panel_user.expire_at)
+ if expire > datetime.now(timezone.utc):
+ updates["status"] = UserStatus.ACTIVE
+ db.set_device_blocked(telegram_id, False)
+
+ try:
+ current_limit = int(getattr(panel_user, "hwid_device_limit", 0) or 0)
+ current_status = panel_user.status
+ need_update = current_limit != soft_limit
+ if "status" in updates and updates["status"] != current_status:
+ need_update = True
+ if need_update:
+ panel_user = await sdk.users.update_user(UpdateUserRequestDto(**updates))
+ except Exception: # noqa: BLE001
+ pass
+
+ usage = DeviceUsage(
+ used=used,
+ allowed=allowed,
+ extra_paid=max(0, int(extra_paid)),
+ over_limit=over,
+ )
+ return panel_user, usage
diff --git a/services/smtp_runtime.py b/services/smtp_runtime.py
new file mode 100644
index 0000000..45c7dd0
--- /dev/null
+++ b/services/smtp_runtime.py
@@ -0,0 +1,90 @@
+"""SMTP runtime: публичный payload и запись в .env из админки."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from config import Settings, get_settings
+from services.env_file import (
+ BOOL_ENV_KEYS,
+ SECRET_ENV_KEYS,
+ env_file_path,
+ env_writable,
+ mask_secret,
+ upsert_env_keys,
+)
+from services.mailer import smtp_configured
+
+SMTP_ENV_KEYS: tuple[str, ...] = (
+ "SMTP_HOST",
+ "SMTP_PORT",
+ "SMTP_USER",
+ "SMTP_PASSWORD",
+ "SMTP_FROM",
+ "SMTP_TLS",
+ "SMTP_SSL",
+)
+
+
+def smtp_public_dict(settings: Settings) -> dict[str, Any]:
+ path = env_file_path()
+ return {
+ "env_path": str(path),
+ "env_writable": env_writable(path),
+ "configured": smtp_configured(settings),
+ "host": settings.smtp_host,
+ "port": settings.smtp_port,
+ "user": settings.smtp_user,
+ "password_set": bool(settings.smtp_password),
+ "password_hint": mask_secret(settings.smtp_password),
+ "from_email": settings.smtp_from,
+ "tls": bool(settings.smtp_tls),
+ "ssl": bool(settings.smtp_ssl),
+ }
+
+
+def _is_masked_placeholder(value: str) -> bool:
+ v = (value or "").strip()
+ if not v:
+ return True
+ return "…" in v or "•" in v or "..." in v
+
+
+def apply_smtp_body(body: dict[str, Any]) -> Settings:
+ updates: dict[str, str | None] = {}
+ mapping = {
+ "SMTP_HOST": body.get("smtp_host"),
+ "SMTP_PORT": body.get("smtp_port"),
+ "SMTP_USER": body.get("smtp_user"),
+ "SMTP_PASSWORD": body.get("smtp_password"),
+ "SMTP_FROM": body.get("smtp_from"),
+ "SMTP_TLS": body.get("smtp_tls"),
+ "SMTP_SSL": body.get("smtp_ssl"),
+ }
+ for env_key, raw in mapping.items():
+ if raw is None:
+ continue
+ if env_key in BOOL_ENV_KEYS:
+ if isinstance(raw, bool):
+ updates[env_key] = "true" if raw else "false"
+ else:
+ updates[env_key] = (
+ "true"
+ if str(raw).strip().lower() in {"1", "true", "yes", "on"}
+ else "false"
+ )
+ continue
+ text = str(raw).strip()
+ if env_key in SECRET_ENV_KEYS:
+ if text == "__clear__":
+ updates[env_key] = None
+ elif _is_masked_placeholder(text):
+ continue
+ else:
+ updates[env_key] = text
+ continue
+ updates[env_key] = text if text else None
+
+ if updates:
+ upsert_env_keys(updates, allowlist=SMTP_ENV_KEYS)
+ return get_settings(require_bot_token=False)
diff --git a/services/stars.py b/services/stars.py
new file mode 100644
index 0000000..85e575f
--- /dev/null
+++ b/services/stars.py
@@ -0,0 +1,135 @@
+"""Telegram Stars: курс, пакеты и создание инвойсов."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import Any
+
+from aiogram.types import LabeledPrice
+
+from services.db import Database
+
+# Суммы пополнения в рублях (как у крипты)
+STAR_TOPUP_AMOUNTS_RUB: tuple[int, ...] = (100, 200, 500, 1000, 2000)
+
+
+@dataclass(frozen=True, slots=True)
+class StarsQuote:
+ amount_rub: float
+ stars: int
+ rub_per_star: float
+
+ @property
+ def stars_label(self) -> str:
+ return f"{self.stars} ⭐"
+
+ @property
+ def rub_label(self) -> str:
+ if self.amount_rub == int(self.amount_rub):
+ return f"{int(self.amount_rub)} ₽"
+ return f"{self.amount_rub:.2f} ₽"
+
+
+def rub_to_stars(amount_rub: float, *, rub_per_star: float) -> int:
+ rate = float(rub_per_star)
+ if rate <= 0:
+ raise ValueError("rub_per_star must be > 0")
+ stars = int(math.ceil(float(amount_rub) / rate))
+ return max(1, stars)
+
+
+def quote_topup(amount_rub: float, *, rub_per_star: float) -> StarsQuote:
+ amount = float(amount_rub)
+ if amount <= 0:
+ raise ValueError("amount_rub must be > 0")
+ stars = rub_to_stars(amount, rub_per_star=rub_per_star)
+ return StarsQuote(amount_rub=amount, stars=stars, rub_per_star=float(rub_per_star))
+
+
+def parse_stars_payload(payload: str) -> tuple[int, int, float] | None:
+ """
+ payload: stars:{invoice_id}:{telegram_id}:{amount_rub}
+ → (invoice_id, telegram_id, amount_rub)
+ """
+ parts = (payload or "").split(":")
+ if len(parts) != 4 or parts[0] != "stars":
+ return None
+ try:
+ return int(parts[1]), int(parts[2]), float(parts[3])
+ except (TypeError, ValueError):
+ return None
+
+
+def build_stars_payload(*, invoice_id: int, telegram_id: int, amount_rub: float) -> str:
+ return f"stars:{invoice_id}:{telegram_id}:{int(amount_rub)}"
+
+
+def packs_payload(*, rub_per_star: float) -> list[dict[str, Any]]:
+ out: list[dict[str, Any]] = []
+ for amount in STAR_TOPUP_AMOUNTS_RUB:
+ q = quote_topup(amount, rub_per_star=rub_per_star)
+ out.append(
+ {
+ "amount_rub": q.amount_rub,
+ "stars": q.stars,
+ "rub_label": q.rub_label,
+ "stars_label": q.stars_label,
+ "title": f"{q.rub_label} · {q.stars_label}",
+ }
+ )
+ return out
+
+
+async def create_stars_topup(
+ *,
+ bot: Any,
+ db: Database,
+ telegram_id: int,
+ amount_rub: float,
+ rub_per_star: float,
+) -> dict[str, Any]:
+ """Создать локальный инвойс + invoice link для Mini App / Telegram Web."""
+ if telegram_id <= 0:
+ raise ValueError("Stars доступны только для Telegram-аккаунта")
+ quote = quote_topup(amount_rub, rub_per_star=rub_per_star)
+ local_id = db.next_local_invoice_id()
+ payload = build_stars_payload(
+ invoice_id=local_id,
+ telegram_id=telegram_id,
+ amount_rub=quote.amount_rub,
+ )
+ invoice_link = await bot.create_invoice_link(
+ title="Пополнение баланса",
+ description=(
+ f"Зачисление {quote.rub_label} на баланс VPN Service "
+ f"({quote.stars_label})"
+ ),
+ payload=payload,
+ currency="XTR",
+ prices=[
+ LabeledPrice(
+ label=f"Баланс {quote.rub_label}",
+ amount=quote.stars,
+ )
+ ],
+ provider_token="",
+ )
+ db.create_invoice_record(
+ telegram_id=telegram_id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=quote.amount_rub,
+ pay_url=invoice_link,
+ payload=payload,
+ provider="stars",
+ )
+ return {
+ "invoice_id": local_id,
+ "invoice_link": invoice_link,
+ "amount_rub": quote.amount_rub,
+ "stars": quote.stars,
+ "rub_label": quote.rub_label,
+ "stars_label": quote.stars_label,
+ "payload": payload,
+ }
diff --git a/services/subscription_renewal.py b/services/subscription_renewal.py
new file mode 100644
index 0000000..3cddba4
--- /dev/null
+++ b/services/subscription_renewal.py
@@ -0,0 +1,254 @@
+"""Напоминания об окончании подписки и автопродление с баланса."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from datetime import date, datetime, timedelta, timezone
+
+from aiogram import Bot
+from remnawave import RemnawaveSDK
+
+from config import get_settings
+from services.db import Database
+from services.mailer import notify_important
+from services.remnawave_users import find_panel_user
+from services.purchase import purchase_tariff
+from services.tariffs import Tariff, get_tariff
+
+logger = logging.getLogger(__name__)
+
+REMIND_WITHIN_DAYS = 7
+# Автопродление: в последние сутки или уже истекло (до 3 суток простоя)
+AUTO_RENEW_MAX_PAST_DAYS = 3
+
+
+def _utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _as_utc(dt: datetime) -> datetime:
+ if dt.tzinfo is None:
+ return dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+
+
+def _days_left(expire_at: datetime) -> float:
+ return (_as_utc(expire_at) - _utc_now()).total_seconds() / 86400.0
+
+
+def _utc_today() -> date:
+ return _utc_now().date()
+
+
+async def _notify(
+ bot: Bot | None,
+ telegram_id: int,
+ text: str,
+ *,
+ db: Database | None = None,
+ subject: str = "VPN Service",
+) -> None:
+ if db is not None:
+ try:
+ settings = get_settings(require_bot_token=False)
+ except SystemExit:
+ settings = None
+ if settings is not None:
+ await notify_important(
+ db=db,
+ settings=settings,
+ telegram_id=telegram_id,
+ html=text,
+ subject=subject,
+ bot=bot,
+ )
+ return
+ if bot is None or telegram_id <= 0:
+ return
+ try:
+ await bot.send_message(telegram_id, text, parse_mode="HTML")
+ except Exception: # noqa: BLE001
+ logger.warning("renewal notify failed tg=%s", telegram_id)
+
+
+def _format_days_left(days: float) -> str:
+ if days < 0:
+ n = int(abs(days))
+ if n == 0:
+ return "истекла сегодня"
+ return f"истекла {n} дн. назад"
+ if days < 1:
+ return "менее суток"
+ n = int(round(days))
+ if n == 1:
+ return "1 день"
+ if 2 <= n <= 4:
+ return f"{n} дня"
+ return f"{n} дней"
+
+
+async def process_user_subscription_renewal(
+ db: Database,
+ sdk: RemnawaveSDK,
+ tariffs: list[Tariff],
+ telegram_id: int,
+ *,
+ bot: Bot | None = None,
+ login: str | None = None,
+) -> None:
+ if telegram_id <= 0:
+ return
+
+ bot_user = db.get_user(telegram_id)
+ login = login or (bot_user.login if bot_user else None)
+
+ panel_user = await find_panel_user(sdk, telegram_id, login=login)
+ if panel_user is None or not panel_user.expire_at:
+ return
+
+ expire = _as_utc(panel_user.expire_at)
+ days = _days_left(expire)
+
+ if days > REMIND_WITHIN_DAYS:
+ return
+
+ tariff_id = db.get_last_tariff_id(telegram_id)
+ if not tariff_id:
+ return
+ tariff = get_tariff(tariffs, tariff_id)
+ if not tariff:
+ return
+
+ balance = db.get_balance(telegram_id)
+ can_auto = balance >= tariff.price
+
+ bot_user = db.get_user(telegram_id)
+ device_blocked = bool(bot_user and bot_user.device_blocked)
+ auto_renew_on = bool(bot_user.auto_renew) if bot_user else True
+
+ # Автопродление — только если пользователь включил опцию
+ should_auto = days <= 1.0 or (
+ days < 0 and days >= -AUTO_RENEW_MAX_PAST_DAYS
+ )
+ if should_auto and auto_renew_on and can_auto and not device_blocked:
+ result = await purchase_tariff(
+ db=db,
+ sdk=sdk,
+ telegram_id=telegram_id,
+ tariff=tariff,
+ charge_balance=True,
+ renew=True,
+ payment_meta=f"auto_renew:{tariff.id}",
+ )
+ if result.ok:
+ await _notify(
+ bot,
+ telegram_id,
+ (
+ "✅ Подписка продлена автоматически\n\n"
+ f"Тариф: {tariff.title}\n"
+ f"Списано: {tariff.format_price()} с баланса\n"
+ f"Баланс: {result.balance:.0f} ₽\n\n"
+ "Отключить автопродление можно в кабинете в любой момент."
+ ),
+ db=db,
+ subject="Подписка продлена — VPN Service",
+ )
+ db.mark_expiry_reminder_sent(telegram_id, _utc_today())
+ return
+ logger.info(
+ "auto renew failed tg=%s: %s",
+ telegram_id,
+ result.message[:120],
+ )
+
+ # Напоминание раз в сутки (UTC)
+ if db.expiry_reminder_sent_on(telegram_id) == _utc_today():
+ return
+
+ expire_label = expire.strftime("%d.%m.%Y %H:%M UTC")
+ left_label = _format_days_left(days)
+ lines = [
+ "⏳ Подписка скоро закончится\n",
+ f"Осталось: {left_label}\n",
+ f"Действует до: {expire_label}\n",
+ f"Тариф для продления: {tariff.title} · {tariff.format_price()}\n",
+ f"Баланс: {balance:.0f} ₽\n",
+ ]
+ if auto_renew_on and can_auto and days <= 1.0:
+ lines.append(
+ "\nАвтопродление включено — при достаточном балансе подписка продлится сама."
+ )
+ elif auto_renew_on and can_auto:
+ lines.append(
+ f"\nАвтопродление включено. За сутки до окончания спишем "
+ f"{tariff.format_price()} с баланса."
+ )
+ elif auto_renew_on and device_blocked:
+ lines.append(
+ "\n⚠️ Подписка отключена из‑за превышения устройств — "
+ "сначала оплати доп. слоты или отвяжи лишние устройства."
+ )
+ elif auto_renew_on:
+ need = max(0.0, tariff.price - balance)
+ lines.append(
+ f"\nПополни баланс минимум на {need:.0f} ₽ "
+ f"для автопродления или продли вручную в боте / Mini App."
+ )
+ else:
+ lines.append(
+ "\nАвтопродление выключено. Включите в кабинете или продлите тариф вручную."
+ )
+
+ await _notify(
+ bot,
+ telegram_id,
+ "".join(lines),
+ db=db,
+ subject="Подписка скоро закончится — VPN Service",
+ )
+ db.mark_expiry_reminder_sent(telegram_id, _utc_today())
+
+
+async def run_subscription_renewal_cycle(
+ db: Database,
+ sdk: RemnawaveSDK,
+ tariffs: list[Tariff],
+ *,
+ bot: Bot | None = None,
+ limit: int = 500,
+) -> int:
+ ids = db.list_telegram_ids_with_purchases(limit=limit)
+ done = 0
+ for tid in ids:
+ try:
+ await process_user_subscription_renewal(
+ db, sdk, tariffs, tid, bot=bot
+ )
+ done += 1
+ except Exception: # noqa: BLE001
+ logger.exception("subscription renewal tg=%s", tid)
+ return done
+
+
+async def subscription_renewal_worker(
+ db: Database,
+ sdk: RemnawaveSDK,
+ bot: Bot,
+ tariffs: list[Tariff],
+ *,
+ interval_sec: int = 6 * 3600,
+) -> None:
+ """Проверка несколько раз в сутки; напоминание — не чаще 1 раза в день на пользователя."""
+ await asyncio.sleep(90)
+ while True:
+ try:
+ n = await run_subscription_renewal_cycle(
+ db, sdk, tariffs, bot=bot
+ )
+ if n:
+ logger.info("subscription renewal cycle: %s users", n)
+ except Exception: # noqa: BLE001
+ logger.exception("subscription renewal cycle error")
+ await asyncio.sleep(interval_sec)
diff --git a/services/tariffs.py b/services/tariffs.py
new file mode 100644
index 0000000..3703013
--- /dev/null
+++ b/services/tariffs.py
@@ -0,0 +1,188 @@
+"""Тарифы из tariffs.json."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from uuid import UUID
+
+TARIFF_ID_RE = re.compile(r"^[A-Za-z0-9_]{2,40}$")
+
+
+class TariffError(ValueError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class Tariff:
+ id: str
+ title: str
+ price: float
+ days: int
+ traffic_gb: float
+ device_limit: int
+ squad_uuids: tuple[UUID, ...]
+ description: str = ""
+
+ @property
+ def traffic_bytes(self) -> float:
+ # 0 или меньше → базовая квота 1000 ГБ
+ from services.traffic import BASE_TRAFFIC_GB, gb_to_bytes
+
+ gb = self.traffic_gb if self.traffic_gb > 0 else BASE_TRAFFIC_GB
+ return gb_to_bytes(gb)
+
+ def format_price(self) -> str:
+ if self.price == int(self.price):
+ return f"{int(self.price)} ₽"
+ return f"{self.price:.2f} ₽"
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "title": self.title,
+ "price": self.price if self.price != int(self.price) else int(self.price),
+ "days": self.days,
+ "traffic_gb": self.traffic_gb,
+ "device_limit": self.device_limit,
+ "squad_uuids": [str(u) for u in self.squad_uuids],
+ "description": self.description,
+ }
+
+ def to_admin_dict(self) -> dict:
+ d = self.to_dict()
+ d["price_label"] = self.format_price()
+ return d
+
+
+def load_tariffs(path: str | Path) -> list[Tariff]:
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ tariffs: list[Tariff] = []
+ for item in data:
+ tariffs.append(_tariff_from_item(item))
+ return tariffs
+
+
+def save_tariffs(path: str | Path, tariffs: list[Tariff]) -> None:
+ payload = [t.to_dict() for t in tariffs]
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
+ Path(path).write_text(text, encoding="utf-8")
+
+
+def replace_shared_tariffs(shared: list[Tariff], new: list[Tariff]) -> None:
+ """Обновить общий список in-place (bot + webapp + workers)."""
+ shared[:] = list(new)
+
+
+def get_tariff(tariffs: list[Tariff], tariff_id: str) -> Tariff | None:
+ for t in tariffs:
+ if t.id == tariff_id:
+ return t
+ return None
+
+
+def parse_tariff_payload(
+ body: dict,
+ *,
+ existing: Tariff | None = None,
+ default_squads: tuple[UUID, ...] = (),
+) -> Tariff:
+ tid = str(body.get("id") or (existing.id if existing else "")).strip()
+ if not TARIFF_ID_RE.fullmatch(tid):
+ raise TariffError("ID: 2–40 символов (латиница, цифры, _)")
+
+ title = str(body.get("title") or (existing.title if existing else "")).strip()
+ if not title:
+ raise TariffError("Укажите название тарифа")
+ if len(title) > 80:
+ raise TariffError("Название слишком длинное")
+
+ try:
+ price = float(body.get("price") if "price" in body else (existing.price if existing else 0))
+ except (TypeError, ValueError) as exc:
+ raise TariffError("Некорректная цена") from exc
+ if price < 0:
+ raise TariffError("Цена не может быть отрицательной")
+
+ try:
+ days = int(body.get("days") if "days" in body else (existing.days if existing else 0))
+ except (TypeError, ValueError) as exc:
+ raise TariffError("Некорректное число дней") from exc
+ if days < 1 or days > 3650:
+ raise TariffError("Дни: от 1 до 3650")
+
+ try:
+ traffic_gb = float(
+ body.get("traffic_gb")
+ if "traffic_gb" in body
+ else (existing.traffic_gb if existing else 1000)
+ )
+ except (TypeError, ValueError) as exc:
+ raise TariffError("Некорректный трафик") from exc
+ if traffic_gb < 0:
+ raise TariffError("Трафик не может быть отрицательным")
+
+ try:
+ device_limit = int(
+ body.get("device_limit")
+ if "device_limit" in body
+ else (existing.device_limit if existing else 10)
+ )
+ except (TypeError, ValueError) as exc:
+ raise TariffError("Некорректный лимит устройств") from exc
+ if device_limit < 1 or device_limit > 100:
+ raise TariffError("Устройства: от 1 до 100")
+
+ if "squad_uuids" in body:
+ squads_raw = body.get("squad_uuids")
+ if isinstance(squads_raw, str):
+ parts = [p.strip() for p in squads_raw.replace(";", ",").split(",") if p.strip()]
+ elif isinstance(squads_raw, list):
+ parts = [str(x).strip() for x in squads_raw if str(x).strip()]
+ else:
+ parts = []
+ try:
+ squad_uuids = tuple(UUID(p) for p in parts)
+ except ValueError as exc:
+ raise TariffError("Некорректный UUID в squad_uuids") from exc
+ elif existing:
+ squad_uuids = existing.squad_uuids
+ else:
+ squad_uuids = default_squads
+
+ if not squad_uuids:
+ raise TariffError("Нужен хотя бы один squad UUID")
+
+ description = str(
+ body.get("description")
+ if "description" in body
+ else (existing.description if existing else "")
+ ).strip()
+ if len(description) > 300:
+ raise TariffError("Описание слишком длинное")
+
+ return Tariff(
+ id=tid,
+ title=title,
+ price=price,
+ days=days,
+ traffic_gb=traffic_gb,
+ device_limit=device_limit,
+ squad_uuids=squad_uuids,
+ description=description,
+ )
+
+
+def _tariff_from_item(item: dict) -> Tariff:
+ return Tariff(
+ id=str(item["id"]),
+ title=str(item["title"]),
+ price=float(item["price"]),
+ days=int(item["days"]),
+ traffic_gb=float(item.get("traffic_gb") or 0),
+ device_limit=int(item.get("device_limit") or 1),
+ squad_uuids=tuple(UUID(str(u)) for u in item.get("squad_uuids") or []),
+ description=str(item.get("description") or ""),
+ )
diff --git a/services/tg_webapp.py b/services/tg_webapp.py
new file mode 100644
index 0000000..d537e0a
--- /dev/null
+++ b/services/tg_webapp.py
@@ -0,0 +1,193 @@
+"""Валидация Telegram WebApp initData, Login Widget и OIDC id_token."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import logging
+import time
+from dataclasses import dataclass
+from typing import Any, Mapping
+from urllib.parse import parse_qsl
+
+import jwt
+from jwt import PyJWKClient
+
+logger = logging.getLogger(__name__)
+
+_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json"
+_ISSUER = "https://oauth.telegram.org"
+_jwks_client: PyJWKClient | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class WebAppUser:
+ id: int
+ first_name: str
+ last_name: str | None = None
+ username: str | None = None
+ language_code: str | None = None
+ is_premium: bool = False
+
+ @property
+ def full_name(self) -> str:
+ if self.last_name:
+ return f"{self.first_name} {self.last_name}"
+ return self.first_name
+
+
+class InitDataError(ValueError):
+ pass
+
+
+def telegram_client_id_from_token(bot_token: str) -> str:
+ """Числовой Client ID бота = часть BOT_TOKEN до ':'."""
+ raw = (bot_token or "").strip()
+ if ":" in raw:
+ return raw.split(":", 1)[0].strip()
+ return raw
+
+
+def parse_and_validate_init_data(
+ init_data: str,
+ bot_token: str,
+ *,
+ max_age_sec: int = 86400,
+) -> WebAppUser:
+ if not init_data or not init_data.strip():
+ raise InitDataError("empty initData")
+
+ pairs = dict(parse_qsl(init_data, keep_blank_values=True))
+ received_hash = pairs.pop("hash", None)
+ if not received_hash:
+ raise InitDataError("missing hash")
+
+ data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items()))
+ secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
+ calculated = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
+ if not hmac.compare_digest(calculated, received_hash):
+ raise InitDataError("invalid hash")
+
+ auth_date = int(pairs.get("auth_date") or 0)
+ if auth_date and abs(time.time() - auth_date) > max_age_sec:
+ raise InitDataError("initData expired")
+
+ user_raw = pairs.get("user")
+ if not user_raw:
+ raise InitDataError("missing user")
+ user = json.loads(user_raw)
+ return WebAppUser(
+ id=int(user["id"]),
+ first_name=str(user.get("first_name") or "User"),
+ last_name=user.get("last_name"),
+ username=user.get("username"),
+ language_code=user.get("language_code"),
+ is_premium=bool(user.get("is_premium")),
+ )
+
+
+def parse_and_validate_login_widget(
+ data: Mapping[str, Any],
+ bot_token: str,
+ *,
+ max_age_sec: int = 86400,
+) -> WebAppUser:
+ """Проверка данных legacy Telegram Login Widget (HMAC)."""
+ payload = {str(k): str(v) for k, v in data.items() if v is not None and str(v) != ""}
+ received_hash = payload.pop("hash", None)
+ if not received_hash:
+ raise InitDataError("missing hash")
+
+ data_check_string = "\n".join(f"{k}={payload[k]}" for k in sorted(payload.keys()))
+ secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
+ calculated = hmac.new(
+ secret_key,
+ data_check_string.encode("utf-8"),
+ hashlib.sha256,
+ ).hexdigest()
+ if not hmac.compare_digest(calculated, received_hash):
+ raise InitDataError("invalid hash")
+
+ auth_date = int(payload.get("auth_date") or 0)
+ if not auth_date or abs(time.time() - auth_date) > max_age_sec:
+ raise InitDataError("login data expired")
+
+ if "id" not in payload:
+ raise InitDataError("missing id")
+
+ return WebAppUser(
+ id=int(payload["id"]),
+ first_name=str(payload.get("first_name") or "User"),
+ last_name=payload.get("last_name"),
+ username=payload.get("username"),
+ )
+
+
+def _get_jwks_client() -> PyJWKClient:
+ global _jwks_client
+ if _jwks_client is None:
+ _jwks_client = PyJWKClient(_JWKS_URL, cache_keys=True, lifespan=3600)
+ return _jwks_client
+
+
+def parse_and_validate_id_token(
+ id_token: str,
+ client_id: str,
+ *,
+ nonce: str | None = None,
+) -> WebAppUser:
+ """Проверка OIDC id_token нового Telegram Login (JWKS)."""
+ token = (id_token or "").strip()
+ aud = str(client_id or "").strip()
+ if not token:
+ raise InitDataError("missing id_token")
+ if not aud:
+ raise InitDataError("telegram client_id not configured")
+
+ try:
+ signing_key = _get_jwks_client().get_signing_key_from_jwt(token)
+ payload = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256", "RS384", "RS512", "PS256", "ES256", "ES384", "EdDSA"],
+ audience=aud,
+ issuer=_ISSUER,
+ options={"require": ["exp", "iat", "iss", "aud"]},
+ )
+ except jwt.PyJWTError as exc:
+ logger.info("telegram id_token invalid: %s", exc)
+ raise InitDataError("invalid id_token") from exc
+
+ if nonce and payload.get("nonce") and str(payload.get("nonce")) != str(nonce):
+ raise InitDataError("nonce mismatch")
+
+ tid_raw = payload.get("id")
+ if tid_raw is None:
+ tid_raw = payload.get("sub")
+ try:
+ tid = int(tid_raw)
+ except (TypeError, ValueError) as exc:
+ raise InitDataError("missing user id in id_token") from exc
+
+ given = str(payload.get("given_name") or "").strip()
+ family = str(payload.get("family_name") or "").strip() or None
+ full = str(payload.get("name") or "").strip()
+ if given:
+ first_name = given
+ last_name = family
+ elif full:
+ parts = full.split(None, 1)
+ first_name = parts[0]
+ last_name = parts[1] if len(parts) > 1 else family
+ else:
+ first_name = "User"
+ last_name = family
+
+ username = payload.get("preferred_username") or payload.get("username")
+ return WebAppUser(
+ id=tid,
+ first_name=first_name,
+ last_name=last_name,
+ username=str(username) if username else None,
+ )
diff --git a/services/ticket_ai.py b/services/ticket_ai.py
new file mode 100644
index 0000000..c28491c
--- /dev/null
+++ b/services/ticket_ai.py
@@ -0,0 +1,231 @@
+"""Автоответы ИИ по тикетам через OpenRouter."""
+
+from __future__ import annotations
+
+import html
+import logging
+import re
+from typing import Any
+
+from aiogram import Bot
+
+from config import Settings
+from services.db import Database
+from services.faq import FAQ_ITEMS
+from services.openrouter import (
+ AiDecision,
+ chat_completion,
+ load_openrouter_config,
+ parse_ai_decision,
+)
+from services.tickets import (
+ BODY_MAX,
+ clamp_body,
+ notify_user_ticket_reply,
+)
+
+logger = logging.getLogger(__name__)
+
+_HTML_RE = re.compile(r"<[^>]+>")
+
+
+def _strip_html(text: str) -> str:
+ t = _HTML_RE.sub("", text or "")
+ return (
+ t.replace(" ", " ")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+ .replace(""", '"')
+ .strip()
+ )
+
+
+def build_faq_context() -> str:
+ lines = ["FAQ сервиса:"]
+ for item in FAQ_ITEMS:
+ lines.append(f"Q: {_strip_html(item.question)}")
+ lines.append(f"A: {_strip_html(item.answer)}")
+ lines.append("")
+ return "\n".join(lines).strip()
+
+
+def build_ticket_messages_for_ai(messages: list[dict[str, Any]]) -> list[dict[str, str]]:
+ out: list[dict[str, str]] = []
+ for msg in messages[-20:]:
+ role = str(msg.get("author_role") or "")
+ body = str(msg.get("body") or "").strip()
+ if not body:
+ continue
+ if role == "user":
+ out.append({"role": "user", "content": body})
+ else:
+ prefix = "ИИ" if role == "ai" else "Поддержка"
+ out.append({"role": "assistant", "content": f"[{prefix}] {body}"})
+ return out
+
+
+async def notify_admins_ai_escalate(
+ bot: Bot,
+ settings: Settings,
+ *,
+ ticket_id: int,
+ telegram_id: int,
+ subject: str,
+ reason: str,
+) -> None:
+ from keyboards import admin_ticket_notify_inline
+
+ text = (
+ f"👤 Нужен человек · тикет #{ticket_id}\n"
+ f"От: {telegram_id}\n"
+ f"Тема: {html.escape(subject)}\n\n"
+ f"ИИ эскалировал: {html.escape((reason or 'без причины')[:400])}"
+ )
+ kb = admin_ticket_notify_inline(ticket_id)
+ for admin_id in settings.admin_ids:
+ try:
+ await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
+ except Exception as exc: # noqa: BLE001
+ logger.warning("notify admin escalate %s: %s", admin_id, exc)
+
+
+async def try_ai_ticket_reply(
+ *,
+ db: Database,
+ bot: Bot | None,
+ settings: Settings,
+ ticket_id: int,
+ notify_user: bool = True,
+) -> dict[str, Any]:
+ """
+ Пытается ответить на тикет через OpenRouter.
+ Возвращает dict: ok, action, detail, model?
+ """
+ cfg = load_openrouter_config(db)
+ if not cfg.ready or not cfg.auto_reply:
+ return {"ok": False, "action": "skip", "detail": "ai disabled"}
+
+ ticket = db.get_ticket(ticket_id)
+ if not ticket:
+ return {"ok": False, "action": "skip", "detail": "ticket not found"}
+ if str(ticket.get("status")) == "closed":
+ return {"ok": False, "action": "skip", "detail": "closed"}
+
+ messages = db.list_ticket_messages(ticket_id)
+ if not messages:
+ return {"ok": False, "action": "skip", "detail": "no messages"}
+ last = messages[-1]
+ if str(last.get("author_role")) != "user":
+ return {"ok": False, "action": "skip", "detail": "last not user"}
+
+ subject = str(ticket.get("subject") or "")
+ system = cfg.system_prompt.strip()
+ faq = build_faq_context()
+ system_full = (
+ f"{system}\n\n{faq}\n\n"
+ f"Текущий тикет #{ticket_id}, тема: {subject}"
+ )
+ chat_messages = [
+ {"role": "system", "content": system_full},
+ *build_ticket_messages_for_ai(messages),
+ ]
+
+ referer = (settings.webapp_url or "").rstrip("/") or "https://bot.example.com"
+ try:
+ raw, used_model = await chat_completion(
+ api_key=cfg.api_key,
+ model=cfg.model,
+ models=cfg.models_for_request(),
+ messages=chat_messages,
+ referer=referer,
+ )
+ decision = parse_ai_decision(raw, used_model=used_model)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("OpenRouter ticket #%s failed", ticket_id)
+ if bot is not None:
+ await notify_admins_ai_escalate(
+ bot,
+ settings,
+ ticket_id=ticket_id,
+ telegram_id=int(ticket["telegram_id"]),
+ subject=subject,
+ reason=f"Ошибка ИИ: {exc}",
+ )
+ return {"ok": False, "action": "error", "detail": str(exc)}
+
+ return await _apply_decision(
+ db=db,
+ bot=bot,
+ settings=settings,
+ ticket=ticket,
+ decision=decision,
+ notify_user=notify_user,
+ )
+
+
+async def _apply_decision(
+ *,
+ db: Database,
+ bot: Bot | None,
+ settings: Settings,
+ ticket: dict[str, Any],
+ decision: AiDecision,
+ notify_user: bool,
+) -> dict[str, Any]:
+ ticket_id = int(ticket["id"])
+ subject = str(ticket.get("subject") or "")
+ telegram_id = int(ticket["telegram_id"])
+
+ if decision.action == "answer" and decision.reply.strip():
+ reply = clamp_body(decision.reply)
+ if len(reply) > BODY_MAX:
+ reply = reply[: BODY_MAX - 1] + "…"
+ try:
+ db.add_ticket_message(
+ ticket_id,
+ author_role="ai",
+ author_id=None,
+ body=reply,
+ )
+ except ValueError as exc:
+ return {"ok": False, "action": "error", "detail": str(exc)}
+
+ if notify_user and bot is not None:
+ await notify_user_ticket_reply(
+ bot,
+ telegram_id=telegram_id,
+ ticket_id=ticket_id,
+ subject=subject,
+ body=reply,
+ is_ai=True,
+ )
+ logger.info(
+ "AI answered ticket #%s model=%s",
+ ticket_id,
+ decision.model or "?",
+ )
+ return {
+ "ok": True,
+ "action": "answer",
+ "detail": "ok",
+ "model": decision.model,
+ "reply": reply,
+ }
+
+ reason = decision.reason or "Нужен оператор"
+ if bot is not None:
+ await notify_admins_ai_escalate(
+ bot,
+ settings,
+ ticket_id=ticket_id,
+ telegram_id=telegram_id,
+ subject=subject,
+ reason=reason,
+ )
+ return {
+ "ok": True,
+ "action": "escalate",
+ "detail": reason,
+ "model": decision.model,
+ }
diff --git a/services/tickets.py b/services/tickets.py
new file mode 100644
index 0000000..7fdb0e0
--- /dev/null
+++ b/services/tickets.py
@@ -0,0 +1,227 @@
+"""Тикеты поддержки: тексты, лимиты, уведомления."""
+
+from __future__ import annotations
+
+import html
+import logging
+from datetime import datetime
+from typing import Any
+
+from aiogram import Bot
+
+from config import Settings
+
+logger = logging.getLogger(__name__)
+
+SUBJECT_MAX = 100
+BODY_MAX = 2000
+MAX_OPEN_PER_USER = 3
+
+STATUS_LABELS: dict[str, str] = {
+ "open": "🟢 Открыт",
+ "answered": "💬 Есть ответ",
+ "closed": "🔒 Закрыт",
+}
+
+STATUS_LABELS_SHORT: dict[str, str] = {
+ "open": "Открыт",
+ "answered": "Ответ",
+ "closed": "Закрыт",
+}
+
+
+def status_label(status: str, *, short: bool = False) -> str:
+ key = (status or "").strip().lower()
+ table = STATUS_LABELS_SHORT if short else STATUS_LABELS
+ return table.get(key, status or "—")
+
+
+def clamp_subject(text: str) -> str:
+ return (text or "").strip()[:SUBJECT_MAX]
+
+
+def clamp_body(text: str) -> str:
+ return (text or "").strip()[:BODY_MAX]
+
+
+def _dt(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, datetime):
+ return value.isoformat()
+ return str(value)
+
+
+def ticket_to_dict(row: dict[str, Any], *, include_user: bool = False) -> dict[str, Any]:
+ out: dict[str, Any] = {
+ "id": int(row["id"]),
+ "telegram_id": int(row["telegram_id"]),
+ "subject": str(row.get("subject") or ""),
+ "status": str(row.get("status") or "open"),
+ "status_label": status_label(str(row.get("status") or "open"), short=True),
+ "created_at": _dt(row.get("created_at")),
+ "updated_at": _dt(row.get("updated_at")),
+ "message_count": int(row.get("message_count") or 0),
+ }
+ if include_user:
+ name = row.get("full_name") or row.get("login")
+ username = row.get("username")
+ if name:
+ display = str(name)
+ elif username:
+ display = f"@{username}"
+ else:
+ display = f"ID {out['telegram_id']}"
+ out["user"] = {
+ "telegram_id": out["telegram_id"],
+ "display_name": display,
+ "username": username,
+ "login": row.get("login"),
+ }
+ return out
+
+
+def message_to_dict(row: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": int(row["id"]),
+ "ticket_id": int(row["ticket_id"]),
+ "author_role": str(row.get("author_role") or ""),
+ "author_id": int(row["author_id"]) if row.get("author_id") is not None else None,
+ "body": str(row.get("body") or ""),
+ "created_at": _dt(row.get("created_at")),
+ }
+
+
+def format_ticket_card(ticket: dict[str, Any], messages: list[dict[str, Any]]) -> str:
+ tid = int(ticket["id"])
+ subject = html.escape(str(ticket.get("subject") or ""))
+ status = status_label(str(ticket.get("status") or "open"))
+ lines = [
+ f"🎫 Тикет #{tid}",
+ f"Тема: {subject}",
+ f"Статус: {status}",
+ "",
+ ]
+ for msg in messages[-12:]:
+ role = str(msg.get("author_role") or "")
+ if role == "user":
+ who = "Вы"
+ elif role == "ai":
+ who = "ИИ-поддержка"
+ else:
+ who = "Поддержка"
+ body = html.escape(str(msg.get("body") or ""))
+ lines.append(f"{who}:\n{body}")
+ lines.append("")
+ return "\n".join(lines).strip()
+
+
+def format_admin_ticket_card(ticket: dict[str, Any], messages: list[dict[str, Any]]) -> str:
+ tid = int(ticket["id"])
+ subject = html.escape(str(ticket.get("subject") or ""))
+ status = status_label(str(ticket.get("status") or "open"))
+ uid = int(ticket["telegram_id"])
+ uname = ticket.get("username")
+ fname = ticket.get("full_name") or ticket.get("login") or ""
+ user_line = f"ID {uid}"
+ if uname:
+ user_line += f" · @{html.escape(str(uname))}"
+ if fname:
+ user_line += f" · {html.escape(str(fname))}"
+ lines = [
+ f"🎫 Тикет #{tid}",
+ f"Тема: {subject}",
+ f"Статус: {status}",
+ f"Пользователь: {user_line}",
+ "",
+ ]
+ for msg in messages[-15:]:
+ role = str(msg.get("author_role") or "")
+ if role == "user":
+ who = "Пользователь"
+ elif role == "ai":
+ who = "ИИ"
+ else:
+ who = "Админ"
+ body = html.escape(str(msg.get("body") or ""))
+ lines.append(f"{who}:\n{body}")
+ lines.append("")
+ return "\n".join(lines).strip()
+
+
+async def notify_admins_new_ticket(
+ bot: Bot,
+ settings: Settings,
+ *,
+ ticket_id: int,
+ telegram_id: int,
+ subject: str,
+ body: str,
+ ai_handled: bool = False,
+) -> None:
+ prefix = "🤖 Новый тикет (ИИ ответил)" if ai_handled else "🆕 Новый тикет"
+ text = (
+ f"{prefix} #{ticket_id}\n"
+ f"От: {telegram_id}\n"
+ f"Тема: {html.escape(subject)}\n\n"
+ f"{html.escape(body[:500])}"
+ )
+ from keyboards import admin_ticket_notify_inline
+
+ kb = admin_ticket_notify_inline(ticket_id)
+ for admin_id in settings.admin_ids:
+ try:
+ await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
+ except Exception as exc: # noqa: BLE001
+ logger.warning("notify admin %s ticket: %s", admin_id, exc)
+
+
+async def notify_admins_ticket_reply(
+ bot: Bot,
+ settings: Settings,
+ *,
+ ticket_id: int,
+ telegram_id: int,
+ body: str,
+) -> None:
+ text = (
+ f"💬 Ответ в тикете #{ticket_id}\n"
+ f"От: {telegram_id}\n\n"
+ f"{html.escape(body[:500])}"
+ )
+ from keyboards import admin_ticket_notify_inline
+
+ kb = admin_ticket_notify_inline(ticket_id)
+ for admin_id in settings.admin_ids:
+ try:
+ await bot.send_message(admin_id, text, reply_markup=kb, parse_mode="HTML")
+ except Exception as exc: # noqa: BLE001
+ logger.warning("notify admin %s reply: %s", admin_id, exc)
+
+
+async def notify_user_ticket_reply(
+ bot: Bot,
+ *,
+ telegram_id: int,
+ ticket_id: int,
+ subject: str,
+ body: str,
+ is_ai: bool = False,
+) -> None:
+ from keyboards import ticket_notify_inline
+
+ title = "🤖 ИИ-поддержка" if is_ai else "💬 Ответ поддержки"
+ text = (
+ f"{title} · тикет #{ticket_id}\n"
+ f"Тема: {html.escape(subject)}\n\n"
+ f"{html.escape(body[:800])}"
+ )
+ try:
+ await bot.send_message(
+ telegram_id,
+ text,
+ reply_markup=ticket_notify_inline(ticket_id),
+ parse_mode="HTML",
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("notify user %s ticket reply: %s", telegram_id, exc)
diff --git a/services/topup_code_redeem.py b/services/topup_code_redeem.py
new file mode 100644
index 0000000..a52511b
--- /dev/null
+++ b/services/topup_code_redeem.py
@@ -0,0 +1,65 @@
+"""Активация кода пополнения."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from services.db import Database
+from services.topup_codes import is_valid_code_format, normalize_code
+
+
+class TopupCodeError(ValueError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class TopupCodeRedeemResult:
+ ok: bool
+ message: str
+ amount: float = 0.0
+ balance: float = 0.0
+ already: bool = False
+
+
+def redeem_topup_code(
+ db: Database,
+ *,
+ telegram_id: int,
+ raw_code: str,
+) -> TopupCodeRedeemResult:
+ code = normalize_code(raw_code)
+ if not is_valid_code_format(code):
+ raise TopupCodeError("Некорректный формат кода (ожидается XXXX-XXXX-XXXX-XXXX)")
+
+ db.ensure_user(telegram_id)
+ try:
+ amount, already = db.redeem_topup_code(code=code, telegram_id=telegram_id)
+ except ValueError as exc:
+ raise TopupCodeError(str(exc)) from exc
+ if already:
+ balance = db.get_balance(telegram_id)
+ return TopupCodeRedeemResult(
+ ok=True,
+ already=True,
+ message="Этот код уже был использован.",
+ amount=amount,
+ balance=balance,
+ )
+
+ balance = db.add_balance(
+ telegram_id,
+ amount,
+ kind="code_topup",
+ meta=f"code:{code}",
+ )
+ text = (
+ f"✅ Баланс пополнен по коду\n\n"
+ f"+{amount:.0f} ₽\n"
+ f"Доступно: {balance:.0f} ₽"
+ )
+ return TopupCodeRedeemResult(
+ ok=True,
+ message=text,
+ amount=amount,
+ balance=balance,
+ )
diff --git a/services/topup_codes.py b/services/topup_codes.py
new file mode 100644
index 0000000..df6a1cc
--- /dev/null
+++ b/services/topup_codes.py
@@ -0,0 +1,31 @@
+"""Коды пополнения баланса."""
+
+from __future__ import annotations
+
+import re
+import secrets
+import string
+
+# Без похожих символов O/0, I/1, L
+_CODE_ALPHABET = "".join(
+ c for c in (string.ascii_uppercase + string.digits) if c not in "O0IL1"
+)
+_CODE_RE = re.compile(r"^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){3}$")
+
+
+def normalize_code(raw: str) -> str:
+ """XXXX-XXXX-XXXX-XXXX из произвольного ввода."""
+ cleaned = "".join(ch for ch in (raw or "").upper() if ch.isalnum())
+ if len(cleaned) != 16:
+ return cleaned
+ return "-".join(cleaned[i : i + 4] for i in range(0, 16, 4))
+
+
+def is_valid_code_format(code: str) -> bool:
+ norm = normalize_code(code)
+ return bool(_CODE_RE.fullmatch(norm))
+
+
+def generate_code() -> str:
+ parts = ["".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) for _ in range(4)]
+ return "-".join(parts)
diff --git a/services/traffic.py b/services/traffic.py
new file mode 100644
index 0000000..39fad73
--- /dev/null
+++ b/services/traffic.py
@@ -0,0 +1,134 @@
+"""Лимиты и пакеты трафика."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+# База на подписку + докупка
+BASE_TRAFFIC_GB = 1000.0
+PACK_GB = 500.0
+PACK_PRICE_RUB = 50.0
+
+GB = 1024 * 1024 * 1024
+
+
+def gb_to_bytes(gb: float) -> float:
+ return float(gb) * GB
+
+
+def bytes_to_gb(value: float | int | None) -> float:
+ if not value:
+ return 0.0
+ return float(value) / GB
+
+
+def format_gb(gb: float, *, digits: int = 1) -> str:
+ if gb >= 100 and abs(gb - round(gb)) < 0.05:
+ return f"{int(round(gb))} ГБ"
+ if digits <= 0:
+ return f"{int(round(gb))} ГБ"
+ text = f"{gb:.{digits}f}".rstrip("0").rstrip(".")
+ return f"{text} ГБ"
+
+
+def effective_limit_bytes(limit_bytes: float | int | None) -> float:
+ """0 / None в панели = безлимит; для нас база = 1000 ГБ."""
+ raw = float(limit_bytes or 0)
+ if raw <= 0:
+ return gb_to_bytes(BASE_TRAFFIC_GB)
+ return raw
+
+
+def ensure_base_limit_bytes(current_limit_bytes: float | int | None) -> float:
+ """Не меньше базовых 1000 ГБ; безлимит (0) → база."""
+ raw = float(current_limit_bytes or 0)
+ base = gb_to_bytes(BASE_TRAFFIC_GB)
+ if raw <= 0:
+ return base
+ return max(raw, base)
+
+
+@dataclass(frozen=True, slots=True)
+class TrafficPack:
+ packs: int
+ gb: float
+ price: float
+
+ @property
+ def id(self) -> str:
+ return f"pack_{self.packs}"
+
+ @property
+ def title(self) -> str:
+ return f"+{format_gb(self.gb, digits=0)}"
+
+ def format_price(self) -> str:
+ if self.price == int(self.price):
+ return f"{int(self.price)} ₽/мес"
+ return f"{self.price:.2f} ₽/мес"
+
+
+# Готовые пакеты: 1 / 2 / 4 / 10 × 500 ГБ
+TRAFFIC_PACKS: tuple[TrafficPack, ...] = tuple(
+ TrafficPack(
+ packs=n,
+ gb=PACK_GB * n,
+ price=PACK_PRICE_RUB * n,
+ )
+ for n in (1, 2, 4, 10)
+)
+
+
+def get_traffic_pack(pack_id: str) -> TrafficPack | None:
+ for p in TRAFFIC_PACKS:
+ if p.id == pack_id or str(p.packs) == pack_id:
+ return p
+ return None
+
+
+@dataclass(frozen=True, slots=True)
+class TrafficUsage:
+ used_bytes: float
+ limit_bytes: float
+
+ @property
+ def used_gb(self) -> float:
+ return bytes_to_gb(self.used_bytes)
+
+ @property
+ def limit_gb(self) -> float:
+ return bytes_to_gb(self.limit_bytes)
+
+ @property
+ def left_gb(self) -> float:
+ return max(0.0, self.limit_gb - self.used_gb)
+
+ @property
+ def percent(self) -> float:
+ if self.limit_bytes <= 0:
+ return 0.0
+ return min(100.0, max(0.0, 100.0 * self.used_bytes / self.limit_bytes))
+
+ def format_line(self) -> str:
+ return (
+ f"📦 Трафик: {format_gb(self.used_gb)} / {format_gb(self.limit_gb)} "
+ f"({self.percent:.0f}%)\n"
+ f"Осталось: {format_gb(self.left_gb)}"
+ )
+
+ def as_dict(self) -> dict:
+ return {
+ "used_gb": round(self.used_gb, 2),
+ "limit_gb": round(self.limit_gb, 2),
+ "left_gb": round(self.left_gb, 2),
+ "percent": round(self.percent, 1),
+ "used_label": format_gb(self.used_gb),
+ "limit_label": format_gb(self.limit_gb),
+ "left_label": format_gb(self.left_gb),
+ }
+
+
+def traffic_from_panel_user(user) -> TrafficUsage:
+ used = float(getattr(user, "used_traffic_bytes", 0) or 0)
+ limit = effective_limit_bytes(getattr(user, "traffic_limit_bytes", 0))
+ return TrafficUsage(used_bytes=used, limit_bytes=limit)
diff --git a/services/transactions_ui.py b/services/transactions_ui.py
new file mode 100644
index 0000000..7747223
--- /dev/null
+++ b/services/transactions_ui.py
@@ -0,0 +1,49 @@
+"""Человекочитаемые подписи к операциям баланса."""
+
+from __future__ import annotations
+
+_KIND_LABELS: dict[str, str] = {
+ "topup": "Пополнение",
+ "admin_credit": "Зачисление администратором",
+ "topup_code": "Код пополнения",
+ "stars_topup": "Telegram Stars",
+ "crypto_topup": "Крипто-пополнение",
+ "lava_topup": "Lava",
+ "anypay_topup": "AnyPay",
+ "purchase": "Покупка тарифа",
+ "renewal": "Продление подписки",
+ "traffic": "Докупка трафика",
+ "devices": "Доп. устройства",
+ "extras_renewal": "Продление доп. опций",
+ "wg_config": "WireGuard конфиг",
+ "awg_config": "AmneziaWG конфиг",
+ "refund": "Возврат",
+}
+
+
+def transaction_label(kind: str, meta: str | None = None) -> str:
+ k = (kind or "").strip()
+ m = (meta or "").strip()
+ if k == "admin_credit":
+ if " | admin:" in m:
+ m = m.split(" | admin:", 1)[0].strip()
+ return m or _KIND_LABELS["admin_credit"]
+ if k == "topup_code" and m:
+ return f"Код пополнения · {m}"
+ if m and k in ("purchase", "renewal", "traffic", "devices", "wg_config", "awg_config", "refund"):
+ return f"{_KIND_LABELS.get(k, k)} · {m}"
+ return _KIND_LABELS.get(k, k or "Операция")
+
+
+def transaction_row_dict(amount: float, kind: str, meta: str | None, created_at) -> dict:
+ sign = "+" if amount > 0 else ""
+ amt = float(amount)
+ label = transaction_label(kind, meta)
+ return {
+ "amount": amt,
+ "kind": kind,
+ "meta": meta,
+ "label": label,
+ "amount_label": f"{sign}{amt:.0f} ₽",
+ "created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else str(created_at),
+ }
diff --git a/services/trial.py b/services/trial.py
new file mode 100644
index 0000000..fd119f4
--- /dev/null
+++ b/services/trial.py
@@ -0,0 +1,99 @@
+"""Пробный период подписки."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+
+from remnawave import RemnawaveSDK
+
+from services.db import AuthError, Database
+from services.purchase import provision_subscription
+from services.tariffs import Tariff, get_tariff
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_TRIAL_DAYS = 3
+
+
+@dataclass(frozen=True, slots=True)
+class TrialResult:
+ ok: bool
+ message: str
+ expire_at: datetime | None = None
+
+
+def trial_days_setting(db: Database) -> int:
+ raw = (db.get_setting("trial_days") or str(DEFAULT_TRIAL_DAYS)).strip()
+ try:
+ days = int(raw)
+ except ValueError:
+ days = DEFAULT_TRIAL_DAYS
+ return max(0, min(days, 30))
+
+
+def trial_enabled(db: Database) -> bool:
+ return trial_days_setting(db) > 0
+
+
+async def grant_trial(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ tariffs: list[Tariff],
+) -> TrialResult:
+ days = trial_days_setting(db)
+ if days <= 0:
+ return TrialResult(ok=False, message="Пробный период выключен")
+
+ user = db.ensure_user(telegram_id)
+ if user.banned:
+ return TrialResult(ok=False, message="Аккаунт заблокирован")
+ if user.trial_used_at:
+ return TrialResult(ok=False, message="Пробный период уже использован")
+
+ tariff_id = (db.get_setting("trial_tariff_id") or "").strip()
+ tariff = get_tariff(tariffs, tariff_id) if tariff_id else None
+ if tariff is None and tariffs:
+ tariff = min(tariffs, key=lambda t: t.price)
+ if tariff is None:
+ return TrialResult(ok=False, message="Нет тарифа для пробного периода")
+
+ trial_tariff = Tariff(
+ id=tariff.id,
+ title=f"Trial · {tariff.title}",
+ price=0.0,
+ days=days,
+ traffic_gb=tariff.traffic_gb,
+ device_limit=tariff.device_limit,
+ squad_uuids=tariff.squad_uuids,
+ description="trial",
+ )
+
+ try:
+ rw_uuid, sub_url, expire_at = await provision_subscription(
+ sdk,
+ telegram_id=telegram_id,
+ tariff=trial_tariff,
+ login=user.login,
+ extra_devices=user.extra_devices,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("trial provision failed")
+ return TrialResult(ok=False, message=f"Не удалось выдать пробный период: {exc}")
+
+ db.mark_trial_used(telegram_id)
+ db.record_purchase(
+ telegram_id,
+ tariff_id=f"trial:{tariff.id}",
+ price=0.0,
+ remnawave_uuid=rw_uuid,
+ subscription_url=sub_url,
+ )
+ return TrialResult(
+ ok=True,
+ message=f"Пробный период на {days} дн. активен до {expire_at.strftime('%d.%m.%Y %H:%M UTC')}",
+ expire_at=expire_at,
+ )
diff --git a/services/vpn_configs_expiry.py b/services/vpn_configs_expiry.py
new file mode 100644
index 0000000..89a451b
--- /dev/null
+++ b/services/vpn_configs_expiry.py
@@ -0,0 +1,63 @@
+"""Фоновое отключение WG/AWG конфигов при истекшей подписке Remnawave."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+from remnawave import RemnawaveSDK
+
+from services.awg_configs import enforce_awg_configs_subscription
+from services.db import Database
+from services.xui_wg import enforce_wg_configs_subscription
+
+logger = logging.getLogger(__name__)
+
+
+async def run_vpn_configs_expiry_cycle(
+ db: Database,
+ sdk: RemnawaveSDK,
+ *,
+ limit: int = 500,
+) -> int:
+ ids = db.list_telegram_ids_with_enabled_vpn_configs(limit=limit)
+ touched = 0
+ for tid in ids:
+ try:
+ user = db.get_user(tid)
+ login = user.login if user else None
+ wg = await enforce_wg_configs_subscription(
+ db=db, sdk=sdk, telegram_id=tid, login=login
+ )
+ awg = await enforce_awg_configs_subscription(
+ db=db, sdk=sdk, telegram_id=tid, login=login
+ )
+ if (wg.get("disabled") or 0) + (awg.get("disabled") or 0) > 0:
+ touched += 1
+ logger.info(
+ "vpn configs auto-disabled tg=%s wg=%s awg=%s",
+ tid,
+ wg.get("disabled"),
+ awg.get("disabled"),
+ )
+ except Exception: # noqa: BLE001
+ logger.exception("vpn configs expiry enforce failed tg=%s", tid)
+ return touched
+
+
+async def vpn_configs_expiry_worker(
+ db: Database,
+ sdk: RemnawaveSDK,
+ *,
+ interval_sec: int = 120,
+) -> None:
+ """Каждые ~2 минуты проверяет включённые конфиги и гасит их без подписки."""
+ await asyncio.sleep(45)
+ while True:
+ try:
+ n = await run_vpn_configs_expiry_cycle(db, sdk)
+ if n:
+ logger.info("vpn configs expiry cycle: disabled for %s users", n)
+ except Exception: # noqa: BLE001
+ logger.exception("vpn configs expiry cycle error")
+ await asyncio.sleep(interval_sec)
diff --git a/services/web_topup.py b/services/web_topup.py
new file mode 100644
index 0000000..ff3644e
--- /dev/null
+++ b/services/web_topup.py
@@ -0,0 +1,264 @@
+"""Пополнение баланса с сайта (Lava / AnyPay / Heleket / Crypto Pay)."""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Any
+
+from services.anypay import Anypay, AnypayError
+from services.cryptopay import CryptoPay, CryptoPayError
+from services.db import Database
+from services.heleket import Heleket, HeleketError
+from services.lava import Lava, LavaError
+
+TOPUP_MIN_RUB = 10.0
+TOPUP_MAX_RUB = 3000.0
+TOPUP_PRESETS_RUB: tuple[int, ...] = (50, 100, 200, 500, 1000, 2000, 3000)
+
+PROVIDERS = ("lava", "anypay", "heleket", "cryptobot")
+
+
+class TopupError(ValueError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class TopupInvoice:
+ provider: str
+ amount_rub: float
+ pay_url: str
+ invoice_id: int
+ order_id: str | None = None
+ title: str = ""
+
+
+def parse_topup_amount(raw: Any) -> float:
+ try:
+ amount = float(raw)
+ except (TypeError, ValueError) as exc:
+ raise TopupError("Укажите сумму числом") from exc
+ amount = round(amount, 2)
+ if amount < TOPUP_MIN_RUB:
+ raise TopupError(f"Минимум {int(TOPUP_MIN_RUB)} ₽ за одну оплату")
+ if amount > TOPUP_MAX_RUB:
+ raise TopupError(f"Максимум {int(TOPUP_MAX_RUB)} ₽ за одну оплату")
+ return amount
+
+
+def normalize_provider(raw: Any) -> str:
+ provider = str(raw or "").strip().lower()
+ if provider in {"crypto", "cryptopay", "crypto_bot"}:
+ provider = "cryptobot"
+ if provider not in PROVIDERS:
+ raise TopupError("Неизвестный способ оплаты")
+ return provider
+
+
+def _safe_order_id(raw: str) -> str:
+ cleaned = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in raw)
+ return cleaned[:128]
+
+
+def available_providers(
+ *,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+ anypay: Anypay | None = None,
+) -> list[dict[str, Any]]:
+ items: list[dict[str, Any]] = []
+ if lava.enabled:
+ items.append(
+ {
+ "id": "lava",
+ "title": "Карта / СБП · Lava",
+ "hint": "Lava.ru · зачисление автоматически",
+ }
+ )
+ if anypay and anypay.enabled:
+ items.append(
+ {
+ "id": "anypay",
+ "title": "AnyPay",
+ "hint": "Карта, СБП, крипта и др. · anypay.io",
+ }
+ )
+ if heleket.enabled:
+ items.append(
+ {
+ "id": "heleket",
+ "title": "Крипта · Heleket",
+ "hint": "USDT и др. · зачисление по webhook",
+ }
+ )
+ if cryptopay.enabled:
+ items.append(
+ {
+ "id": "cryptobot",
+ "title": "Крипта · Crypto Bot",
+ "hint": "Оплата в @CryptoBot",
+ }
+ )
+ return items
+
+
+def topup_methods_payload(
+ *,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+ anypay: Anypay | None = None,
+) -> dict[str, Any]:
+ return {
+ "min_amount": int(TOPUP_MIN_RUB),
+ "max_amount": int(TOPUP_MAX_RUB),
+ "presets": list(TOPUP_PRESETS_RUB),
+ "providers": available_providers(
+ cryptopay=cryptopay, heleket=heleket, lava=lava, anypay=anypay
+ ),
+ }
+
+
+async def create_web_topup_invoice(
+ *,
+ db: Database,
+ telegram_id: int,
+ amount_rub: float,
+ provider: str,
+ cryptopay: CryptoPay,
+ heleket: Heleket,
+ lava: Lava,
+ anypay: Anypay | None = None,
+) -> TopupInvoice:
+ amount = parse_topup_amount(amount_rub)
+ provider = normalize_provider(provider)
+ stamp = int(time.time())
+ order_id = _safe_order_id(f"web_topup_{telegram_id}_{int(amount)}_{stamp}")
+
+ if provider == "lava":
+ if not lava.enabled:
+ raise TopupError("Lava не настроена")
+ try:
+ inv = await lava.create_invoice(
+ amount_rub=amount,
+ order_id=order_id,
+ comment=f"Web topup {int(amount)} RUB uid={telegram_id}",
+ custom_fields=str(telegram_id),
+ )
+ except LavaError as exc:
+ raise TopupError(str(exc)) from exc
+ local_id = db.next_local_invoice_id()
+ db.create_invoice_record(
+ telegram_id=telegram_id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ payload=None,
+ provider="lava",
+ order_id=order_id,
+ external_uuid=inv.invoice_id,
+ )
+ return TopupInvoice(
+ provider="lava",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ invoice_id=local_id,
+ order_id=order_id,
+ title="Карта / СБП · Lava",
+ )
+
+ if provider == "anypay":
+ if not anypay or not anypay.enabled:
+ raise TopupError("AnyPay не настроен")
+ local_id = db.next_local_invoice_id()
+ pay_id = str(local_id)
+ try:
+ inv = await anypay.create_invoice(
+ pay_id=pay_id,
+ amount_rub=amount,
+ desc=f"Web topup {int(amount)} RUB uid={telegram_id}",
+ )
+ except AnypayError as exc:
+ raise TopupError(str(exc)) from exc
+ db.create_invoice_record(
+ telegram_id=telegram_id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ payload=pay_id,
+ provider="anypay",
+ order_id=pay_id,
+ external_uuid=inv.transaction_id,
+ )
+ return TopupInvoice(
+ provider="anypay",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ invoice_id=local_id,
+ order_id=pay_id,
+ title="AnyPay",
+ )
+
+ if provider == "heleket":
+ if not heleket.enabled:
+ raise TopupError("Heleket не настроен")
+ try:
+ inv = await heleket.create_invoice(
+ amount_rub=amount,
+ order_id=order_id,
+ description=f"Web topup {int(amount)} RUB uid={telegram_id}",
+ )
+ except HeleketError as exc:
+ raise TopupError(str(exc)) from exc
+ local_id = db.next_local_invoice_id()
+ db.create_invoice_record(
+ telegram_id=telegram_id,
+ invoice_id=local_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ payload=order_id,
+ provider="heleket",
+ order_id=order_id,
+ external_uuid=inv.uuid,
+ )
+ return TopupInvoice(
+ provider="heleket",
+ amount_rub=amount,
+ pay_url=inv.pay_url,
+ invoice_id=local_id,
+ order_id=order_id,
+ title="Крипта · Heleket",
+ )
+
+ if not cryptopay.enabled:
+ raise TopupError("Crypto Bot не настроен")
+ payload = f"topup:{telegram_id}:{int(amount)}:web"
+ try:
+ invoice = await cryptopay.create_fiat_invoice(
+ amount_rub=amount,
+ description=f"Пополнение баланса на {int(amount)} ₽",
+ payload=payload,
+ )
+ except CryptoPayError as exc:
+ raise TopupError(str(exc)) from exc
+ db.create_invoice_record(
+ telegram_id=telegram_id,
+ invoice_id=invoice.invoice_id,
+ kind="topup",
+ amount_rub=amount,
+ pay_url=invoice.pay_url,
+ payload=payload,
+ provider="cryptobot",
+ )
+ return TopupInvoice(
+ provider="cryptobot",
+ amount_rub=amount,
+ pay_url=invoice.pay_url or invoice.bot_invoice_url,
+ invoice_id=invoice.invoice_id,
+ order_id=None,
+ title="Крипта · Crypto Bot",
+ )
diff --git a/services/webapp_growth_routes.py b/services/webapp_growth_routes.py
new file mode 100644
index 0000000..09f61a8
--- /dev/null
+++ b/services/webapp_growth_routes.py
@@ -0,0 +1,833 @@
+"""Доп. API: рефералка, trial, промо, пароль, сессии, HWID, legal, бэкапы."""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from uuid import UUID
+
+from aiohttp import web
+from remnawave.models import HWIDDeleteRequest
+
+from config import Settings, ROOT_DIR
+from services.db import AuthError, BotUser, Database
+from services.legal_pages import (
+ html_aup,
+ html_contacts,
+ html_cookies,
+ html_delivery,
+ html_help,
+ html_offer,
+ html_privacy,
+ html_refund,
+ html_site_rules,
+ html_status,
+ html_support,
+ html_terms,
+ html_vpn_usage,
+)
+from services.mailer import render_password_reset_email, send_email, smtp_configured
+from services.pg_backup import (
+ delete_backup,
+ list_backups,
+ resolve_backup_path,
+ restore_from_backup_name,
+ restore_from_bytes,
+ run_pg_dump_backup,
+ save_uploaded_backup,
+)
+from services.rate_limit import allow, client_ip
+from services.remnawave_users import find_panel_user
+from services.trial import grant_trial, trial_days_setting, trial_enabled
+
+logger = logging.getLogger(__name__)
+
+
+def _require_user(request: web.Request) -> BotUser:
+ from webapp_server import _require_user as req
+
+ return req(request)
+
+
+def _user_payload(user: BotUser) -> dict:
+ from webapp_server import _user_payload as payload
+
+ return payload(user)
+
+
+def _session_token(request: web.Request) -> str | None:
+ from webapp_server import _session_token as tok
+
+ return tok(request)
+
+
+def _set_session_cookie(resp, request, token: str) -> None:
+ from webapp_server import _set_session_cookie as setc
+
+ setc(resp, request, token)
+
+
+def register_growth_routes(app: web.Application) -> None:
+ app.router.add_get("/api/referral", handle_referral_get)
+ app.router.add_post("/api/trial", handle_trial)
+ app.router.add_get("/api/account/notifications", handle_notify_get)
+ app.router.add_post("/api/account/notifications", handle_notify_set)
+ app.router.add_get("/api/account/sessions", handle_sessions_list)
+ app.router.add_post("/api/account/sessions/logout-all", handle_sessions_logout_all)
+ app.router.add_post("/api/auth/password/forgot", handle_password_forgot)
+ app.router.add_post("/api/auth/password/reset", handle_password_reset)
+ app.router.add_get("/api/devices/hwid", handle_hwid_list)
+ app.router.add_post("/api/devices/hwid/delete", handle_hwid_delete)
+ app.router.add_get("/terms", handle_terms_page)
+ app.router.add_get("/offer", handle_offer_page)
+ app.router.add_get("/privacy", handle_privacy_page)
+ app.router.add_get("/aup", handle_aup_page)
+ app.router.add_get("/vpn-usage", handle_vpn_usage_page)
+ app.router.add_get("/vpn_usage", handle_vpn_usage_page)
+ app.router.add_get("/rules", handle_rules_page)
+ app.router.add_get("/refund", handle_refund_page)
+ app.router.add_get("/delivery", handle_delivery_page)
+ app.router.add_get("/contacts", handle_contacts_page)
+ app.router.add_get("/help", handle_help_page)
+ app.router.add_get("/support", handle_support_page)
+ app.router.add_get("/cookies", handle_cookies_page)
+ app.router.add_get("/status", handle_status_page)
+ app.router.add_get("/api/admin/promos", handle_admin_promos_list)
+ app.router.add_post("/api/admin/promos", handle_admin_promos_create)
+ app.router.add_post("/api/admin/promos/{promo_id}/toggle", handle_admin_promos_toggle)
+ app.router.add_post("/api/admin/users/ban", handle_admin_user_ban)
+ app.router.add_post("/api/admin/users/delete", handle_admin_user_delete)
+ app.router.add_get("/api/admin/payments/journal", handle_admin_payment_journal)
+ app.router.add_get("/api/admin/backups", handle_admin_backups_list)
+ app.router.add_post("/api/admin/backups", handle_admin_backups_run)
+ app.router.add_get("/api/admin/backups/{name}/download", handle_admin_backups_download)
+ app.router.add_delete("/api/admin/backups/{name}", handle_admin_backups_delete)
+ app.router.add_post("/api/admin/backups/delete", handle_admin_backups_delete_post)
+ app.router.add_post("/api/admin/backups/restore", handle_admin_backups_restore)
+ app.router.add_post("/api/admin/backups/upload", handle_admin_backups_upload)
+ app.router.add_get("/api/admin/growth-settings", handle_admin_growth_get)
+ app.router.add_post("/api/admin/growth-settings", handle_admin_growth_set)
+
+
+async def handle_referral_get(request: web.Request) -> web.Response:
+ settings: Settings = request.app["settings"]
+ db: Database = request.app["db"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ code = db.ensure_referral_code(user.telegram_id)
+ bot = str(request.app.get("bot_username") or "").lstrip("@")
+ link = f"https://t.me/{bot}?start=ref_{code}" if bot else ""
+ web_link = f"{settings.webapp_url.rstrip('/')}/?ref={code}" if settings.webapp_url else ""
+ return web.json_response(
+ {
+ "ok": True,
+ "code": code,
+ "telegram_link": link,
+ "web_link": web_link,
+ "bonus_referrer": float(db.get_setting("referral_bonus_referrer") or "50"),
+ "bonus_referee": float(db.get_setting("referral_bonus_referee") or "50"),
+ }
+ )
+
+
+async def handle_trial(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ sdk = request.app["sdk"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ if not allow(request, "trial", limit=5, window_sec=3600):
+ return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
+ result = await grant_trial(
+ db=db, sdk=sdk, telegram_id=user.telegram_id, tariffs=request.app["tariffs"]
+ )
+ status = 200 if result.ok else 400
+ return web.json_response(
+ {
+ "ok": result.ok,
+ "error": None if result.ok else result.message,
+ "message": result.message,
+ "expire_at": result.expire_at.isoformat() if result.expire_at else None,
+ "trial_enabled": trial_enabled(db),
+ "trial_days": trial_days_setting(db),
+ },
+ status=status,
+ )
+
+
+async def handle_notify_get(request: web.Request) -> web.Response:
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ return web.json_response(
+ {
+ "ok": True,
+ "notify_telegram": bool(user.notify_telegram),
+ "notify_email": bool(user.notify_email),
+ }
+ )
+
+
+async def handle_notify_set(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ updated = db.set_notify_prefs(
+ user.telegram_id,
+ notify_telegram=body.get("notify_telegram"),
+ notify_email=body.get("notify_email"),
+ )
+ return web.json_response(
+ {
+ "ok": True,
+ "notify_telegram": updated.notify_telegram,
+ "notify_email": updated.notify_email,
+ "user": _user_payload(updated),
+ }
+ )
+
+
+async def handle_sessions_list(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ current = _session_token(request)
+ items = []
+ for s in db.list_sessions(user.telegram_id):
+ items.append(
+ {
+ "token_hint": (s["token"][:6] + "…") if s.get("token") else "",
+ "current": s.get("token") == current,
+ "created_at": s["created_at"].isoformat()
+ if hasattr(s["created_at"], "isoformat")
+ else str(s["created_at"]),
+ "expires_at": s["expires_at"].isoformat()
+ if hasattr(s["expires_at"], "isoformat")
+ else str(s["expires_at"]),
+ "user_agent": s.get("user_agent"),
+ "ip": s.get("ip"),
+ }
+ )
+ return web.json_response({"ok": True, "sessions": items})
+
+
+async def handle_sessions_logout_all(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ keep = _session_token(request)
+ deleted = db.clear_all_sessions(user.telegram_id, keep_token=keep)
+ return web.json_response({"ok": True, "deleted": deleted})
+
+
+async def handle_password_forgot(request: web.Request) -> web.Response:
+ import os
+
+ from dotenv import load_dotenv
+
+ from config import get_settings
+
+ # Подтянуть .env (SMTP из админки) и обновить runtime settings
+ env_file = (os.getenv("ENV_FILE") or "").strip() or None
+ load_dotenv(env_file, override=True)
+ settings = get_settings(require_bot_token=False)
+ request.app["settings"] = settings
+ db: Database = request.app["db"]
+ if not allow(request, "pwd_forgot", limit=5, window_sec=600):
+ return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ email = str(body.get("email") or "").strip()
+ if not email or "@" not in email:
+ return web.json_response({"ok": False, "error": "Укажите корректный email"}, status=400)
+
+ if not smtp_configured(settings):
+ return web.json_response(
+ {
+ "ok": False,
+ "error": "Сброс пароля недоступен: SMTP не настроен (Админка → SMTP)",
+ },
+ status=503,
+ )
+ if not (settings.webapp_url or "").strip():
+ return web.json_response(
+ {
+ "ok": False,
+ "error": "Сброс пароля недоступен: не задан WEBAPP_URL",
+ },
+ status=503,
+ )
+
+ # Не раскрываем, есть ли email в базе
+ user = db.get_user_by_email(email)
+ if user and user.email:
+ token = db.create_password_reset_token(user.telegram_id)
+ link = f"{settings.webapp_url.rstrip('/')}/?reset={token}"
+ try:
+ await send_email(
+ settings,
+ to=user.email,
+ subject="Сброс пароля — VPN Service",
+ html=render_password_reset_email(
+ reset_url=link,
+ webapp_url=settings.webapp_url or "",
+ ),
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("password reset mail failed")
+ return web.json_response(
+ {"ok": False, "error": f"Не удалось отправить письмо: {exc}"},
+ status=502,
+ )
+ return web.json_response(
+ {
+ "ok": True,
+ "message": "Если email найден — отправили ссылку для сброса. Проверьте почту и «Спам».",
+ }
+ )
+
+
+async def handle_password_reset(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ if not allow(request, "pwd_reset", limit=10, window_sec=600):
+ return web.json_response({"ok": False, "error": "Слишком часто"}, status=429)
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ token = str(body.get("token") or "")
+ password = str(body.get("password") or "")
+ try:
+ tid = db.consume_password_reset_token(token)
+ db.set_password_hash(tid, password)
+ db.clear_all_sessions(tid)
+ session = db.create_session(
+ tid, user_agent=request.headers.get("User-Agent"), ip=client_ip(request)
+ )
+ except AuthError as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=400)
+ user = db.get_user(tid)
+ assert user is not None
+ resp = web.json_response(
+ {"ok": True, "token": session, "user": _user_payload(user), "balance": user.balance}
+ )
+ _set_session_cookie(resp, request, session)
+ return resp
+
+
+async def handle_hwid_list(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ sdk = request.app["sdk"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ panel = await find_panel_user(sdk, user.telegram_id, login=user.login)
+ if panel is None:
+ return web.json_response({"ok": True, "devices": [], "total": 0})
+ try:
+ resp = await sdk.hwid.get_hwid_user(str(panel.uuid))
+ devices = []
+ for d in getattr(resp, "devices", None) or []:
+ devices.append(
+ {
+ "hwid": getattr(d, "hwid", ""),
+ "platform": getattr(d, "platform", None),
+ "os_version": getattr(d, "os_version", None),
+ "device_model": getattr(d, "device_model", None),
+ "user_agent": getattr(d, "user_agent", None),
+ "created_at": getattr(d, "created_at", None).isoformat()
+ if getattr(d, "created_at", None)
+ else None,
+ }
+ )
+ return web.json_response(
+ {
+ "ok": True,
+ "total": int(getattr(resp, "total", None) or len(devices)),
+ "devices": devices,
+ }
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("hwid list failed")
+ return web.json_response({"ok": False, "error": str(exc)}, status=500)
+
+
+async def handle_hwid_delete(request: web.Request) -> web.Response:
+ db: Database = request.app["db"]
+ sdk = request.app["sdk"]
+ try:
+ user = _require_user(request)
+ except web.HTTPUnauthorized:
+ return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ hwid = str(body.get("hwid") or "").strip()
+ if not hwid:
+ return web.json_response({"ok": False, "error": "hwid required"}, status=400)
+ panel = await find_panel_user(sdk, user.telegram_id, login=user.login)
+ if panel is None:
+ return web.json_response({"ok": False, "error": "Нет подписки"}, status=400)
+ try:
+ await sdk.hwid.delete_hwid_to_user(
+ HWIDDeleteRequest(user_uuid=UUID(str(panel.uuid)), hwid=hwid)
+ )
+ from services.remnawave_users import sync_device_policy
+ from services.extras_billing import effective_extra_devices
+
+ fresh = await find_panel_user(sdk, user.telegram_id, login=user.login)
+ if fresh is not None:
+ await sync_device_policy(
+ sdk,
+ fresh,
+ db=db,
+ telegram_id=user.telegram_id,
+ extra_paid=effective_extra_devices(user),
+ device_blocked=user.device_blocked,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("hwid delete failed")
+ return web.json_response({"ok": False, "error": str(exc)}, status=500)
+ return web.json_response({"ok": True})
+
+
+async def handle_terms_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_terms(), content_type="text/html", charset="utf-8")
+
+
+async def handle_offer_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_offer(), content_type="text/html", charset="utf-8")
+
+
+async def handle_privacy_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_privacy(), content_type="text/html", charset="utf-8")
+
+
+async def handle_aup_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_aup(), content_type="text/html", charset="utf-8")
+
+
+async def handle_vpn_usage_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_vpn_usage(), content_type="text/html", charset="utf-8")
+
+
+async def handle_rules_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_site_rules(), content_type="text/html", charset="utf-8")
+
+
+async def handle_refund_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_refund(), content_type="text/html", charset="utf-8")
+
+
+async def handle_delivery_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_delivery(), content_type="text/html", charset="utf-8")
+
+
+async def handle_contacts_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_contacts(), content_type="text/html", charset="utf-8")
+
+
+async def handle_help_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_help(), content_type="text/html", charset="utf-8")
+
+
+async def handle_support_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_support(), content_type="text/html", charset="utf-8")
+
+
+async def handle_cookies_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_cookies(), content_type="text/html", charset="utf-8")
+
+
+async def handle_status_page(_: web.Request) -> web.Response:
+ return web.Response(text=html_status(), content_type="text/html", charset="utf-8")
+
+
+def _require_admin(request: web.Request):
+ from webapp_admin_routes import _require_admin as ra
+
+ return ra(request)
+
+
+async def handle_admin_promos_list(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ rows = db.list_promo_codes()
+ items = []
+ for r in rows:
+ items.append(
+ {
+ "id": r["id"],
+ "code": r["code"],
+ "kind": r["kind"],
+ "value": float(r["value"]),
+ "max_uses": r.get("max_uses"),
+ "used_count": int(r.get("used_count") or 0),
+ "tariff_id": r.get("tariff_id"),
+ "active": bool(r.get("active")),
+ "expires_at": r["expires_at"].isoformat()
+ if r.get("expires_at") and hasattr(r["expires_at"], "isoformat")
+ else None,
+ "note": r.get("note"),
+ }
+ )
+ return web.json_response({"ok": True, "promos": items})
+
+
+async def handle_admin_promos_create(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ try:
+ row = db.create_promo_code(
+ code=str(body.get("code") or ""),
+ kind=str(body.get("kind") or "percent"),
+ value=float(body.get("value") or 0),
+ max_uses=int(body["max_uses"]) if body.get("max_uses") not in (None, "") else None,
+ tariff_id=str(body.get("tariff_id") or "").strip() or None,
+ note=str(body.get("note") or "").strip() or None,
+ )
+ except (AuthError, ValueError) as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=400)
+ return web.json_response({"ok": True, "promo": {"id": row["id"], "code": row["code"]}})
+
+
+async def handle_admin_promos_toggle(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ promo_id = int(request.match_info["promo_id"])
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ db.set_promo_active(promo_id, bool(body.get("active", True)))
+ return web.json_response({"ok": True})
+
+
+async def handle_admin_user_ban(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ tid = int(body.get("telegram_id") or 0)
+ banned = bool(body.get("banned"))
+ user = db.set_banned(tid, banned)
+ return web.json_response({"ok": True, "user": _user_payload(user), "banned": user.banned})
+
+
+async def handle_admin_user_delete(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ settings: Settings = request.app["settings"]
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ try:
+ tid = int(body.get("telegram_id") or 0)
+ except (TypeError, ValueError):
+ return web.json_response({"ok": False, "error": "bad telegram_id"}, status=400)
+ if not tid:
+ return web.json_response({"ok": False, "error": "Укажите telegram_id"}, status=400)
+ if not body.get("confirm"):
+ return web.json_response(
+ {"ok": False, "error": "Нужно подтверждение (confirm=true)"},
+ status=400,
+ )
+ if tid in (settings.admin_ids or ()):
+ return web.json_response(
+ {"ok": False, "error": "Нельзя удалить пользователя из ADMIN_IDS"},
+ status=400,
+ )
+ user = db.get_user(tid)
+ if user is None:
+ return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404)
+ label = user.display_name
+ try:
+ ok = db.delete_user(tid)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("admin delete user failed tid=%s", tid)
+ return web.json_response({"ok": False, "error": str(exc)}, status=500)
+ if not ok:
+ return web.json_response({"ok": False, "error": "Пользователь не найден"}, status=404)
+ return web.json_response(
+ {
+ "ok": True,
+ "telegram_id": tid,
+ "message": f"Удалён: {label} (ID {tid})",
+ }
+ )
+
+
+async def handle_admin_payment_journal(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ rows = db.list_payment_events(limit=100)
+ items = []
+ for r in rows:
+ items.append(
+ {
+ "id": r["id"],
+ "provider": r["provider"],
+ "event_type": r["event_type"],
+ "telegram_id": r.get("telegram_id"),
+ "amount": float(r["amount"]) if r.get("amount") is not None else None,
+ "invoice_ref": r.get("invoice_ref"),
+ "status": r.get("status"),
+ "meta": r.get("meta"),
+ "created_at": r["created_at"].isoformat()
+ if hasattr(r["created_at"], "isoformat")
+ else str(r["created_at"]),
+ }
+ )
+ return web.json_response({"ok": True, "events": items})
+
+
+async def handle_admin_backups_list(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ return web.json_response({"ok": True, "backups": list_backups()})
+
+
+async def handle_admin_backups_run(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ try:
+ path = await run_pg_dump_backup(compose_dir=ROOT_DIR)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("backup failed")
+ return web.json_response({"ok": False, "error": str(exc)}, status=500)
+ return web.json_response({"ok": True, "file": path.name, "backups": list_backups()})
+
+
+async def handle_admin_backups_download(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ name = request.match_info.get("name") or ""
+ try:
+ path = resolve_backup_path(name)
+ except (ValueError, FileNotFoundError) as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=404)
+ return web.FileResponse(
+ path,
+ headers={
+ "Content-Type": "application/gzip",
+ "Content-Disposition": f'attachment; filename="{path.name}"',
+ },
+ )
+
+
+async def handle_admin_backups_delete(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ name = request.match_info.get("name") or ""
+ try:
+ delete_backup(name)
+ except (ValueError, FileNotFoundError) as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=404)
+ return web.json_response({"ok": True, "backups": list_backups()})
+
+
+async def handle_admin_backups_delete_post(request: web.Request) -> web.Response:
+ """Удаление через POST — надёжнее, чем DELETE за прокси."""
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ name = str(body.get("name") or "").strip()
+ if not name:
+ return web.json_response({"ok": False, "error": "Укажите name"}, status=400)
+ try:
+ delete_backup(name)
+ except (ValueError, FileNotFoundError) as exc:
+ return web.json_response({"ok": False, "error": str(exc)}, status=404)
+ return web.json_response({"ok": True, "backups": list_backups(), "message": f"Удалён {name}"})
+
+
+async def handle_admin_backups_restore(request: web.Request) -> web.Response:
+ """Восстановить БД из сохранённого бэкапа или загруженного файла (multipart)."""
+ denied = _require_admin(request)
+ if denied:
+ return denied
+
+ content_type = (request.content_type or "").lower()
+ safety_name = None
+
+ try:
+ # Сначала страховочный бэкап текущего состояния
+ try:
+ safety = await run_pg_dump_backup(compose_dir=ROOT_DIR)
+ safety_name = safety.name
+ except Exception as exc: # noqa: BLE001
+ logger.warning("pre-restore safety backup failed: %s", exc)
+
+ if "multipart" in content_type:
+ reader = await request.multipart()
+ confirm = False
+ file_bytes: bytes | None = None
+ filename = "upload.sql.gz"
+ while True:
+ part = await reader.next()
+ if part is None:
+ break
+ if part.name == "confirm":
+ confirm = (await part.text()).strip().lower() in {"1", "true", "yes", "on"}
+ elif part.name == "file":
+ filename = Path(part.filename or filename).name
+ file_bytes = await part.read(decode=False)
+ if not confirm:
+ return web.json_response(
+ {"ok": False, "error": "Нужно подтверждение restore (confirm=true)"},
+ status=400,
+ )
+ if not file_bytes:
+ return web.json_response({"ok": False, "error": "Файл не передан"}, status=400)
+ await restore_from_bytes(file_bytes, filename=filename)
+ try:
+ await save_uploaded_backup(file_bytes, original_name=filename)
+ except Exception: # noqa: BLE001
+ pass
+ else:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ if not body.get("confirm"):
+ return web.json_response(
+ {"ok": False, "error": "Нужно подтверждение restore (confirm=true)"},
+ status=400,
+ )
+ name = str(body.get("name") or "").strip()
+ if not name:
+ return web.json_response({"ok": False, "error": "Укажите name бэкапа"}, status=400)
+ await restore_from_backup_name(name)
+ except Exception as exc: # noqa: BLE001
+ logger.exception("restore failed")
+ return web.json_response(
+ {
+ "ok": False,
+ "error": str(exc),
+ "safety_backup": safety_name,
+ },
+ status=500,
+ )
+
+ return web.json_response(
+ {
+ "ok": True,
+ "message": "База восстановлена. Перезапустите bot при необходимости.",
+ "safety_backup": safety_name,
+ "backups": list_backups(),
+ }
+ )
+
+
+async def handle_admin_backups_upload(request: web.Request) -> web.Response:
+ """Только сохранить файл в список бэкапов (без restore)."""
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ if "multipart" not in (request.content_type or "").lower():
+ return web.json_response({"ok": False, "error": "ожидается multipart"}, status=400)
+ reader = await request.multipart()
+ file_bytes: bytes | None = None
+ filename = "upload.sql.gz"
+ while True:
+ part = await reader.next()
+ if part is None:
+ break
+ if part.name == "file":
+ filename = Path(part.filename or filename).name
+ file_bytes = await part.read(decode=False)
+ if not file_bytes:
+ return web.json_response({"ok": False, "error": "Файл не передан"}, status=400)
+ try:
+ path = await save_uploaded_backup(file_bytes, original_name=filename)
+ except Exception as exc: # noqa: BLE001
+ return web.json_response({"ok": False, "error": str(exc)}, status=400)
+ return web.json_response({"ok": True, "file": path.name, "backups": list_backups()})
+
+
+async def handle_admin_growth_get(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ return web.json_response(
+ {
+ "ok": True,
+ "settings": {
+ "referral_bonus_referrer": db.get_setting("referral_bonus_referrer", "50"),
+ "referral_bonus_referee": db.get_setting("referral_bonus_referee", "50"),
+ "trial_days": db.get_setting("trial_days", "3"),
+ "trial_tariff_id": db.get_setting("trial_tariff_id", ""),
+ },
+ }
+ )
+
+
+async def handle_admin_growth_set(request: web.Request) -> web.Response:
+ denied = _require_admin(request)
+ if denied:
+ return denied
+ db: Database = request.app["db"]
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ return web.json_response({"ok": False, "error": "invalid json"}, status=400)
+ mapping = {
+ "referral_bonus_referrer": "referral_bonus_referrer",
+ "referral_bonus_referee": "referral_bonus_referee",
+ "trial_days": "trial_days",
+ "trial_tariff_id": "trial_tariff_id",
+ }
+ data = {}
+ for k, sk in mapping.items():
+ if k in body and body[k] is not None:
+ data[sk] = str(body[k]).strip()
+ if data:
+ db.set_settings_map(data)
+ return await handle_admin_growth_get(request)
diff --git a/services/webauthn_auth.py b/services/webauthn_auth.py
new file mode 100644
index 0000000..d30d0c3
--- /dev/null
+++ b/services/webauthn_auth.py
@@ -0,0 +1,164 @@
+"""WebAuthn / Passkey helpers."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from urllib.parse import urlparse
+
+from webauthn import (
+ generate_authentication_options,
+ generate_registration_options,
+ options_to_json,
+ verify_authentication_response,
+ verify_registration_response,
+)
+from webauthn.helpers import (
+ base64url_to_bytes,
+ bytes_to_base64url,
+ parse_authentication_credential_json,
+ parse_registration_credential_json,
+)
+from webauthn.helpers.structs import (
+ AuthenticatorSelectionCriteria,
+ AuthenticatorTransport,
+ PublicKeyCredentialDescriptor,
+ ResidentKeyRequirement,
+ UserVerificationRequirement,
+)
+
+from config import Settings
+
+
+def rp_id_from_settings(settings: Settings) -> str:
+ raw = (settings.webapp_url or "").strip()
+ if not raw:
+ raise ValueError("WEBAPP_URL не задан — Passkey недоступен")
+ host = urlparse(raw).hostname
+ if not host:
+ raise ValueError("Некорректный WEBAPP_URL")
+ return host
+
+
+def expected_origin(settings: Settings) -> str:
+ origin = (settings.webapp_url or "").strip().rstrip("/")
+ if not origin:
+ raise ValueError("WEBAPP_URL не задан")
+ return origin
+
+
+def challenge_from_client_data(client_data_b64: str) -> str:
+ raw = base64url_to_bytes(client_data_b64)
+ data = json.loads(raw.decode("utf-8"))
+ challenge = data.get("challenge")
+ if not challenge:
+ raise ValueError("challenge missing")
+ return str(challenge)
+
+
+def build_registration_options(
+ settings: Settings,
+ *,
+ user_id: int,
+ user_name: str,
+ user_display_name: str,
+ existing_credential_ids: list[bytes],
+) -> tuple[dict[str, Any], bytes]:
+ options = generate_registration_options(
+ rp_id=rp_id_from_settings(settings),
+ rp_name="VPN Service",
+ user_id=str(user_id).encode("utf-8"),
+ user_name=user_name[:64] or f"user-{user_id}",
+ user_display_name=user_display_name[:64] or user_name,
+ exclude_credentials=[
+ PublicKeyCredentialDescriptor(id=cid) for cid in existing_credential_ids
+ ],
+ authenticator_selection=AuthenticatorSelectionCriteria(
+ resident_key=ResidentKeyRequirement.PREFERRED,
+ user_verification=UserVerificationRequirement.PREFERRED,
+ ),
+ )
+ challenge = options.challenge
+ return json.loads(options_to_json(options)), challenge
+
+
+def verify_registration(
+ settings: Settings,
+ *,
+ credential: dict[str, Any],
+ expected_challenge: bytes,
+) -> Any:
+ parsed = parse_registration_credential_json(json.dumps(credential))
+ return verify_registration_response(
+ credential=parsed,
+ expected_challenge=expected_challenge,
+ expected_rp_id=rp_id_from_settings(settings),
+ expected_origin=expected_origin(settings),
+ )
+
+
+def build_authentication_options(
+ settings: Settings,
+ *,
+ allow_credential_ids: list[bytes] | None = None,
+) -> tuple[dict[str, Any], bytes]:
+ allow = None
+ if allow_credential_ids:
+ allow = [PublicKeyCredentialDescriptor(id=cid) for cid in allow_credential_ids]
+ options = generate_authentication_options(
+ rp_id=rp_id_from_settings(settings),
+ allow_credentials=allow,
+ user_verification=UserVerificationRequirement.PREFERRED,
+ )
+ return json.loads(options_to_json(options)), options.challenge
+
+
+def verify_authentication(
+ settings: Settings,
+ *,
+ credential: dict[str, Any],
+ expected_challenge: bytes,
+ credential_public_key: bytes,
+ credential_current_sign_count: int,
+) -> Any:
+ parsed = parse_authentication_credential_json(json.dumps(credential))
+ return verify_authentication_response(
+ credential=parsed,
+ expected_challenge=expected_challenge,
+ expected_rp_id=rp_id_from_settings(settings),
+ expected_origin=expected_origin(settings),
+ credential_public_key=credential_public_key,
+ credential_current_sign_count=credential_current_sign_count,
+ )
+
+
+def credential_id_b64(raw: bytes) -> str:
+ return bytes_to_base64url(raw)
+
+
+def credential_id_bytes(b64: str) -> bytes:
+ return base64url_to_bytes(b64)
+
+
+def parse_transports(raw: Any) -> list[str]:
+ if not raw:
+ return []
+ if isinstance(raw, list):
+ return [str(x) for x in raw]
+ try:
+ data = json.loads(raw) if isinstance(raw, str) else []
+ except json.JSONDecodeError:
+ return []
+ if isinstance(data, list):
+ return [str(x) for x in data]
+ return []
+
+
+def transports_to_enum(names: list[str]) -> list[AuthenticatorTransport]:
+ out: list[AuthenticatorTransport] = []
+ for name in names:
+ try:
+ out.append(AuthenticatorTransport(name))
+ except ValueError:
+ continue
+ return out
diff --git a/services/xui_client.py b/services/xui_client.py
new file mode 100644
index 0000000..e345831
--- /dev/null
+++ b/services/xui_client.py
@@ -0,0 +1,643 @@
+"""Клиент API панели 3x-ui (v3.5.0+): login, inbounds, WireGuard clients."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import secrets
+import string
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import quote, urljoin, urlparse
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+# 3x-ui 3.5.0: WireGuard — multi-client через settings.clients + addClient
+SUPPORTED_PANEL = "3.5.0"
+
+
+class XuiError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True, slots=True)
+class XuiInbound:
+ id: int
+ remark: str
+ protocol: str
+ port: int
+ enable: bool
+ clients_count: int = 0
+ listen: str = ""
+
+ @property
+ def is_wireguard(self) -> bool:
+ return (self.protocol or "").lower() in {"wireguard", "wg"}
+
+
+@dataclass(frozen=True, slots=True)
+class XuiConnectionInfo:
+ ok: bool
+ version: str | None
+ inbounds_total: int
+ wireguard: list[XuiInbound]
+ message: str = ""
+
+
+def _normalize_base_url(url: str) -> str:
+ raw = (url or "").strip().rstrip("/")
+ if not raw:
+ raise XuiError("API URL пуст")
+ if not raw.startswith(("http://", "https://")):
+ raw = "https://" + raw
+ parsed = urlparse(raw)
+ if not parsed.netloc:
+ raise XuiError("Некорректный API URL")
+ return raw
+
+
+def _new_sub_id(length: int = 16) -> str:
+ alphabet = string.ascii_lowercase + string.digits
+ return "".join(secrets.choice(alphabet) for _ in range(length))
+
+
+def build_subscription_link(subscription_base: str, sub_id: str) -> str:
+ """Склеивает ручной base URL подписки с subId клиента."""
+ base = (subscription_base or "").strip().rstrip("/")
+ sid = (sub_id or "").strip().lstrip("/")
+ if not base or not sid:
+ return ""
+ return f"{base}/{sid}"
+
+
+def _parse_settings(raw: Any) -> dict[str, Any]:
+ if isinstance(raw, dict):
+ return raw
+ if isinstance(raw, str) and raw.strip():
+ try:
+ data = json.loads(raw)
+ return data if isinstance(data, dict) else {}
+ except json.JSONDecodeError:
+ return {}
+ return {}
+
+
+def _clients_count(settings: dict[str, Any]) -> int:
+ clients = settings.get("clients")
+ if isinstance(clients, list):
+ return len(clients)
+ peers = settings.get("peers")
+ if isinstance(peers, list):
+ return len(peers)
+ return 0
+
+
+def _inbound_from_obj(obj: dict[str, Any]) -> XuiInbound:
+ settings = _parse_settings(obj.get("settings"))
+ return XuiInbound(
+ id=int(obj.get("id") or 0),
+ remark=str(obj.get("remark") or ""),
+ protocol=str(obj.get("protocol") or ""),
+ port=int(obj.get("port") or 0),
+ enable=bool(obj.get("enable", True)),
+ clients_count=_clients_count(settings),
+ listen=str(obj.get("listen") or ""),
+ )
+
+
+class XuiClient:
+ """Сессия к одной панели 3x-ui.
+
+ Предпочтительно: API Token (Settings → Security → API Token) как Bearer.
+ Fallback: cookie после POST /login (username + password).
+ """
+
+ def __init__(
+ self,
+ api_url: str,
+ username: str = "",
+ password: str = "",
+ *,
+ api_key: str = "",
+ verify_ssl: bool = True,
+ timeout: float = 25.0,
+ ) -> None:
+ self.base_url = _normalize_base_url(api_url)
+ self.username = (username or "").strip()
+ self.password = password or ""
+ self.api_key = (api_key or "").strip()
+ self.verify_ssl = verify_ssl
+ self.timeout = timeout
+ self._client: httpx.AsyncClient | None = None
+ self._authed = False
+
+ async def __aenter__(self) -> XuiClient:
+ await self.open()
+ return self
+
+ async def __aexit__(self, *args: Any) -> None:
+ await self.close()
+
+ async def open(self) -> None:
+ if self._client is not None:
+ return
+ headers: dict[str, str] = {"Accept": "application/json"}
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+ self._client = httpx.AsyncClient(
+ base_url=self.base_url,
+ timeout=self.timeout,
+ verify=self.verify_ssl,
+ follow_redirects=True,
+ headers=headers,
+ )
+
+ async def close(self) -> None:
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+ self._authed = False
+
+ def _http(self) -> httpx.AsyncClient:
+ if self._client is None:
+ raise XuiError("Клиент не открыт — вызовите open() или используйте async with")
+ return self._client
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ json_body: dict[str, Any] | None = None,
+ data: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ http = self._http()
+ url = path if path.startswith("/") else f"/{path}"
+ try:
+ resp = await http.request(method, url, json=json_body, data=data)
+ except httpx.RequestError as exc:
+ raise XuiError(f"Сеть: {exc}") from exc
+
+ try:
+ payload = resp.json()
+ except json.JSONDecodeError:
+ text = (resp.text or "")[:200].replace("\n", " ")
+ hint = ""
+ if resp.status_code == 404:
+ hint = (
+ " (часто: неверный API Token — панель отвечает 404; "
+ "или устаревший путь API)"
+ )
+ raise XuiError(
+ f"Не JSON ответ ({resp.status_code}){hint}: {text}"
+ ) from None
+
+ if not isinstance(payload, dict):
+ raise XuiError("Неожиданный формат ответа панели")
+
+ if resp.status_code >= 400:
+ msg = payload.get("msg") or payload.get("message") or resp.reason_phrase
+ raise XuiError(f"HTTP {resp.status_code}: {msg}")
+
+ # 3x-ui обычно: {success: bool, msg: str, obj: ...}
+ if "success" in payload and payload.get("success") is False:
+ raise XuiError(str(payload.get("msg") or "Ошибка панели"))
+
+ return payload
+
+ async def ensure_auth(self) -> None:
+ """Авторизация: Bearer API key или login cookie."""
+ if self._authed:
+ return
+ await self.open()
+ if self.api_key:
+ # Bearer уже в headers — login не нужен (обходит CSRF на /panel/api/*)
+ self._authed = True
+ return
+ await self.login()
+ self._authed = True
+
+ async def login(self) -> None:
+ if not self.username or not self.password:
+ raise XuiError("Нужен API Token или пара логин/пароль")
+ # Классический form login; часть сборок принимает JSON
+ try:
+ await self._request(
+ "POST",
+ "/login",
+ data={"username": self.username, "password": self.password},
+ )
+ except XuiError:
+ await self._request(
+ "POST",
+ "/login",
+ json_body={"username": self.username, "password": self.password},
+ )
+
+ async def get_version(self) -> str | None:
+ for path in ("/panel/api/server/status", "/panel/api/server/getConfigJson"):
+ try:
+ payload = await self._request("GET", path)
+ except XuiError:
+ continue
+ obj = payload.get("obj")
+ if isinstance(obj, dict):
+ for key in ("version", "xrayVersion", "appVersion"):
+ val = obj.get(key)
+ if val:
+ return str(val)
+ # status иногда кладёт version на верхний уровень obj как строку в nested
+ if isinstance(obj, dict) and "cpu" in obj:
+ # нет версии в status — ок
+ return None
+ return None
+
+ async def list_inbounds(self) -> list[XuiInbound]:
+ payload = await self._request("GET", "/panel/api/inbounds/list")
+ obj = payload.get("obj")
+ items: list[Any]
+ if isinstance(obj, list):
+ items = obj
+ elif isinstance(obj, dict):
+ items = obj.get("inbounds") or obj.get("list") or []
+ else:
+ items = []
+ out: list[XuiInbound] = []
+ for item in items:
+ if isinstance(item, dict) and item.get("id") is not None:
+ out.append(_inbound_from_obj(item))
+ return out
+
+ async def get_inbound(self, inbound_id: int) -> dict[str, Any]:
+ payload = await self._request("GET", f"/panel/api/inbounds/get/{int(inbound_id)}")
+ obj = payload.get("obj")
+ if not isinstance(obj, dict):
+ raise XuiError(f"Инбаунд #{inbound_id} не найден")
+ return obj
+
+ async def list_wireguard_inbounds(self) -> list[XuiInbound]:
+ return [i for i in await self.list_inbounds() if i.is_wireguard]
+
+ async def add_wireguard_client(
+ self,
+ inbound_id: int,
+ *,
+ email: str,
+ sub_id: str | None = None,
+ tg_id: int | str = 0,
+ expiry_ms: int = 0,
+ total_gb: int = 0,
+ limit_ip: int = 0,
+ enable: bool = True,
+ comment: str = "",
+ ) -> dict[str, Any]:
+ """Добавляет WireGuard-клиента (3x-ui 3.5.0: POST /panel/api/clients/add)."""
+ await self.ensure_auth()
+ sid = (sub_id or "").strip() or _new_sub_id()
+ try:
+ tg_num = int(tg_id) if tg_id not in ("", None) else 0
+ except (TypeError, ValueError):
+ tg_num = 0
+ client: dict[str, Any] = {
+ "email": email,
+ "enable": bool(enable),
+ "expiryTime": int(expiry_ms),
+ "totalGB": int(total_gb),
+ "limitIp": int(limit_ip),
+ "tgId": tg_num,
+ "subId": sid,
+ "comment": comment or "",
+ "reset": 0,
+ }
+ modern_body = {
+ "client": client,
+ "inboundIds": [int(inbound_id)],
+ }
+ try:
+ payload = await self._request(
+ "POST",
+ "/panel/api/clients/add",
+ json_body=modern_body,
+ )
+ except XuiError as modern_exc:
+ # Fallback для старых панелей с /inbounds/addClient
+ legacy_body = {
+ "id": int(inbound_id),
+ "settings": json.dumps({"clients": [client]}, ensure_ascii=False),
+ }
+ try:
+ payload = await self._request(
+ "POST",
+ "/panel/api/inbounds/addClient",
+ json_body=legacy_body,
+ )
+ except XuiError:
+ raise modern_exc from None
+
+ created = await self.get_client(inbound_id, email=email)
+ if created and created.get("subId"):
+ sid = str(created.get("subId") or sid)
+ return {
+ "sub_id": sid,
+ "email": email,
+ "client": created,
+ "response": payload,
+ }
+
+ def _find_client_in_inbound(
+ self, inbound: dict[str, Any], *, email: str
+ ) -> dict[str, Any] | None:
+ settings = _parse_settings(inbound.get("settings"))
+ clients = settings.get("clients")
+ if not isinstance(clients, list):
+ return None
+ email_l = email.strip().lower()
+ for client in clients:
+ if isinstance(client, dict) and str(client.get("email") or "").strip().lower() == email_l:
+ return client
+ return None
+
+ def _path_email(self, email: str) -> str:
+ """Email в path: простые id без кодирования, иначе PathEscape-совместимо."""
+ raw = (email or "").strip()
+ if re.fullmatch(r"[A-Za-z0-9_.-]+", raw or ""):
+ return raw
+ return quote(raw, safe="._-~")
+
+ @staticmethod
+ def _sanitize_client_payload(
+ client: dict[str, Any],
+ *,
+ enable: bool | None = None,
+ email: str | None = None,
+ ) -> dict[str, Any]:
+ """Только поля клиента — без traffic/inboundIds (иначе update ломается)."""
+ allowed = {
+ "email",
+ "enable",
+ "expiryTime",
+ "totalGB",
+ "limitIp",
+ "tgId",
+ "subId",
+ "comment",
+ "reset",
+ "flow",
+ "password",
+ "id",
+ "security",
+ "encryption",
+ "privateKey",
+ "publicKey",
+ "preSharedKey",
+ "allowedIPs",
+ "keepAlive",
+ "method",
+ "auth",
+ }
+ out: dict[str, Any] = {}
+ for key, value in client.items():
+ if key not in allowed or value is None:
+ continue
+ if key == "allowedIPs" and not isinstance(value, list):
+ continue
+ if key != "allowedIPs" and isinstance(value, (dict, list)):
+ continue
+ out[key] = value
+
+ # Client.id в 3x-ui — строка (UUID и т.п.), не numeric id из таблицы clients
+ raw_id = out.get("id", None)
+ if raw_id is None and client.get("uuid"):
+ raw_id = client.get("uuid")
+ if isinstance(raw_id, int):
+ # числовой PK таблицы clients нельзя слать как Client.id
+ out.pop("id", None)
+ if client.get("uuid"):
+ out["id"] = str(client["uuid"])
+ elif raw_id is not None:
+ out["id"] = str(raw_id)
+
+ if email:
+ out["email"] = email
+ elif "email" not in out and client.get("email"):
+ out["email"] = client["email"]
+ if enable is not None:
+ out["enable"] = bool(enable)
+
+ # tgId панель иногда ждёт число
+ if "tgId" in out:
+ try:
+ out["tgId"] = int(out["tgId"] or 0)
+ except (TypeError, ValueError):
+ out["tgId"] = 0
+ return out
+
+ async def get_client(
+ self, inbound_id: int, *, email: str
+ ) -> dict[str, Any] | None:
+ await self.ensure_auth()
+ # Modern API
+ try:
+ payload = await self._request(
+ "GET",
+ f"/panel/api/clients/get/{self._path_email(email)}",
+ )
+ obj = payload.get("obj")
+ if isinstance(obj, dict):
+ client = obj.get("client") if isinstance(obj.get("client"), dict) else obj
+ if isinstance(client, dict) and client.get("email"):
+ return client
+ except XuiError:
+ pass
+ inbound = await self.get_inbound(inbound_id)
+ return self._find_client_in_inbound(inbound, email=email)
+
+ async def update_client(
+ self,
+ inbound_id: int,
+ client_id: str,
+ client: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Обновление клиента: modern clients/update → legacy updateClient → inbound update."""
+ await self.ensure_auth()
+ email = str(client.get("email") or client_id).strip()
+ clean = self._sanitize_client_payload(client, email=email)
+
+ # 1) Modern API
+ for path in (
+ f"/panel/api/clients/update/{self._path_email(email)}",
+ f"/panel/api/clients/update/{self._path_email(email)}?inboundIds={int(inbound_id)}",
+ ):
+ try:
+ return await self._request("POST", path, json_body=clean)
+ except XuiError:
+ pass
+
+ # 2) Legacy inbounds/updateClient
+ legacy_body = {
+ "id": int(inbound_id),
+ "settings": json.dumps({"clients": [clean]}, ensure_ascii=False),
+ }
+ for client_key in (email, str(clean.get("id") or ""), client_id):
+ if not client_key:
+ continue
+ path = f"/panel/api/inbounds/updateClient/{self._path_email(str(client_key))}"
+ try:
+ return await self._request("POST", path, json_body=legacy_body)
+ except XuiError:
+ pass
+ try:
+ return await self._request(
+ "POST",
+ path,
+ data={
+ "id": str(int(inbound_id)),
+ "settings": legacy_body["settings"],
+ },
+ )
+ except XuiError:
+ pass
+
+ # 3) Fallback: правим поля прямо в settings inbound
+ return await self._update_inbound_client_fields(
+ inbound_id,
+ email=email,
+ patch=clean,
+ )
+
+ async def _update_inbound_client_fields(
+ self,
+ inbound_id: int,
+ *,
+ email: str,
+ patch: dict[str, Any],
+ ) -> dict[str, Any]:
+ inbound = await self.get_inbound(inbound_id)
+ settings = _parse_settings(inbound.get("settings"))
+ clients = settings.get("clients")
+ if not isinstance(clients, list):
+ raise XuiError(f"В inbound #{inbound_id} нет clients[]")
+ email_l = email.strip().lower()
+ found = False
+ new_clients: list[Any] = []
+ for item in clients:
+ if not isinstance(item, dict):
+ new_clients.append(item)
+ continue
+ if str(item.get("email") or "").strip().lower() == email_l:
+ merged = dict(item)
+ merged.update(patch)
+ merged["email"] = item.get("email") or email
+ new_clients.append(merged)
+ found = True
+ else:
+ new_clients.append(item)
+ if not found:
+ raise XuiError(f"Клиент {email} не найден в inbound #{inbound_id}")
+ settings["clients"] = new_clients
+
+ payload = dict(inbound)
+ payload["id"] = int(inbound_id)
+ payload["settings"] = json.dumps(settings, ensure_ascii=False)
+ for key in ("streamSettings", "sniffing", "allocate"):
+ val = payload.get(key)
+ if isinstance(val, (dict, list)):
+ payload[key] = json.dumps(val, ensure_ascii=False)
+ # убрать служебные поля ответа get
+ for key in ("clientStats", "clients", "obj"):
+ payload.pop(key, None)
+ return await self._request(
+ "POST",
+ f"/panel/api/inbounds/update/{int(inbound_id)}",
+ json_body=payload,
+ )
+
+ async def set_client_enabled(
+ self,
+ inbound_id: int,
+ *,
+ email: str,
+ enable: bool,
+ ) -> dict[str, Any]:
+ await self.ensure_auth()
+ current = await self.get_client(inbound_id, email=email)
+ if not current:
+ # всё равно пробуем через inbound settings
+ return await self._update_inbound_client_fields(
+ inbound_id,
+ email=email,
+ patch={"enable": bool(enable), "email": email},
+ )
+ clean = self._sanitize_client_payload(current, enable=enable, email=email)
+ try:
+ return await self.update_client(inbound_id, email, clean)
+ except XuiError:
+ return await self._update_inbound_client_fields(
+ inbound_id,
+ email=email,
+ patch=clean,
+ )
+
+ async def delete_client_by_email(self, inbound_id: int, email: str) -> dict[str, Any]:
+ await self.ensure_auth()
+ try:
+ return await self._request(
+ "POST",
+ f"/panel/api/clients/del/{self._path_email(email)}",
+ )
+ except XuiError:
+ path = (
+ f"/panel/api/inbounds/{int(inbound_id)}/"
+ f"delClientByEmail/{self._path_email(email)}"
+ )
+ try:
+ return await self._request("POST", path)
+ except XuiError:
+ # отвязка / disable как мягкое удаление
+ return await self._update_inbound_client_fields(
+ inbound_id,
+ email=email,
+ patch={"enable": False, "email": email},
+ )
+
+ async def test_connection(self) -> XuiConnectionInfo:
+ await self.open()
+ try:
+ await self.ensure_auth()
+ inbounds = await self.list_inbounds()
+ wg = [i for i in inbounds if i.is_wireguard]
+ version = None
+ try:
+ version = await self.get_version()
+ except XuiError:
+ version = None
+ auth_mode = "api_key" if self.api_key else "login"
+ msg = (
+ f"OK · {auth_mode} · inbounds {len(inbounds)} · WireGuard {len(wg)}"
+ + (f" · panel {version}" if version else f" · target {SUPPORTED_PANEL}+")
+ )
+ return XuiConnectionInfo(
+ ok=True,
+ version=version,
+ inbounds_total=len(inbounds),
+ wireguard=wg,
+ message=msg,
+ )
+ except XuiError as exc:
+ return XuiConnectionInfo(
+ ok=False,
+ version=None,
+ inbounds_total=0,
+ wireguard=[],
+ message=str(exc),
+ )
+
+
+def subscription_preview(subscription_url: str, sample_sub_id: str = "exampleSubId") -> str:
+ link = build_subscription_link(subscription_url, sample_sub_id)
+ if link:
+ return link
+ return urljoin(_normalize_base_url(subscription_url or "https://example.invalid") + "/", sample_sub_id)
diff --git a/services/xui_wg.py b/services/xui_wg.py
new file mode 100644
index 0000000..23c90ca
--- /dev/null
+++ b/services/xui_wg.py
@@ -0,0 +1,588 @@
+"""Покупка и управление WireGuard-конфигами 3x-ui (лимит 10 при активной подписке)."""
+
+from __future__ import annotations
+
+import logging
+import secrets
+import string
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+from remnawave import RemnawaveSDK
+
+from services.db import Database
+from services.purchase import get_my_subscription_info
+from services.remnawave_users import find_panel_user
+from services.traffic import (
+ BASE_TRAFFIC_GB,
+ ensure_base_limit_bytes,
+ format_gb,
+ gb_to_bytes,
+)
+from services.xui_client import XuiClient, XuiError, build_subscription_link
+
+logger = logging.getLogger(__name__)
+
+MAX_WG_CONFIGS = 10
+SETTING_ENABLED = "xui_wg_purchase_enabled"
+SETTING_PRICE = "xui_wg_config_price"
+DEFAULT_PRICE = 0.0
+
+
+def _truthy(value: str) -> bool:
+ return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
+
+
+def _as_utc(dt: datetime) -> datetime:
+ if dt.tzinfo is None:
+ return dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+
+
+@dataclass(slots=True)
+class WgPurchaseSettings:
+ enabled: bool = False
+ price: float = DEFAULT_PRICE
+ max_configs: int = MAX_WG_CONFIGS
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "enabled": self.enabled,
+ "price": self.price,
+ "price_label": _format_price(self.price),
+ "max_configs": self.max_configs,
+ }
+
+
+@dataclass(slots=True)
+class WgActionResult:
+ ok: bool
+ message: str
+ balance: float = 0.0
+ config: dict[str, Any] | None = None
+ configs: list[dict[str, Any]] | None = None
+
+
+def _format_price(price: float) -> str:
+ if price <= 0:
+ return "бесплатно"
+ if price == int(price):
+ return f"{int(price)} ₽"
+ return f"{price:.2f} ₽"
+
+
+def load_wg_purchase_settings(db: Database) -> WgPurchaseSettings:
+ raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE])
+ price_raw = (raw.get(SETTING_PRICE) or "").strip()
+ try:
+ price = float(price_raw) if price_raw else DEFAULT_PRICE
+ except ValueError:
+ price = DEFAULT_PRICE
+ return WgPurchaseSettings(
+ enabled=_truthy(raw.get(SETTING_ENABLED, "")),
+ price=max(0.0, price),
+ max_configs=MAX_WG_CONFIGS,
+ )
+
+
+def save_wg_purchase_settings(
+ db: Database,
+ *,
+ enabled: bool | None = None,
+ price: float | None = None,
+) -> WgPurchaseSettings:
+ data: dict[str, str] = {}
+ if enabled is not None:
+ data[SETTING_ENABLED] = "1" if enabled else "0"
+ if price is not None:
+ data[SETTING_PRICE] = str(max(0.0, float(price)))
+ if data:
+ db.set_settings_map(data)
+ return load_wg_purchase_settings(db)
+
+
+def _client_from_server(row: dict[str, Any]) -> XuiClient:
+ return XuiClient(
+ str(row["api_url"]),
+ username=str(row.get("username") or ""),
+ password=str(row.get("password") or ""),
+ api_key=str(row.get("api_key") or ""),
+ verify_ssl=bool(row.get("verify_ssl", True)),
+ )
+
+
+def _new_email(telegram_id: int) -> str:
+ suffix = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(6))
+ return f"wg_{telegram_id}_{suffix}"
+
+
+def serialize_wg_config(row: dict[str, Any], server: dict[str, Any] | None = None) -> dict[str, Any]:
+ expire = row.get("expire_at")
+ traffic_bytes = int(row.get("traffic_bytes") or 0)
+ if traffic_bytes <= 0:
+ traffic_bytes = int(gb_to_bytes(BASE_TRAFFIC_GB))
+ return {
+ "id": int(row["id"]),
+ "server_id": int(row["server_id"]),
+ "server_name": (server or {}).get("name") or row.get("server_name") or "",
+ "email": row.get("email") or "",
+ "sub_id": row.get("sub_id") or "",
+ "subscription_url": row.get("subscription_url") or "",
+ "remark": row.get("remark") or "",
+ "enabled": bool(row.get("enabled", True)),
+ "expire_at": expire.isoformat() if isinstance(expire, datetime) else expire,
+ "traffic_bytes": traffic_bytes,
+ "traffic_label": format_gb(traffic_bytes / (1024**3), digits=0),
+ "qr_url": f"/api/wg/configs/{int(row['id'])}/qr",
+ "created_at": row["created_at"].isoformat()
+ if isinstance(row.get("created_at"), datetime)
+ else row.get("created_at"),
+ }
+
+
+async def _subscription_limits(
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ *,
+ login: str | None = None,
+ db: Database | None = None,
+) -> tuple[datetime, int]:
+ """Срок и лимит трафика как у подписки в кабинете (база ≥ 1000 ГБ)."""
+ info = await get_my_subscription_info(sdk, telegram_id, login=login, db=db)
+ if not info.expire_at:
+ raise RuntimeError("Нет срока подписки")
+ expire = _as_utc(info.expire_at)
+ panel = await find_panel_user(sdk, telegram_id, login=login)
+ if panel is not None:
+ traffic_bytes = int(ensure_base_limit_bytes(getattr(panel, "traffic_limit_bytes", 0)))
+ elif info.traffic:
+ traffic_bytes = int(ensure_base_limit_bytes(info.traffic.limit_bytes))
+ else:
+ traffic_bytes = int(gb_to_bytes(BASE_TRAFFIC_GB))
+ return expire, traffic_bytes
+
+
+async def subscription_is_active(
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ *,
+ login: str | None = None,
+ db: Database | None = None,
+) -> tuple[bool, str]:
+ info = await get_my_subscription_info(sdk, telegram_id, login=login, db=db)
+ if not info.has_subscription or not info.expire_at:
+ return False, "Нет активной подписки. Сначала купите тариф."
+ now = datetime.now(timezone.utc)
+ if _as_utc(info.expire_at) <= now:
+ return (
+ False,
+ "Подписка истекла. Продлите или купите тариф — после этого снова можно включать конфиги.",
+ )
+ if info.devices and info.devices.over_limit:
+ return False, "Подписка отключена из‑за превышения устройств."
+ return True, ""
+
+
+def list_available_servers(db: Database) -> list[dict[str, Any]]:
+ rows = db.list_xui_servers(enabled_only=True)
+ out: list[dict[str, Any]] = []
+ for r in rows:
+ if r.get("inbound_id") is None:
+ continue
+ if not str(r.get("subscription_url") or "").strip():
+ continue
+ if not (str(r.get("api_key") or "").strip() or str(r.get("password") or "").strip()):
+ continue
+ out.append(
+ {
+ "id": int(r["id"]),
+ "name": r.get("name") or f"Server #{r['id']}",
+ "inbound_id": r.get("inbound_id"),
+ "inbound_remark": r.get("inbound_remark") or "",
+ }
+ )
+ return out
+
+
+async def purchase_wg_config(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ server_id: int,
+ remark: str = "",
+ login: str | None = None,
+) -> WgActionResult:
+ settings = load_wg_purchase_settings(db)
+ bot_user = db.ensure_user(telegram_id)
+ balance = bot_user.balance
+ login = login or bot_user.login
+
+ if not settings.enabled:
+ return WgActionResult(
+ ok=False,
+ message="Покупка WireGuard-конфигов временно отключена.",
+ balance=balance,
+ )
+
+ active, reason = await subscription_is_active(
+ sdk, telegram_id, login=login, db=db
+ )
+ if not active:
+ return WgActionResult(ok=False, message=reason, balance=balance)
+
+ count = db.count_xui_wg_configs(telegram_id)
+ if count >= settings.max_configs:
+ return WgActionResult(
+ ok=False,
+ message=f"Лимит: максимум {settings.max_configs} конфигов на аккаунт.",
+ balance=balance,
+ )
+
+ server = db.get_xui_server(server_id)
+ if not server or not server.get("enabled"):
+ return WgActionResult(ok=False, message="Сервер недоступен.", balance=balance)
+ inbound_id = server.get("inbound_id")
+ if inbound_id is None:
+ return WgActionResult(
+ ok=False,
+ message="У сервера не выбран WireGuard inbound.",
+ balance=balance,
+ )
+ sub_base = str(server.get("subscription_url") or "").strip()
+ if not sub_base:
+ return WgActionResult(
+ ok=False,
+ message="У сервера не задан URL подписки.",
+ balance=balance,
+ )
+
+ price = float(settings.price)
+ if price > 0:
+ ok, balance = db.try_charge(
+ telegram_id,
+ price,
+ kind="wg_config",
+ meta=f"wg:{server_id}",
+ )
+ if not ok:
+ return WgActionResult(
+ ok=False,
+ message=(
+ f"Недостаточно средств.\n"
+ f"Нужно: {_format_price(price)}\n"
+ f"Баланс: {balance:.0f} ₽"
+ ),
+ balance=balance,
+ )
+
+ email = _new_email(telegram_id)
+ remark = (remark or "").strip()[:80] or f"WG {bot_user.display_name}"
+ try:
+ expire_at, traffic_bytes = await _subscription_limits(
+ sdk, telegram_id, login=login, db=db
+ )
+ expiry_ms = int(expire_at.timestamp() * 1000)
+ async with _client_from_server(server) as client:
+ created = await client.add_wireguard_client(
+ int(inbound_id),
+ email=email,
+ tg_id=telegram_id if telegram_id > 0 else 0,
+ comment=remark,
+ enable=True,
+ expiry_ms=expiry_ms,
+ total_gb=traffic_bytes, # в 3x-ui clients API totalGB — байты
+ )
+ sub_id = str(created["sub_id"])
+ sub_url = build_subscription_link(sub_base, sub_id)
+ row = db.create_xui_wg_config(
+ telegram_id=telegram_id,
+ server_id=server_id,
+ email=email,
+ sub_id=sub_id,
+ subscription_url=sub_url,
+ remark=remark,
+ enabled=True,
+ expire_at=expire_at,
+ traffic_bytes=traffic_bytes,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("WG config create failed")
+ if price > 0:
+ balance = db.add_balance(
+ telegram_id,
+ price,
+ kind="refund",
+ meta=f"wg_refund:{server_id}",
+ )
+ return WgActionResult(
+ ok=False,
+ message=f"Не удалось создать конфиг: {exc}",
+ balance=balance,
+ )
+
+ cfg = serialize_wg_config(row, server)
+ expire_str = expire_at.strftime("%d.%m.%Y %H:%M UTC")
+ return WgActionResult(
+ ok=True,
+ message=(
+ f"WireGuard-конфиг создан"
+ + (f" (−{_format_price(price)})" if price > 0 else "")
+ + f".\nДо: {expire_str}"
+ + f"\nТрафик: {format_gb(traffic_bytes / (1024**3), digits=0)}"
+ + f".\n🔗 {sub_url}"
+ ),
+ balance=balance if price <= 0 else balance,
+ config=cfg,
+ )
+
+
+async def set_wg_config_enabled(
+ *,
+ db: Database,
+ telegram_id: int,
+ config_id: int,
+ enable: bool,
+ sdk: RemnawaveSDK | None = None,
+ login: str | None = None,
+) -> WgActionResult:
+ bot_user = db.ensure_user(telegram_id)
+ login = login or bot_user.login
+ row = db.get_xui_wg_config(config_id, telegram_id=telegram_id)
+ if not row:
+ return WgActionResult(ok=False, message="Конфиг не найден.", balance=bot_user.balance)
+
+ if enable:
+ if sdk is None:
+ return WgActionResult(
+ ok=False,
+ message="Не удалось проверить подписку.",
+ balance=bot_user.balance,
+ )
+ active, reason = await subscription_is_active(
+ sdk, telegram_id, login=login, db=db
+ )
+ if not active:
+ return WgActionResult(ok=False, message=reason, balance=bot_user.balance)
+
+ server = db.get_xui_server(int(row["server_id"]))
+ if not server or server.get("inbound_id") is None:
+ return WgActionResult(
+ ok=False,
+ message="Сервер конфига недоступен.",
+ balance=bot_user.balance,
+ )
+
+ try:
+ async with _client_from_server(server) as client:
+ await client.set_client_enabled(
+ int(server["inbound_id"]),
+ email=str(row["email"]),
+ enable=enable,
+ )
+ updated = db.set_xui_wg_config_enabled(config_id, telegram_id, enable)
+ except XuiError as exc:
+ return WgActionResult(
+ ok=False,
+ message=f"Панель: {exc}",
+ balance=bot_user.balance,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.exception("WG toggle failed")
+ return WgActionResult(
+ ok=False,
+ message=str(exc),
+ balance=bot_user.balance,
+ )
+
+ return WgActionResult(
+ ok=True,
+ message="Конфиг включён." if enable else "Конфиг выключен.",
+ balance=bot_user.balance,
+ config=serialize_wg_config(updated or row, server) if updated or row else None,
+ )
+
+
+async def _disable_wg_row(db: Database, row: dict[str, Any]) -> bool:
+ """Выключает один WG-конфиг на панели и в БД. True если изменили."""
+ if not row.get("enabled"):
+ return False
+ server = db.get_xui_server(int(row["server_id"]))
+ if not server or server.get("inbound_id") is None:
+ db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), False)
+ return True
+ try:
+ async with _client_from_server(server) as client:
+ await client.set_client_enabled(
+ int(server["inbound_id"]),
+ email=str(row["email"]),
+ enable=False,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "WG auto-disable panel failed config=%s: %s",
+ row.get("id"),
+ exc,
+ )
+ db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), False)
+ return True
+
+
+async def _enable_wg_row(db: Database, row: dict[str, Any]) -> bool:
+ if row.get("enabled"):
+ return False
+ server = db.get_xui_server(int(row["server_id"]))
+ if not server or server.get("inbound_id") is None:
+ return False
+ try:
+ async with _client_from_server(server) as client:
+ await client.set_client_enabled(
+ int(server["inbound_id"]),
+ email=str(row["email"]),
+ enable=True,
+ )
+ db.set_xui_wg_config_enabled(int(row["id"]), int(row["telegram_id"]), True)
+ return True
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "WG auto-enable panel failed config=%s: %s",
+ row.get("id"),
+ exc,
+ )
+ return False
+
+
+async def enforce_wg_configs_subscription(
+ *,
+ db: Database,
+ sdk: RemnawaveSDK,
+ telegram_id: int,
+ login: str | None = None,
+) -> dict[str, Any]:
+ """В реальном времени: нет подписки → выключить все WG."""
+ bot_user = db.ensure_user(telegram_id)
+ login = login or bot_user.login
+ active, reason = await subscription_is_active(
+ sdk, telegram_id, login=login, db=db
+ )
+ disabled = 0
+ if not active:
+ for row in db.list_xui_wg_configs(telegram_id):
+ if await _disable_wg_row(db, row):
+ disabled += 1
+ return {
+ "subscription_active": active,
+ "reason": "" if active else reason,
+ "disabled": disabled,
+ }
+
+
+async def restore_wg_configs_after_renew(
+ *,
+ db: Database,
+ telegram_id: int,
+) -> int:
+ """После продления снова включает ранее отключённые WG-конфиги."""
+ rows = db.list_xui_wg_configs(telegram_id)
+ restored = 0
+ for row in rows:
+ if row.get("enabled"):
+ continue
+ if await _enable_wg_row(db, row):
+ restored += 1
+ return restored
+
+
+async def sync_wg_configs_limits(
+ *,
+ db: Database,
+ telegram_id: int,
+ expire_at: datetime | None = None,
+ traffic_bytes: int | None = None,
+ restore_enabled: bool = False,
+) -> int:
+ """Обновляет срок/трафик всех WG-конфигов пользователя на панелях 3x-ui."""
+ if expire_at is None and traffic_bytes is None and not restore_enabled:
+ return 0
+ rows = db.list_xui_wg_configs(telegram_id)
+ updated = 0
+ expiry_ms = int(_as_utc(expire_at).timestamp() * 1000) if expire_at else None
+ for row in rows:
+ server = db.get_xui_server(int(row["server_id"]))
+ if not server or server.get("inbound_id") is None:
+ continue
+ try:
+ async with _client_from_server(server) as client:
+ current = await client.get_client(
+ int(server["inbound_id"]),
+ email=str(row["email"]),
+ )
+ if not current:
+ continue
+ patch = dict(current)
+ patch["email"] = str(row["email"])
+ if expiry_ms is not None:
+ patch["expiryTime"] = expiry_ms
+ if traffic_bytes is not None:
+ patch["totalGB"] = int(traffic_bytes)
+ if restore_enabled:
+ patch["enable"] = True
+ await client.update_client(
+ int(server["inbound_id"]),
+ str(row["email"]),
+ patch,
+ )
+ db.update_xui_wg_config_limits(
+ int(row["id"]),
+ expire_at=expire_at,
+ traffic_bytes=traffic_bytes,
+ )
+ if restore_enabled and not row.get("enabled"):
+ db.set_xui_wg_config_enabled(
+ int(row["id"]), telegram_id, True
+ )
+ updated += 1
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "WG sync limits failed config=%s: %s",
+ row.get("id"),
+ exc,
+ )
+ return updated
+
+
+def list_user_wg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]:
+ rows = db.list_xui_wg_configs(telegram_id)
+ servers = {int(s["id"]): s for s in db.list_xui_servers()}
+ return [serialize_wg_config(r, servers.get(int(r["server_id"]))) for r in rows]
+
+
+async def delete_wg_config(
+ *,
+ db: Database,
+ telegram_id: int,
+ config_id: int,
+) -> WgActionResult:
+ bot_user = db.ensure_user(telegram_id)
+ row = db.get_xui_wg_config(config_id, telegram_id=telegram_id)
+ if not row:
+ return WgActionResult(ok=False, message="Конфиг не найден.", balance=bot_user.balance)
+
+ server = db.get_xui_server(int(row["server_id"]))
+ if server and server.get("inbound_id") is not None:
+ try:
+ async with _client_from_server(server) as client:
+ await client.delete_client_by_email(
+ int(server["inbound_id"]),
+ str(row["email"]),
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("WG delete on panel failed (will drop locally): %s", exc)
+
+ db.delete_xui_wg_config(config_id, telegram_id)
+ return WgActionResult(
+ ok=True,
+ message="Конфиг удалён. Слот освобождён.",
+ balance=bot_user.balance,
+ )
diff --git a/tariffs.json b/tariffs.json
new file mode 100644
index 0000000..17f8ef5
--- /dev/null
+++ b/tariffs.json
@@ -0,0 +1,84 @@
+[
+
+ {
+
+ "id": "month_1",
+
+ "title": "1 месяц",
+
+ "price": 199,
+
+ "days": 30,
+
+ "traffic_gb": 1000,
+
+ "device_limit": 10,
+
+ "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"],
+
+ "description": "Все серверы · 10 устройств · 1000 ГБ"
+
+ },
+
+ {
+
+ "id": "month_3",
+
+ "title": "3 месяца",
+
+ "price": 499,
+
+ "days": 90,
+
+ "traffic_gb": 1000,
+
+ "device_limit": 10,
+
+ "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"],
+
+ "description": "Выгоднее · 10 устройств · 1000 ГБ"
+
+ },
+
+ {
+
+ "id": "month_6",
+
+ "title": "6 месяцев",
+
+ "price": 899,
+
+ "days": 180,
+
+ "traffic_gb": 1000,
+
+ "device_limit": 10,
+
+ "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"],
+
+ "description": "Максимум экономии · 10 устройств · 1000 ГБ"
+
+ },
+
+ {
+
+ "id": "year_1",
+
+ "title": "12 месяцев",
+
+ "price": 1599,
+
+ "days": 365,
+
+ "traffic_gb": 1000,
+
+ "device_limit": 10,
+
+ "squad_uuids": ["03a5cc01-80b0-426e-9356-d2aa6ec20988"],
+
+ "description": "Год доступа · 10 устройств · 1000 ГБ"
+
+ }
+
+]
+
diff --git a/webapp/admin.html b/webapp/admin.html
new file mode 100644
index 0000000..092076a
--- /dev/null
+++ b/webapp/admin.html
@@ -0,0 +1,1087 @@
+
+
+
+
+
+ Только для администраторов
++ Отдельный вход для /admin. +
+ + +VPN Service
+Привет!
+Здесь всё для безопасного интернета
+Загрузка…
+Коротко для новичков
+Пополнение и история операций
+От 10 до 3000 ₽ за одну оплату
+Загрузка способов оплаты…
+Пополнение прямо в Telegram (Web и приложение)
++ Карта, СБП и др. — на странице покупки 40+ способов оплаты. После оплаты введите код ниже. +
+Пополнение на сайте и в боте · тарифы — с баланса
+Последние операции
+WireGuard и AmneziaWG конфиги
+ + +Дополнительный трафик и устройства
+ + +Почта, уведомления, Passkey и устройства
+Email, Passkey и вход с другого устройства
+—
+ +Бесплатные дни для новых аккаунтов
+Оплата с баланса. Пополнить можно в разделе «Баланс»: карта, СБП или крипта (10–3000 ₽).
+ +Скачайте приложение под ваше устройство
+ +Ответы на частые вопросы
+ +Нажмите «Каталог» или добавьте модель вручную
'; + return; + } + for (const m of show) { + const label = document.createElement("label"); + label.className = "ai-model-chip"; + if (selected.has(m.id)) label.classList.add("active"); + label.innerHTML = ` + + ${escapeHtml(m.name || m.id)} +${escapeHtml(m.id)}
+ `;
+ box.appendChild(label);
+ }
+ }
+
+ async function loadAiSettings() {
+ const data = await api("/api/admin/ai");
+ const ai = data.ai || {};
+ $("#ai-enabled").checked = Boolean(ai.enabled);
+ $("#ai-auto-reply").checked = ai.auto_reply !== false;
+ $("#ai-enabled-bar")?.classList.toggle("is-off", !ai.enabled);
+ $("#ai-auto-bar")?.classList.toggle("is-off", !ai.auto_reply);
+ const enStatus = $("#ai-enabled-status");
+ if (enStatus) enStatus.textContent = ai.enabled ? "Включены" : "Выключены";
+ $("#ai-api-key").value = "";
+ $("#ai-api-key").placeholder = ai.api_key_set
+ ? ai.api_key_masked || "ключ сохранён"
+ : "sk-or-v1-…";
+ $("#ai-key-hint").textContent = ai.api_key_set
+ ? `Сохранён: ${ai.api_key_masked}`
+ : "Ключ не задан — возьмите на openrouter.ai/keys";
+ aiSelectedModels = Array.isArray(ai.models) && ai.models.length
+ ? ai.models
+ : ai.defaults || [];
+ aiClearKey = false;
+ $("#ai-system-prompt").value = ai.system_prompt || "";
+ renderAiModelSelect(ai.model || aiSelectedModels[0] || "");
+ renderAiModelsPick($("#ai-model-search").value || "");
+ }
+
+ async function loadStats() {
+ const data = await api("/api/admin/stats");
+ const s = data.stats || {};
+ const t = data.tickets || {};
+ $("#admin-stats").innerHTML = `
+ Сгенерировано за всё время
+Можно выдать пользователям
+Уже активированы
+Ждут ответа поддержки
+Тикетов пока нет
+Когда пользователи напишут — обращения появятся здесь
+Серверов пока нет
+Добавьте панель 3x-ui 3.5.0 с WireGuard inbound — кнопка «+ Сервер»
+Серверов AWG пока нет
+Добавьте панель panel5 · API Token · выберите ноду с awg2
+Тарифов пока нет
+Добавьте первый тариф для продажи подписок
+Не удалось загрузить серверы 3x-ui
'; + }); + await loadWgSettings().catch(() => {}); + await loadAwgServers().catch(() => { + $("#awg-servers-list").innerHTML = + 'Не удалось загрузить серверы AWG
'; + }); + await loadAwgSettings().catch(() => {}); + await loadAdminTariffs().catch(() => { + $("#tariffs-admin-list").innerHTML = + 'Не удалось загрузить тарифы
'; + }); + await loadPayments().catch(() => {}); + await loadSmtp().catch(() => {}); + await loadGrowth().catch(() => {}); + await loadJournal().catch(() => {}); + await loadBackups().catch(() => {}); + } else { + showPanel(false); + } + } catch { + showPanel(false); + } + } + + $("#form-admin-login").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const err = $("#admin-login-error"); + err.classList.add("hidden"); + try { + await api("/api/admin/login", { + method: "POST", + body: JSON.stringify({ + login: fd.get("login"), + password: fd.get("password"), + }), + }); + showPanel(true); + await loadStats(); + await loadCodes(); + await loadUsers(); + await loadTickets(); + await loadAiSettings(); + await loadXuiServers().catch(() => {}); + await loadWgSettings().catch(() => {}); + await loadAwgServers().catch(() => {}); + await loadAwgSettings().catch(() => {}); + await loadAdminTariffs().catch(() => {}); + await loadPayments().catch(() => {}); + await loadSmtp().catch(() => {}); + toast("Добро пожаловать"); + } catch (ex) { + err.textContent = ex.message || "Ошибка входа"; + err.classList.remove("hidden"); + } + }); + + $("#btn-admin-logout").addEventListener("click", async () => { + try { + await api("/api/admin/logout", { method: "POST", body: "{}" }); + } catch { + /* ignore */ + } + setToken(""); + showPanel(false); + $("#generated-wrap").classList.add("hidden"); + }); + + $("#form-generate").addEventListener("submit", async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + const data = await api("/api/admin/codes", { + method: "POST", + body: JSON.stringify({ + amount: Number(fd.get("amount")), + count: Number(fd.get("count")), + note: fd.get("note") || "web_admin", + }), + }); + const codes = data.codes || []; + lastGenerated = codes.join("\n"); + const box = $("#generated-codes"); + box.textContent = lastGenerated; + $("#generated-wrap").classList.remove("hidden"); + toast(`Создано кодов: ${data.inserted || 0}`); + await loadStats(); + await loadCodes(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-copy-codes").addEventListener("click", async () => { + if (await copyText(lastGenerated)) toast("Все коды скопированы"); + else toast("Нечего копировать"); + }); + + $("#btn-refresh-codes").addEventListener("click", () => + loadCodes().catch((ex) => toast(ex.message)) + ); + $("#filter-status").addEventListener("change", () => + loadCodes().catch((ex) => toast(ex.message)) + ); + + bindCodeCopy(); + + $("#users-tbody").addEventListener("click", async (e) => { + const banBtn = e.target.closest("[data-ban-id]"); + if (banBtn) { + try { + await api("/api/admin/users/ban", { + method: "POST", + body: JSON.stringify({ + telegram_id: Number(banBtn.dataset.banId), + banned: banBtn.dataset.ban === "1", + }), + }); + toast(banBtn.dataset.ban === "1" ? "Пользователь забанен" : "Бан снят"); + await loadUsers(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + return; + } + const delBtn = e.target.closest("[data-del-user]"); + if (delBtn) { + const tid = Number(delBtn.dataset.delUser); + const name = delBtn.dataset.delName || tid; + const ok = confirm( + `Удалить пользователя «${name}» (ID ${tid}) навсегда?\n\nБудут удалены баланс, сессии, тикеты, конфиги WG/AWG и история покупок в боте. Это нельзя отменить.` + ); + if (!ok) return; + const ok2 = confirm(`Точно удалить ID ${tid}?`); + if (!ok2) return; + try { + const data = await api("/api/admin/users/delete", { + method: "POST", + body: JSON.stringify({ telegram_id: tid, confirm: true }), + }); + toast(data.message || "Удалён"); + await loadUsers(); + } catch (ex) { + toast(ex.message || "Ошибка удаления"); + } + return; + } + const btn = e.target.closest("[data-credit-id]"); + if (!btn) return; + openCreditDialog(btn.dataset.creditId, btn.dataset.creditName || btn.dataset.creditId); + }); + + async function loadGrowth() { + try { + const data = await api("/api/admin/growth-settings"); + const s = data.settings || {}; + $("#growth-ref-referrer").value = s.referral_bonus_referrer || "50"; + $("#growth-ref-referee").value = s.referral_bonus_referee || "50"; + $("#growth-trial-days").value = s.trial_days || "3"; + $("#growth-trial-tariff").value = s.trial_tariff_id || ""; + } catch { + /* ignore */ + } + try { + const data = await api("/api/admin/promos"); + const box = $("#promos-list"); + if (!box) return; + const items = data.promos || []; + box.innerHTML = items.length + ? items + .map( + (p) => + `Нет бэкапов — создайте первый
'; + return; + } + box.innerHTML = items + .map((b) => { + const kb = (b.size / 1024).toFixed(1); + const when = (b.mtime || "").replace("T", " ").slice(0, 19); + const name = escapeAttr(b.name); + return `Загрузка нод…
'; + const data = await api("/api/admin/awg/discover", { + method: "POST", + body: JSON.stringify({ + api_url: apiUrl, + api_token: apiToken, + verify_ssl: $("#awg-verify-ssl").checked, + }), + }); + const servers = data.servers || []; + if (!servers.length) { + box.innerHTML = 'На панели нет серверов
'; + return; + } + box.innerHTML = servers + .map((s) => { + const protos = (s.protocols || []).join(", ") || "—"; + const badge = s.has_awg2 + ? 'awg2' + : 'no awg2'; + return ` + `; + }) + .join(""); + } catch (ex) { + box.classList.remove("hidden"); + box.innerHTML = `${escapeHtml(ex.message || "Ошибка")}
`; + } + }); + + $("#awg-discover-box")?.addEventListener("click", (e) => { + const btn = e.target.closest("[data-pick-panel]"); + if (!btn) return; + $("#awg-panel-server-id").value = btn.dataset.pickPanel; + if (!$("#awg-name").value) { + $("#awg-name").value = btn.dataset.pickName || `AWG #${btn.dataset.pickPanel}`; + } + toast(`Выбран server #${btn.dataset.pickPanel}`); + }); + + $("#btn-awg-test")?.addEventListener("click", async () => { + const out = $("#awg-test-out"); + out.classList.remove("hidden"); + out.textContent = "Проверка…"; + try { + const existingId = ($("#awg-id").value || "").trim(); + const { id, body } = collectAwgFormBody(!existingId); + let data; + if (id) { + if (body.api_token) { + await api(`/api/admin/awg/servers/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + } + data = await api(`/api/admin/awg/servers/${id}/test`, { + method: "POST", + body: "{}", + }); + } else { + data = await api("/api/admin/awg/test", { + method: "POST", + body: JSON.stringify(body), + }); + } + out.textContent = [ + data.ok ? "OK" : "FAIL", + data.message || data.error || "", + data.ping_ms != null ? `ping: ${data.ping_ms} ms` : "", + (data.protocols || []).length ? `protocols: ${(data.protocols || []).join(", ")}` : "", + ] + .filter(Boolean) + .join("\n"); + toast(data.ok ? "Проверка OK" : data.message || "Ошибка"); + if (id) await loadAwgServers().catch(() => {}); + } catch (ex) { + out.textContent = ex.message || "Ошибка"; + toast(ex.message || "Ошибка"); + } + }); + + $("#form-awg")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + const existingId = ($("#awg-id").value || "").trim(); + const { id, body } = collectAwgFormBody(!existingId); + if (id) { + await api(`/api/admin/awg/servers/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }); + toast("Сервер обновлён"); + } else { + await api("/api/admin/awg/servers", { + method: "POST", + body: JSON.stringify(body), + }); + toast("Сервер добавлен"); + } + hideAwgForm(); + await loadAwgServers(); + } catch (ex) { + toast(ex.message || "Ошибка сохранения"); + } + }); + + $("#form-awg-settings")?.addEventListener("submit", async (e) => { + e.preventDefault(); + try { + await saveAwgSettings(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#btn-awg-settings-save")?.addEventListener("click", async () => { + try { + await saveAwgSettings(); + } catch (ex) { + toast(ex.message || "Ошибка"); + } + }); + + $("#awg-purchase-enabled")?.addEventListener("change", async () => { + try { + await saveAwgSettings(); + } catch (ex) { + $("#awg-purchase-enabled").checked = !$("#awg-purchase-enabled").checked; + toast(ex.message || "Ошибка"); + } + }); + + $("#admin-nav")?.addEventListener("click", (e) => { + const btn = e.target.closest(".admin-nav-btn"); + if (!btn) return; + jumpToSection(btn.dataset.jump || "stats"); + }); + + bootstrap(); +})(); diff --git a/webapp/static/app.js b/webapp/static/app.js new file mode 100644 index 0000000..c7ffa32 --- /dev/null +++ b/webapp/static/app.js @@ -0,0 +1,2523 @@ +(() => { + const THEME_KEY = "ef_theme"; + + function getTheme() { + return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; + } + + function applyTheme(theme) { + const t = theme === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", t); + document.documentElement.setAttribute("data-bs-theme", t); + try { + localStorage.setItem(THEME_KEY, t); + } catch (_) {} + const tg = window.Telegram?.WebApp; + if (tg) { + const color = t === "light" ? "#eef3f0" : "#0a0f0d"; + try { + tg.setHeaderColor(color); + tg.setBackgroundColor(color); + } catch (_) {} + } + } + + const tg = window.Telegram?.WebApp; + if (tg) { + tg.ready(); + tg.expand(); + applyTheme(getTheme()); + document.documentElement.classList.add("is-telegram"); + document.body.classList.add("is-telegram"); + try { + if (typeof tg.disableVerticalSwipes === "function") tg.disableVerticalSwipes(); + } catch (_) {} + } + + const initData = tg?.initData || ""; + const TOKEN_KEY = "vpn_session_token"; + const $ = (sel) => document.querySelector(sel); + + document.getElementById("btn-theme-toggle")?.addEventListener("click", () => { + applyTheme(getTheme() === "dark" ? "light" : "dark"); + }); + + function setBalancePill(amount) { + const el = $("#balance-pill"); + if (!el) return; + if (amount === null || amount === undefined || amount === "" || amount === "—") { + el.innerHTML = ` —`; + return; + } + const n = typeof amount === "number" ? Math.round(amount) : amount; + el.innerHTML = ` ${n} ₽`; + } + + const state = { + me: null, + tariffs: [], + botUsername: "", + telegramClientId: "", + authed: false, + starsEnabled: false, + starsRubPerStar: 1.5, + topup: null, + }; + + function getStoredToken() { + try { + return localStorage.getItem(TOKEN_KEY) || ""; + } catch { + return ""; + } + } + + function setStoredToken(token) { + try { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } + } + + let qrPollTimer = null; + + function b64urlToBuf(b64) { + const pad = "=".repeat((4 - ((b64 || "").length % 4)) % 4); + const str = atob(String(b64 || "").replace(/-/g, "+").replace(/_/g, "/") + pad); + const buf = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i); + return buf.buffer; + } + + function bufToB64url(buf) { + const bytes = new Uint8Array(buf); + let s = ""; + bytes.forEach((b) => { + s += String.fromCharCode(b); + }); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function preparePublicKeyCreate(options) { + const o = { ...options }; + o.challenge = b64urlToBuf(options.challenge); + if (o.user?.id) o.user = { ...o.user, id: b64urlToBuf(o.user.id) }; + if (Array.isArray(o.excludeCredentials)) { + o.excludeCredentials = o.excludeCredentials.map((c) => ({ + ...c, + id: b64urlToBuf(c.id), + })); + } + return o; + } + + function preparePublicKeyGet(options) { + const o = { ...options }; + o.challenge = b64urlToBuf(options.challenge); + if (Array.isArray(o.allowCredentials)) { + o.allowCredentials = o.allowCredentials.map((c) => ({ + ...c, + id: b64urlToBuf(c.id), + })); + } + return o; + } + + function credentialToJSON(cred) { + const response = cred.response; + const out = { + id: cred.id, + rawId: bufToB64url(cred.rawId), + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment || undefined, + response: { + clientDataJSON: bufToB64url(response.clientDataJSON), + attestationObject: response.attestationObject + ? bufToB64url(response.attestationObject) + : undefined, + authenticatorData: response.authenticatorData + ? bufToB64url(response.authenticatorData) + : undefined, + signature: response.signature ? bufToB64url(response.signature) : undefined, + userHandle: response.userHandle ? bufToB64url(response.userHandle) : undefined, + }, + }; + if (typeof response.getTransports === "function") { + out.transports = response.getTransports(); + } + return out; + } + + function stopQrPoll() { + if (qrPollTimer) { + clearInterval(qrPollTimer); + qrPollTimer = null; + } + $("#qr-auth-box")?.classList.add("hidden"); + } + + function toast(msg) { + const el = $("#toast"); + el.textContent = msg.replace(/<[^>]+>/g, " "); + el.classList.remove("hidden"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), 3200); + } + + async function copyText(text) { + const value = String(text || ""); + if (!value) return false; + + // Clipboard API часто падает в Telegram WebView / при открытом