v2.0
This commit is contained in:
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Services package."""
|
||||
744
app/services/bot_config_service.py
Normal file
744
app/services/bot_config_service.py
Normal file
@@ -0,0 +1,744 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import BotSetting
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BotConfigFieldSpec:
|
||||
key: str
|
||||
label: str
|
||||
section: str
|
||||
prompt: str
|
||||
description: str
|
||||
placeholder: str = ""
|
||||
is_secret_like: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BotConfigSnapshot:
|
||||
bot_brand_name: str
|
||||
bot_public_username: str
|
||||
bot_support_url: str
|
||||
bot_terms_url: str
|
||||
bot_support_ticket_link: str
|
||||
payment_review_link: str
|
||||
bot_start_image_enabled: bool
|
||||
bot_start_image_path: str
|
||||
referral_enabled: bool
|
||||
referral_discount_percent: int
|
||||
referral_bonus_days: int
|
||||
payment_plans_raw: str
|
||||
payment_transfer_text: str
|
||||
payment_support_text: str
|
||||
payment_plan_traffic_limit_gb: int
|
||||
payment_plan_traffic_reset_period: str
|
||||
payment_internal_squad_uuids_raw: str
|
||||
payment_external_squad_uuid: str
|
||||
payment_username_prefix: str
|
||||
payment_user_tag: str
|
||||
notification_enabled: bool = True
|
||||
notification_days_before: str = "1,3"
|
||||
notification_check_interval_hours: int = 6
|
||||
fallback_support_ticket_chat_id: int | None = None
|
||||
fallback_support_ticket_thread_id: int | None = None
|
||||
fallback_payment_review_chat_id: int | None = None
|
||||
fallback_payment_review_thread_id: int | None = None
|
||||
|
||||
@property
|
||||
def bot_public_username_normalized(self) -> str:
|
||||
return self.bot_public_username.strip().removeprefix("@")
|
||||
|
||||
@property
|
||||
def payment_external_squad_uuid_normalized(self) -> str | None:
|
||||
value = self.payment_external_squad_uuid.strip()
|
||||
return value or None
|
||||
|
||||
@property
|
||||
def payment_username_prefix_normalized(self) -> str:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_username_prefix.strip() if ch.isalnum() or ch in "_-"
|
||||
)
|
||||
return normalized[:12] or "Oreol"
|
||||
|
||||
@property
|
||||
def payment_user_tag_normalized(self) -> str | None:
|
||||
normalized = "".join(
|
||||
ch for ch in self.payment_user_tag.strip().upper() if ch.isalnum() or ch == "_"
|
||||
)
|
||||
return normalized[:16] or None
|
||||
|
||||
@property
|
||||
def payment_internal_squad_uuids(self) -> list[str]:
|
||||
values: list[str] = []
|
||||
for raw_part in self.payment_internal_squad_uuids_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
return values
|
||||
|
||||
@property
|
||||
def notification_days_list(self) -> list[int]:
|
||||
values: list[int] = []
|
||||
for raw_part in self.notification_days_before.split(","):
|
||||
part = raw_part.strip()
|
||||
if part.isdigit():
|
||||
day = int(part)
|
||||
if day > 0:
|
||||
values.append(day)
|
||||
return sorted(set(values), reverse=True) if values else [1, 3]
|
||||
|
||||
@property
|
||||
def payment_plans(self) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in self.payment_plans_raw.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
continue
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
continue
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
if not normalized:
|
||||
return "-"
|
||||
if len(normalized) <= max_length:
|
||||
return normalized
|
||||
return f"{normalized[: max_length - 3]}..."
|
||||
|
||||
@property
|
||||
def support_ticket_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return parsed_chat_id if parsed_chat_id is not None else self.fallback_support_ticket_chat_id
|
||||
|
||||
@property
|
||||
def support_ticket_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.bot_support_ticket_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_support_ticket_thread_id
|
||||
)
|
||||
|
||||
@property
|
||||
def payment_review_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.payment_review_link)
|
||||
return parsed_chat_id if parsed_chat_id is not None else self.fallback_payment_review_chat_id
|
||||
|
||||
@property
|
||||
def payment_review_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.payment_review_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_payment_review_thread_id
|
||||
)
|
||||
|
||||
|
||||
class StaticBotConfigService:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def get_snapshot(self) -> BotConfigSnapshot:
|
||||
return BotConfigSnapshot(
|
||||
bot_brand_name=self._settings.bot_brand_name,
|
||||
bot_public_username=self._settings.bot_public_username,
|
||||
bot_support_url=self._settings.bot_support_url,
|
||||
bot_terms_url=self._settings.bot_terms_url,
|
||||
bot_support_ticket_link=self._settings.bot_support_ticket_link,
|
||||
payment_review_link=self._settings.payment_review_link,
|
||||
bot_start_image_enabled=self._settings.bot_start_image_enabled,
|
||||
bot_start_image_path=self._settings.bot_start_image_path,
|
||||
referral_enabled=True,
|
||||
referral_discount_percent=self._settings.referral_discount_percent,
|
||||
referral_bonus_days=self._settings.referral_bonus_days,
|
||||
payment_plans_raw=self._settings.payment_plans_raw,
|
||||
payment_transfer_text=self._settings.payment_transfer_text,
|
||||
payment_support_text=self._settings.payment_support_text,
|
||||
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
|
||||
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period,
|
||||
notification_enabled=True,
|
||||
notification_days_before="1,3",
|
||||
notification_check_interval_hours=6,
|
||||
payment_internal_squad_uuids_raw=self._settings.payment_internal_squad_uuids_raw,
|
||||
payment_external_squad_uuid=self._settings.payment_external_squad_uuid,
|
||||
payment_username_prefix=self._settings.payment_username_prefix,
|
||||
payment_user_tag=self._settings.payment_user_tag,
|
||||
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
|
||||
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
|
||||
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
|
||||
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
|
||||
)
|
||||
|
||||
|
||||
class BotConfigService(StaticBotConfigService):
|
||||
FIELD_SPECS: dict[str, BotConfigFieldSpec] = {
|
||||
"bot_brand_name": BotConfigFieldSpec(
|
||||
key="bot_brand_name",
|
||||
label="Название бренда",
|
||||
section="brand",
|
||||
prompt="Введите новое название бренда.",
|
||||
description="Используется в главной панели и пользовательских сообщениях.",
|
||||
placeholder="OREOL VPN",
|
||||
),
|
||||
"bot_public_username": BotConfigFieldSpec(
|
||||
key="bot_public_username",
|
||||
label="Username бота",
|
||||
section="brand",
|
||||
prompt="Введите username бота без @. Для очистки отправьте `-`.",
|
||||
description="Нужен для генерации реферальных ссылок.",
|
||||
placeholder="oreol_vpn_bot",
|
||||
),
|
||||
"bot_start_image_enabled": BotConfigFieldSpec(
|
||||
key="bot_start_image_enabled",
|
||||
label="Стартовая картинка",
|
||||
section="brand",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает бренд-картинку в панели и выдаче доступа.",
|
||||
placeholder="on",
|
||||
),
|
||||
"bot_start_image_path": BotConfigFieldSpec(
|
||||
key="bot_start_image_path",
|
||||
label="Путь к картинке",
|
||||
section="brand",
|
||||
prompt="Введите путь к изображению. Для очистки отправьте `-`.",
|
||||
description="Можно указать относительный путь от корня проекта.",
|
||||
placeholder="assets/main.png",
|
||||
),
|
||||
"payment_plans_raw": BotConfigFieldSpec(
|
||||
key="payment_plans_raw",
|
||||
label="Тарифы и цены",
|
||||
section="pricing",
|
||||
prompt="Введите тарифы в формате `30:250,180:600,365:1000`.",
|
||||
description="Каждая пара — это `дни:цена_в_рублях`.",
|
||||
placeholder="30:250,180:600,365:1000",
|
||||
),
|
||||
"payment_plan_traffic_limit_gb": BotConfigFieldSpec(
|
||||
key="payment_plan_traffic_limit_gb",
|
||||
label="Лимит трафика, GB",
|
||||
section="pricing",
|
||||
prompt="Введите лимит трафика в гигабайтах. `0` = безлимит.",
|
||||
description="Применяется к тарифам, создаваемым ботом.",
|
||||
placeholder="0",
|
||||
),
|
||||
"payment_plan_traffic_reset_period": BotConfigFieldSpec(
|
||||
key="payment_plan_traffic_reset_period",
|
||||
label="Стратегия сброса трафика",
|
||||
section="pricing",
|
||||
prompt="Введите стратегию, например `NO_RESET`.",
|
||||
description="Значение передаётся в Remnawave.",
|
||||
placeholder="NO_RESET",
|
||||
),
|
||||
"referral_enabled": BotConfigFieldSpec(
|
||||
key="referral_enabled",
|
||||
label="Реферальная система",
|
||||
section="referral",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает ввод рефкода и начисление реферальных бонусов.",
|
||||
placeholder="on",
|
||||
),
|
||||
"referral_discount_percent": BotConfigFieldSpec(
|
||||
key="referral_discount_percent",
|
||||
label="Скидка по рефкоду, %",
|
||||
section="referral",
|
||||
prompt="Введите размер скидки в процентах от 0 до 100.",
|
||||
description="Применяется к покупателю перед оплатой.",
|
||||
placeholder="5",
|
||||
),
|
||||
"referral_bonus_days": BotConfigFieldSpec(
|
||||
key="referral_bonus_days",
|
||||
label="Бонус рефереру, дней",
|
||||
section="referral",
|
||||
prompt="Введите количество бонусных дней. `0` отключает бонус.",
|
||||
description="Начисляется после подтверждённой оплаты по рефкоду.",
|
||||
placeholder="7",
|
||||
),
|
||||
"bot_support_url": BotConfigFieldSpec(
|
||||
key="bot_support_url",
|
||||
label="Ссылка на поддержку",
|
||||
section="links",
|
||||
prompt="Введите URL поддержки. Для очистки отправьте `-`.",
|
||||
description="Кнопка быстрой связи с поддержкой в пользовательской панели.",
|
||||
placeholder="https://t.me/your_support_account",
|
||||
),
|
||||
"bot_terms_url": BotConfigFieldSpec(
|
||||
key="bot_terms_url",
|
||||
label="Ссылка на Terms",
|
||||
section="links",
|
||||
prompt="Введите URL правил использования. Для очистки отправьте `-`.",
|
||||
description="Открывается из раздела условий использования.",
|
||||
placeholder="https://example.com/terms",
|
||||
),
|
||||
"bot_support_ticket_link": BotConfigFieldSpec(
|
||||
key="bot_support_ticket_link",
|
||||
label="Ссылка на чат тикетов",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик тикетов. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/2/3",
|
||||
),
|
||||
"payment_review_link": BotConfigFieldSpec(
|
||||
key="payment_review_link",
|
||||
label="Ссылка на очередь оплат",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик проверки оплат. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/56/57",
|
||||
),
|
||||
"payment_transfer_text": BotConfigFieldSpec(
|
||||
key="payment_transfer_text",
|
||||
label="Инструкция по оплате",
|
||||
section="texts",
|
||||
prompt="Введите текст с реквизитами или инструкцией перевода.",
|
||||
description="Показывается пользователю после выбора тарифа.",
|
||||
placeholder="Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN",
|
||||
),
|
||||
"payment_support_text": BotConfigFieldSpec(
|
||||
key="payment_support_text",
|
||||
label="Текст поддержки по оплате",
|
||||
section="texts",
|
||||
prompt="Введите текст, который бот покажет при проблемах с оплатой.",
|
||||
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
|
||||
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
|
||||
),
|
||||
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
|
||||
key="payment_internal_squad_uuids_raw",
|
||||
label="UUID внутренних групп",
|
||||
section="provisioning",
|
||||
prompt="Введите UUID внутренних групп через запятую.",
|
||||
description="Эти группы назначаются пользователю в Remnawave.",
|
||||
placeholder="11111111-1111-1111-1111-111111111111",
|
||||
),
|
||||
"payment_external_squad_uuid": BotConfigFieldSpec(
|
||||
key="payment_external_squad_uuid",
|
||||
label="UUID внешней группы",
|
||||
section="provisioning",
|
||||
prompt="Введите UUID внешней группы. Для очистки отправьте `-`.",
|
||||
description="Необязательный внешний squad для новых пользователей.",
|
||||
placeholder="11111111-1111-1111-1111-111111111111",
|
||||
),
|
||||
"payment_username_prefix": BotConfigFieldSpec(
|
||||
key="payment_username_prefix",
|
||||
label="Префикс логина",
|
||||
section="provisioning",
|
||||
prompt="Введите префикс логина для новых пользователей Remnawave.",
|
||||
description="Итоговый логин строится как `<prefix>-<telegram_id>-<name>`.",
|
||||
placeholder="Oreol",
|
||||
),
|
||||
"payment_user_tag": BotConfigFieldSpec(
|
||||
key="payment_user_tag",
|
||||
label="Tag пользователей",
|
||||
section="provisioning",
|
||||
prompt="Введите tag для создаваемых пользователей. Для очистки отправьте `-`.",
|
||||
description="Опциональный tag в Remnawave.",
|
||||
placeholder="BOT",
|
||||
),
|
||||
"notification_enabled": BotConfigFieldSpec(
|
||||
key="notification_enabled",
|
||||
label="Уведомления об истечении",
|
||||
section="notifications",
|
||||
prompt="Введите `on` или `off`.",
|
||||
description="Включает или выключает уведомления о скором истечении подписки.",
|
||||
placeholder="on",
|
||||
),
|
||||
"notification_days_before": BotConfigFieldSpec(
|
||||
key="notification_days_before",
|
||||
label="Дни до истечения",
|
||||
section="notifications",
|
||||
prompt="Введите дни через запятую, например `1,3`.",
|
||||
description="За сколько дней до истечения отправлять уведомления.",
|
||||
placeholder="1,3",
|
||||
),
|
||||
"notification_check_interval_hours": BotConfigFieldSpec(
|
||||
key="notification_check_interval_hours",
|
||||
label="Интервал проверки, ч",
|
||||
section="notifications",
|
||||
prompt="Введите интервал проверки в часах.",
|
||||
description="Как часто бот проверяет подписки на истечение.",
|
||||
placeholder="6",
|
||||
),
|
||||
}
|
||||
|
||||
SECTION_LABELS: dict[str, str] = {
|
||||
"brand": "Бренд",
|
||||
"pricing": "Тарифы",
|
||||
"referral": "Рефералка",
|
||||
"links": "Ссылки",
|
||||
"texts": "Тексты",
|
||||
"provisioning": "Provisioning",
|
||||
"notifications": "Уведомления",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
super().__init__(settings)
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def get_snapshot(self) -> BotConfigSnapshot:
|
||||
overrides = await self._get_overrides()
|
||||
|
||||
return BotConfigSnapshot(
|
||||
bot_brand_name=str(overrides.get("bot_brand_name", self._settings.bot_brand_name)),
|
||||
bot_public_username=str(overrides.get("bot_public_username", self._settings.bot_public_username)),
|
||||
bot_support_url=str(overrides.get("bot_support_url", self._settings.bot_support_url)),
|
||||
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)),
|
||||
bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)),
|
||||
payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)),
|
||||
bot_start_image_enabled=self._parse_bool(
|
||||
overrides.get("bot_start_image_enabled"),
|
||||
default=self._settings.bot_start_image_enabled,
|
||||
),
|
||||
bot_start_image_path=str(overrides.get("bot_start_image_path", self._settings.bot_start_image_path)),
|
||||
referral_enabled=self._parse_bool(
|
||||
overrides.get("referral_enabled"),
|
||||
default=True,
|
||||
),
|
||||
referral_discount_percent=self._parse_int(
|
||||
overrides.get("referral_discount_percent"),
|
||||
default=self._settings.referral_discount_percent,
|
||||
),
|
||||
referral_bonus_days=self._parse_int(
|
||||
overrides.get("referral_bonus_days"),
|
||||
default=self._settings.referral_bonus_days,
|
||||
),
|
||||
payment_plans_raw=str(overrides.get("payment_plans_raw", self._settings.payment_plans_raw)),
|
||||
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
|
||||
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
|
||||
payment_plan_traffic_limit_gb=self._parse_int(
|
||||
overrides.get("payment_plan_traffic_limit_gb"),
|
||||
default=self._settings.payment_plan_traffic_limit_gb,
|
||||
),
|
||||
payment_plan_traffic_reset_period=str(
|
||||
overrides.get(
|
||||
"payment_plan_traffic_reset_period",
|
||||
self._settings.payment_plan_traffic_reset_period,
|
||||
)
|
||||
),
|
||||
payment_internal_squad_uuids_raw=str(
|
||||
overrides.get(
|
||||
"payment_internal_squad_uuids_raw",
|
||||
self._settings.payment_internal_squad_uuids_raw,
|
||||
)
|
||||
),
|
||||
payment_external_squad_uuid=str(
|
||||
overrides.get(
|
||||
"payment_external_squad_uuid",
|
||||
self._settings.payment_external_squad_uuid,
|
||||
)
|
||||
),
|
||||
payment_username_prefix=str(
|
||||
overrides.get("payment_username_prefix", self._settings.payment_username_prefix)
|
||||
),
|
||||
payment_user_tag=str(overrides.get("payment_user_tag", self._settings.payment_user_tag)),
|
||||
notification_enabled=self._parse_bool(
|
||||
overrides.get("notification_enabled"),
|
||||
default=True,
|
||||
),
|
||||
notification_days_before=str(overrides.get("notification_days_before", "1,3")),
|
||||
notification_check_interval_hours=self._parse_int(
|
||||
overrides.get("notification_check_interval_hours"),
|
||||
default=6,
|
||||
),
|
||||
fallback_support_ticket_chat_id=self._settings.support_ticket_chat_id,
|
||||
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
|
||||
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
|
||||
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
|
||||
)
|
||||
|
||||
async def update_setting(
|
||||
self,
|
||||
*,
|
||||
key: str,
|
||||
raw_value: str,
|
||||
updated_by_telegram_id: int,
|
||||
) -> str:
|
||||
spec = self.FIELD_SPECS.get(key)
|
||||
if spec is None:
|
||||
raise ValueError("Неизвестная настройка.")
|
||||
|
||||
normalized_value = self._normalize_value(key, raw_value)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
record = await session.scalar(
|
||||
select(BotSetting).where(BotSetting.key == key)
|
||||
)
|
||||
if record is None:
|
||||
record = BotSetting(key=key)
|
||||
session.add(record)
|
||||
|
||||
record.value = normalized_value
|
||||
record.updated_by_telegram_id = updated_by_telegram_id
|
||||
record.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return normalized_value
|
||||
|
||||
@classmethod
|
||||
def get_spec(cls, key: str) -> BotConfigFieldSpec | None:
|
||||
return cls.FIELD_SPECS.get(key)
|
||||
|
||||
@classmethod
|
||||
def get_specs_for_section(cls, section: str) -> list[BotConfigFieldSpec]:
|
||||
return [spec for spec in cls.FIELD_SPECS.values() if spec.section == section]
|
||||
|
||||
@classmethod
|
||||
def get_sections(cls) -> list[tuple[str, str]]:
|
||||
return list(cls.SECTION_LABELS.items())
|
||||
|
||||
@classmethod
|
||||
def get_section_label(cls, section: str) -> str:
|
||||
return cls.SECTION_LABELS.get(section, section)
|
||||
|
||||
@classmethod
|
||||
def format_value(cls, *, snapshot: BotConfigSnapshot, key: str) -> str:
|
||||
value = getattr(snapshot, key, "")
|
||||
|
||||
if key in {"bot_start_image_enabled", "referral_enabled", "notification_enabled"}:
|
||||
return "on" if bool(value) else "off"
|
||||
|
||||
if key == "bot_public_username":
|
||||
normalized = snapshot.bot_public_username_normalized
|
||||
return f"@{normalized}" if normalized else "-"
|
||||
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
"payment_username_prefix",
|
||||
"payment_internal_squad_uuids_raw",
|
||||
"payment_plans_raw",
|
||||
"payment_plan_traffic_reset_period",
|
||||
"bot_brand_name",
|
||||
"notification_days_before",
|
||||
}:
|
||||
return cls._compact_text(str(value))
|
||||
|
||||
if key == "payment_plan_traffic_limit_gb":
|
||||
amount = int(value or 0)
|
||||
return "0 GB (unlimited)" if amount <= 0 else f"{amount} GB"
|
||||
|
||||
if key == "referral_discount_percent":
|
||||
return f"{max(int(value or 0), 0)}%"
|
||||
|
||||
if key == "referral_bonus_days":
|
||||
return f"{max(int(value or 0), 0)} days"
|
||||
|
||||
if key == "notification_check_interval_hours":
|
||||
return f"{max(int(value or 6), 1)} ч"
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
return cls._compact_text(str(value), max_length=120)
|
||||
|
||||
return cls._compact_text(str(value))
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(raw_value: str, *, max_length: int = 80) -> str:
|
||||
normalized = " ".join(raw_value.split()).strip()
|
||||
if not normalized:
|
||||
return "-"
|
||||
if len(normalized) <= max_length:
|
||||
return normalized
|
||||
return f"{normalized[: max_length - 3]}..."
|
||||
|
||||
async def _get_overrides(self) -> dict[str, str]:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(select(BotSetting))
|
||||
return {
|
||||
row.key: row.value
|
||||
for row in rows.scalars().all()
|
||||
}
|
||||
|
||||
def _normalize_value(self, key: str, raw_value: str) -> str:
|
||||
value = raw_value.strip()
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
"bot_public_username",
|
||||
} and value == "-":
|
||||
return ""
|
||||
|
||||
if key == "bot_brand_name":
|
||||
if not value:
|
||||
raise ValueError("Название бренда не может быть пустым.")
|
||||
return value[:80]
|
||||
|
||||
if key == "bot_public_username":
|
||||
return value.removeprefix("@")
|
||||
|
||||
if key == "bot_start_image_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "bot_start_image_path":
|
||||
return value
|
||||
|
||||
if key == "payment_plans_raw":
|
||||
plans = self._parse_payment_plans(value)
|
||||
return ",".join(f"{plan.days}:{plan.amount_rub}" for plan in plans)
|
||||
|
||||
if key == "payment_plan_traffic_limit_gb":
|
||||
amount = self._parse_non_negative_int(value, "Лимит трафика")
|
||||
return str(amount)
|
||||
|
||||
if key == "payment_plan_traffic_reset_period":
|
||||
if not value:
|
||||
raise ValueError("Стратегия сброса трафика не может быть пустой.")
|
||||
return value.upper()
|
||||
|
||||
if key == "referral_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "referral_discount_percent":
|
||||
amount = self._parse_non_negative_int(value, "Размер скидки")
|
||||
if amount > 100:
|
||||
raise ValueError("Размер скидки должен быть в диапазоне от 0 до 100.")
|
||||
return str(amount)
|
||||
|
||||
if key == "referral_bonus_days":
|
||||
amount = self._parse_non_negative_int(value, "Размер бонуса")
|
||||
return str(amount)
|
||||
|
||||
if key in {
|
||||
"bot_support_url",
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
}:
|
||||
return value
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
if not value:
|
||||
raise ValueError("Текст не может быть пустым.")
|
||||
return value[:4000]
|
||||
|
||||
if key == "payment_internal_squad_uuids_raw":
|
||||
normalized_parts = [part.strip() for part in value.split(",") if part.strip()]
|
||||
if not normalized_parts:
|
||||
raise ValueError("Нужно указать хотя бы один UUID внутренней группы.")
|
||||
return ",".join(normalized_parts)
|
||||
|
||||
if key == "payment_external_squad_uuid":
|
||||
return value
|
||||
|
||||
if key == "payment_username_prefix":
|
||||
if not value:
|
||||
raise ValueError("Префикс логина не может быть пустым.")
|
||||
return value[:32]
|
||||
|
||||
if key == "payment_user_tag":
|
||||
return value
|
||||
|
||||
if key == "notification_enabled":
|
||||
return "true" if self._parse_bool_token(value) else "false"
|
||||
|
||||
if key == "notification_days_before":
|
||||
parts = [p.strip() for p in value.split(",") if p.strip().isdigit() and int(p.strip()) > 0]
|
||||
if not parts:
|
||||
raise ValueError("Нужно указать хотя бы один день, например `1,3`.")
|
||||
return ",".join(parts)
|
||||
|
||||
if key == "notification_check_interval_hours":
|
||||
amount = self._parse_non_negative_int(value, "Интервал проверки")
|
||||
if amount < 1:
|
||||
raise ValueError("Интервал должен быть не менее 1 часа.")
|
||||
return str(amount)
|
||||
|
||||
raise ValueError("Настройка пока не поддерживается.")
|
||||
|
||||
@staticmethod
|
||||
def _parse_int(raw_value: object | None, *, default: int) -> int:
|
||||
try:
|
||||
return int(str(raw_value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool(raw_value: object | None, *, default: bool) -> bool:
|
||||
if raw_value is None:
|
||||
return default
|
||||
try:
|
||||
return BotConfigService._parse_bool_token(str(raw_value))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool_token(raw_value: str) -> bool:
|
||||
normalized = raw_value.strip().lower()
|
||||
if normalized in {"1", "true", "on", "yes", "y", "да", "вкл"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "off", "no", "n", "нет", "выкл"}:
|
||||
return False
|
||||
raise ValueError("Введите `on` или `off`.")
|
||||
|
||||
@staticmethod
|
||||
def _parse_non_negative_int(raw_value: str, label: str) -> int:
|
||||
if not raw_value.strip().isdigit():
|
||||
raise ValueError(f"{label} должен быть целым неотрицательным числом.")
|
||||
return int(raw_value.strip())
|
||||
|
||||
@staticmethod
|
||||
def _parse_payment_plans(raw_value: str) -> list[PaymentPlanSettings]:
|
||||
values: list[PaymentPlanSettings] = []
|
||||
for raw_part in raw_value.split(","):
|
||||
part = raw_part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
fragments = [fragment.strip() for fragment in part.split(":")]
|
||||
if len(fragments) != 2 or not fragments[0].isdigit() or not fragments[1].isdigit():
|
||||
raise ValueError(
|
||||
"Тарифы должны быть в формате `дни:цена`, например `30:250,180:600`."
|
||||
)
|
||||
|
||||
days = int(fragments[0])
|
||||
amount_rub = int(fragments[1])
|
||||
if days <= 0 or amount_rub <= 0:
|
||||
raise ValueError("У тарифов дни и цена должны быть больше нуля.")
|
||||
|
||||
values.append(
|
||||
PaymentPlanSettings(
|
||||
code=f"{days}d",
|
||||
days=days,
|
||||
amount_rub=amount_rub,
|
||||
)
|
||||
)
|
||||
|
||||
if not values:
|
||||
raise ValueError("Нужно указать хотя бы один тариф.")
|
||||
|
||||
return sorted(values, key=lambda item: item.days)
|
||||
239
app/services/notification_service.py
Normal file
239
app/services/notification_service.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SubscriptionNotification
|
||||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||||
from app.services.sync_service import SyncService
|
||||
from app.utils.formatters import format_datetime_with_days_left
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NOTIFICATION_TYPE_EXPIRE_SOON = "EXPIRE_SOON"
|
||||
|
||||
|
||||
class SubscriptionNotificationService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
sync_service: SyncService,
|
||||
config_service: StaticBotConfigService,
|
||||
project_root: Path,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._sync_service = sync_service
|
||||
self._config_service = config_service
|
||||
self._project_root = project_root
|
||||
|
||||
async def _get_config(self) -> BotConfigSnapshot:
|
||||
return await self._config_service.get_snapshot()
|
||||
|
||||
def _resolve_image_path(self, config: BotConfigSnapshot) -> Path | None:
|
||||
if not config.bot_start_image_enabled:
|
||||
return None
|
||||
|
||||
raw_path = config.bot_start_image_path.strip()
|
||||
default_path = self._project_root / "assets" / "main.png"
|
||||
|
||||
if not raw_path:
|
||||
return default_path if default_path.is_file() else None
|
||||
|
||||
image_path = Path(raw_path).expanduser()
|
||||
if not image_path.is_absolute():
|
||||
image_path = self._project_root / image_path
|
||||
|
||||
return image_path if image_path.is_file() else None
|
||||
|
||||
async def _already_notified(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
remnawave_user_id: int,
|
||||
notification_type: str,
|
||||
expire_at_snapshot: datetime,
|
||||
) -> bool:
|
||||
existing = await session.scalar(
|
||||
select(SubscriptionNotification.id).where(
|
||||
SubscriptionNotification.remnawave_user_id == remnawave_user_id,
|
||||
SubscriptionNotification.notification_type == notification_type,
|
||||
SubscriptionNotification.expire_at_snapshot == expire_at_snapshot,
|
||||
)
|
||||
)
|
||||
return existing is not None
|
||||
|
||||
async def _record_notification(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
remnawave_user_id: int,
|
||||
telegram_id: int,
|
||||
notification_type: str,
|
||||
expire_at_snapshot: datetime,
|
||||
) -> None:
|
||||
session.add(
|
||||
SubscriptionNotification(
|
||||
remnawave_user_id=remnawave_user_id,
|
||||
telegram_id=telegram_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at_snapshot,
|
||||
sent_at=utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
async def check_and_notify(self, bot: Bot) -> int:
|
||||
config = await self._get_config()
|
||||
if not config.notification_enabled:
|
||||
return 0
|
||||
|
||||
days_list = config.notification_days_list
|
||||
if not days_list:
|
||||
return 0
|
||||
|
||||
total_sent = 0
|
||||
image_path = self._resolve_image_path(config)
|
||||
|
||||
for days_before in days_list:
|
||||
expiring = await self._sync_service.get_expiring_users(
|
||||
days_before=days_before,
|
||||
tolerance_hours=max(config.notification_check_interval_hours, 1),
|
||||
)
|
||||
|
||||
for rw_user_id, telegram_id, expire_at in expiring:
|
||||
if telegram_id is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
sent = await self._send_notification(
|
||||
bot,
|
||||
rw_user_id=rw_user_id,
|
||||
telegram_id=telegram_id,
|
||||
expire_at=expire_at,
|
||||
days_before=days_before,
|
||||
brand_name=config.bot_brand_name,
|
||||
image_path=image_path,
|
||||
)
|
||||
if sent:
|
||||
total_sent += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send expiration notification to telegram_id=%s",
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
return total_sent
|
||||
|
||||
async def _send_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
*,
|
||||
rw_user_id: int,
|
||||
telegram_id: int,
|
||||
expire_at: datetime,
|
||||
days_before: int,
|
||||
brand_name: str,
|
||||
image_path: Path | None,
|
||||
) -> bool:
|
||||
notification_type = f"{NOTIFICATION_TYPE_EXPIRE_SOON}_{days_before}d"
|
||||
|
||||
async with self._session_factory() as session:
|
||||
if await self._already_notified(
|
||||
session,
|
||||
remnawave_user_id=rw_user_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at,
|
||||
):
|
||||
return False
|
||||
|
||||
text = self._build_notification_text(
|
||||
brand_name=brand_name,
|
||||
expire_at=expire_at,
|
||||
days_before=days_before,
|
||||
)
|
||||
|
||||
try:
|
||||
if image_path is not None:
|
||||
await bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=FSInputFile(str(image_path)),
|
||||
caption=text,
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=text,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Cannot send notification to telegram_id=%s (blocked or unavailable)",
|
||||
telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await self._record_notification(
|
||||
session,
|
||||
remnawave_user_id=rw_user_id,
|
||||
telegram_id=telegram_id,
|
||||
notification_type=notification_type,
|
||||
expire_at_snapshot=expire_at,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _build_notification_text(
|
||||
*,
|
||||
brand_name: str,
|
||||
expire_at: datetime,
|
||||
days_before: int,
|
||||
) -> str:
|
||||
import html as html_module
|
||||
|
||||
if days_before <= 1:
|
||||
urgency = "⚠️ Ваша подписка истекает <b>завтра</b>!"
|
||||
else:
|
||||
urgency = f"⏰ Ваша подписка истекает через <b>{days_before} дн.</b>"
|
||||
|
||||
lines = [
|
||||
f"<b>{html_module.escape(brand_name)}</b>",
|
||||
"",
|
||||
urgency,
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"Дата истечения: <b>{format_datetime_with_days_left(expire_at)}</b>\n\n"
|
||||
"Чтобы продлить доступ, откройте бот и выберите тариф."
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"<i>Нажмите /start чтобы открыть меню и продлить подписку.</i>",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
async def run_periodic(self, bot: Bot, *, default_interval_hours: int = 6) -> None:
|
||||
logger.info("Subscription notification periodic task started")
|
||||
while True:
|
||||
try:
|
||||
config = await self._get_config()
|
||||
interval_hours = max(config.notification_check_interval_hours, 1)
|
||||
except Exception:
|
||||
interval_hours = max(default_interval_hours, 1)
|
||||
|
||||
try:
|
||||
total = await self.check_and_notify(bot)
|
||||
if total > 0:
|
||||
logger.info("Sent %d subscription expiration notifications", total)
|
||||
except Exception:
|
||||
logger.exception("Error in subscription notification check")
|
||||
|
||||
await asyncio.sleep(interval_hours * 3600)
|
||||
983
app/services/payment_service.py
Normal file
983
app/services/payment_service.py
Normal file
@@ -0,0 +1,983 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import ceil
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import PaymentPlanSettings, Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
|
||||
from app.schemas.remnawave import RemnawaveUser
|
||||
from app.services.bot_config_service import BotConfigSnapshot, StaticBotConfigService
|
||||
from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
|
||||
PENDING_PAYMENT_STATUS = "PENDING"
|
||||
REVIEW_PAYMENT_STATUS = "REVIEW"
|
||||
PROCESSING_PAYMENT_STATUS = "PROCESSING"
|
||||
REJECTED_PAYMENT_STATUS = "REJECTED"
|
||||
FULFILLED_PAYMENT_STATUS = "FULFILLED"
|
||||
REFERRAL_BONUS_PENDING_STATUS = "PENDING"
|
||||
REFERRAL_BONUS_PROCESSING_STATUS = "PROCESSING"
|
||||
REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentPlan:
|
||||
code: str
|
||||
title: str
|
||||
description: str
|
||||
duration_days: int
|
||||
amount_rub: int
|
||||
discount_percent: int
|
||||
original_amount_rub: int
|
||||
applied_referral_code: str = ""
|
||||
traffic_limit_bytes: int = 0
|
||||
traffic_limit_strategy: str = "NO_RESET"
|
||||
internal_squad_uuids: list[str] | None = None
|
||||
external_squad_uuid: str | None = None
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return bool(self.internal_squad_uuids) and self.duration_days > 0 and self.amount_rub > 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreatedPaymentOrder:
|
||||
order_uuid: str
|
||||
provision_username: str
|
||||
plan: PaymentPlan
|
||||
transfer_text: str
|
||||
extends_existing_access: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StoredPaymentOrder:
|
||||
order_uuid: str
|
||||
telegram_id: int
|
||||
plan_title: str
|
||||
plan_duration_days: int
|
||||
amount_rub: int
|
||||
provision_username: str
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IssuedAccess:
|
||||
telegram_id: int
|
||||
username: str
|
||||
subscription_url: str
|
||||
expire_at: datetime
|
||||
short_uuid: str
|
||||
remnawave_user_uuid: str
|
||||
traffic_limit_bytes: int
|
||||
referral_bonus: ReferralBonusGrant | None = None
|
||||
is_renewal: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReferralBonusGrant:
|
||||
inviter_telegram_id: int
|
||||
bonus_days: int
|
||||
status: str
|
||||
total_applied_days: int = 0
|
||||
expire_at: datetime | None = None
|
||||
applied_to_user_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppliedReferralBonus:
|
||||
telegram_id: int
|
||||
total_bonus_days: int
|
||||
expire_at: datetime
|
||||
applied_to_user_uuid: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PaymentAdminStats:
|
||||
pending_orders: int = 0
|
||||
review_orders: int = 0
|
||||
pending_referral_bonuses: int = 0
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
remnawave_client: RemnawaveApiClient,
|
||||
sync_service: SyncService,
|
||||
config_service: StaticBotConfigService | None = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._session_factory = session_factory
|
||||
self._remnawave_client = remnawave_client
|
||||
self._sync_service = sync_service
|
||||
self._config_service = config_service or StaticBotConfigService(settings)
|
||||
|
||||
async def _get_config(self) -> BotConfigSnapshot:
|
||||
return await self._config_service.get_snapshot()
|
||||
|
||||
async def get_available_plans(self, *, telegram_id: int) -> list[PaymentPlan]:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
return [
|
||||
self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
for plan in config.payment_plans
|
||||
]
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
plan_code: str,
|
||||
) -> CreatedPaymentOrder:
|
||||
config = await self._get_config()
|
||||
applied_referral_code = await self._get_applied_referral_code(
|
||||
telegram_id,
|
||||
config=config,
|
||||
)
|
||||
plan = self._get_plan_by_code(
|
||||
plan_code,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
if not plan.is_ready:
|
||||
raise ValueError(
|
||||
"Покупка не настроена. Проверьте PAYMENT_PLANS и PAYMENT_INTERNAL_SQUAD_UUIDS."
|
||||
)
|
||||
|
||||
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
|
||||
order_uuid = str(uuid4())
|
||||
invoice_payload = f"manual:{plan.code}:{order_uuid}"
|
||||
provision_username = self._build_provision_username(
|
||||
config=config,
|
||||
order_uuid=order_uuid,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
user = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
session.add(
|
||||
PaymentOrder(
|
||||
order_uuid=order_uuid,
|
||||
telegram_user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
plan_code=plan.code,
|
||||
plan_title=plan.title,
|
||||
plan_duration_days=plan.duration_days,
|
||||
traffic_limit_bytes=plan.traffic_limit_bytes,
|
||||
traffic_limit_strategy=plan.traffic_limit_strategy,
|
||||
amount_stars=plan.amount_rub,
|
||||
currency="RUB",
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
invoice_payload=invoice_payload,
|
||||
provision_username=provision_username,
|
||||
error_message=applied_referral_code or None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return CreatedPaymentOrder(
|
||||
order_uuid=order_uuid,
|
||||
provision_username=provision_username,
|
||||
plan=plan,
|
||||
transfer_text=config.payment_transfer_text.strip(),
|
||||
extends_existing_access=bool(existing_accesses),
|
||||
)
|
||||
|
||||
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def mark_order_under_review(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == REVIEW_PAYMENT_STATUS:
|
||||
raise ValueError("Чек уже отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается модератором.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже подтверждён.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже отклонён. Создайте новый платёж.")
|
||||
if order.status != PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Некорректный статус заказа для отправки чека.")
|
||||
|
||||
order.status = REVIEW_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def revert_order_to_pending(self, *, order_uuid: str, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(
|
||||
PaymentOrder.order_uuid == order_uuid,
|
||||
PaymentOrder.telegram_id == telegram_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None or order.status != REVIEW_PAYMENT_STATUS:
|
||||
return
|
||||
|
||||
order.status = PENDING_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def approve_order(self, *, order_uuid: str) -> IssuedAccess:
|
||||
order = await self._claim_order_for_approval(order_uuid)
|
||||
config = await self._get_config()
|
||||
is_renewal = False
|
||||
|
||||
try:
|
||||
remote_user: RemnawaveUser | None = None
|
||||
if order.remnawave_user_uuid:
|
||||
try:
|
||||
remote_user = await self._remnawave_client.get_user_by_uuid(
|
||||
order.remnawave_user_uuid
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
remote_user = None
|
||||
|
||||
if remote_user is None:
|
||||
try:
|
||||
remote_user = await self._remnawave_client.get_user_by_username(
|
||||
order.provision_username
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
|
||||
if remote_user is None:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||
order.telegram_id
|
||||
)
|
||||
remote_user = self._select_referral_bonus_target(remote_users)
|
||||
|
||||
if remote_user is None:
|
||||
remote_user = await self._create_remnawave_user(order, config=config)
|
||||
else:
|
||||
is_renewal = True
|
||||
remote_user = await self._extend_remnawave_user(
|
||||
order,
|
||||
remote_user,
|
||||
config=config,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||||
raise
|
||||
|
||||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
||||
own_bonus = None
|
||||
try:
|
||||
own_bonus = await self.apply_pending_referral_bonuses(
|
||||
telegram_id=order.telegram_id,
|
||||
refresh_cache=False,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
logger.warning(
|
||||
"Could not apply pending referral bonus for telegram_id=%s: %s",
|
||||
order.telegram_id,
|
||||
exc.message,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._sync_service.refresh_cached_users_for_telegram(
|
||||
telegram_id=order.telegram_id,
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
# Access is already issued at this point; cache can be refreshed later.
|
||||
pass
|
||||
|
||||
expire_at = remote_user.expire_at
|
||||
if own_bonus is not None and own_bonus.applied_to_user_uuid == str(remote_user.uuid):
|
||||
expire_at = own_bonus.expire_at
|
||||
|
||||
referral_bonus = await self._grant_referral_bonus(order, config=config)
|
||||
|
||||
return IssuedAccess(
|
||||
telegram_id=order.telegram_id,
|
||||
username=remote_user.username,
|
||||
subscription_url=remote_user.subscription_url,
|
||||
expire_at=expire_at,
|
||||
short_uuid=remote_user.short_uuid,
|
||||
remnawave_user_uuid=str(remote_user.uuid),
|
||||
traffic_limit_bytes=remote_user.traffic_limit_bytes,
|
||||
referral_bonus=referral_bonus,
|
||||
is_renewal=is_renewal,
|
||||
)
|
||||
|
||||
async def reject_order(self, *, order_uuid: str, reason: str | None = None) -> StoredPaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Подтверждённый заказ нельзя отклонить.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
|
||||
order.status = REJECTED_PAYMENT_STATUS
|
||||
order.error_message = reason or "Платёж отклонён"
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
return StoredPaymentOrder(
|
||||
order_uuid=order.order_uuid,
|
||||
telegram_id=order.telegram_id,
|
||||
plan_title=order.plan_title,
|
||||
plan_duration_days=order.plan_duration_days,
|
||||
amount_rub=order.amount_stars,
|
||||
provision_username=order.provision_username,
|
||||
status=order.status,
|
||||
)
|
||||
|
||||
async def get_referral_code_for_order(self, *, order_uuid: str) -> str:
|
||||
order = await self._get_order(order_uuid)
|
||||
return order.error_message or ""
|
||||
|
||||
def build_transfer_text(self, *, order: CreatedPaymentOrder) -> str:
|
||||
lines = [
|
||||
"<b>Оплата тарифа</b>",
|
||||
"",
|
||||
f"<b>{html.escape(order.plan.title)}</b>",
|
||||
html.escape(order.plan.description),
|
||||
"",
|
||||
f"Сумма к переводу: <b>{order.plan.amount_rub} ₽</b>",
|
||||
]
|
||||
if order.plan.applied_referral_code:
|
||||
lines.append(
|
||||
f"Реферальный код: <code>{order.plan.applied_referral_code}</code> "
|
||||
f"(<b>-{order.plan.discount_percent}%</b>)"
|
||||
)
|
||||
if order.extends_existing_access:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
"<blockquote>"
|
||||
"У вас уже есть доступ. После подтверждения оплаты бот автоматически "
|
||||
"продлит текущую подписку на выбранный срок."
|
||||
"</blockquote>"
|
||||
),
|
||||
]
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Реквизиты</b>",
|
||||
(
|
||||
"<blockquote>"
|
||||
f"{html.escape(order.transfer_text or 'Укажите реквизиты в PAYMENT_TRANSFER_TEXT.')}"
|
||||
"</blockquote>"
|
||||
),
|
||||
"",
|
||||
"После перевода нажмите кнопку ниже и отправьте чек следующим сообщением.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_plan_by_code(
|
||||
self,
|
||||
plan_code: str,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
for plan in config.payment_plans:
|
||||
if plan.code == plan_code:
|
||||
return self._build_plan(
|
||||
plan,
|
||||
applied_referral_code=applied_referral_code,
|
||||
config=config,
|
||||
)
|
||||
|
||||
raise ValueError("Тариф не найден.")
|
||||
|
||||
def _build_plan(
|
||||
self,
|
||||
plan_settings: PaymentPlanSettings,
|
||||
*,
|
||||
applied_referral_code: str,
|
||||
config: BotConfigSnapshot,
|
||||
) -> PaymentPlan:
|
||||
discount_percent = max(config.referral_discount_percent, 0) if applied_referral_code else 0
|
||||
discounted_amount = self._apply_discount(
|
||||
amount_rub=plan_settings.amount_rub,
|
||||
discount_percent=discount_percent,
|
||||
)
|
||||
traffic_limit_bytes = max(config.payment_plan_traffic_limit_gb, 0) * 1024**3
|
||||
return PaymentPlan(
|
||||
code=plan_settings.code,
|
||||
title=f"{config.bot_brand_name} на {plan_settings.days} дней",
|
||||
description=f"Доступ к VPN на {plan_settings.days} дней",
|
||||
duration_days=plan_settings.days,
|
||||
amount_rub=discounted_amount,
|
||||
discount_percent=discount_percent,
|
||||
original_amount_rub=plan_settings.amount_rub,
|
||||
applied_referral_code=applied_referral_code,
|
||||
traffic_limit_bytes=traffic_limit_bytes,
|
||||
traffic_limit_strategy=config.payment_plan_traffic_reset_period.strip() or "NO_RESET",
|
||||
internal_squad_uuids=config.payment_internal_squad_uuids,
|
||||
external_squad_uuid=config.payment_external_squad_uuid_normalized,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_discount(*, amount_rub: int, discount_percent: int) -> int:
|
||||
if discount_percent <= 0:
|
||||
return amount_rub
|
||||
return max(1, ceil(amount_rub * (100 - discount_percent) / 100))
|
||||
|
||||
async def _get_applied_referral_code(
|
||||
self,
|
||||
telegram_id: int,
|
||||
*,
|
||||
config: BotConfigSnapshot | None = None,
|
||||
) -> str:
|
||||
resolved_config = config or await self._get_config()
|
||||
if not resolved_config.referral_enabled:
|
||||
return ""
|
||||
summary = await self._sync_service.get_referral_summary(telegram_id)
|
||||
return summary.applied_referral_code.strip().upper()
|
||||
|
||||
async def apply_pending_referral_bonuses(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
refresh_cache: bool = True,
|
||||
) -> AppliedReferralBonus | None:
|
||||
config = await self._get_config()
|
||||
if not config.referral_enabled:
|
||||
return None
|
||||
|
||||
claimed_bonuses = await self._claim_pending_referral_bonuses(telegram_id)
|
||||
if not claimed_bonuses:
|
||||
return None
|
||||
|
||||
total_bonus_days = sum(max(bonus.bonus_days, 0) for bonus in claimed_bonuses)
|
||||
if total_bonus_days <= 0:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
return None
|
||||
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
target_user = self._select_referral_bonus_target(remote_users)
|
||||
if target_user is None:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
return None
|
||||
|
||||
current_expire_at = target_user.expire_at
|
||||
if current_expire_at.tzinfo is None:
|
||||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||||
next_expire_at = max(current_expire_at, datetime.now(timezone.utc)) + timedelta(
|
||||
days=total_bonus_days
|
||||
)
|
||||
|
||||
try:
|
||||
updated_user = await self._remnawave_client.update_user(
|
||||
{
|
||||
"uuid": str(target_user.uuid),
|
||||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"description": (
|
||||
f"Referral bonus applied by Telegram bot "
|
||||
f"({total_bonus_days} days)"
|
||||
),
|
||||
}
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
await self._restore_claimed_referral_bonuses(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
raise
|
||||
|
||||
await self._mark_referral_bonuses_applied(
|
||||
[bonus.id for bonus in claimed_bonuses],
|
||||
applied_to_user_uuid=str(updated_user.uuid),
|
||||
)
|
||||
|
||||
if refresh_cache:
|
||||
try:
|
||||
await self._sync_service.refresh_cached_users_for_telegram(
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
pass
|
||||
|
||||
return AppliedReferralBonus(
|
||||
telegram_id=telegram_id,
|
||||
total_bonus_days=total_bonus_days,
|
||||
expire_at=updated_user.expire_at,
|
||||
applied_to_user_uuid=str(updated_user.uuid),
|
||||
)
|
||||
|
||||
async def get_admin_stats(self) -> PaymentAdminStats:
|
||||
async with self._session_factory() as session:
|
||||
pending_orders = await session.scalar(
|
||||
select(func.count(PaymentOrder.id)).where(
|
||||
PaymentOrder.status == PENDING_PAYMENT_STATUS
|
||||
)
|
||||
)
|
||||
review_orders = await session.scalar(
|
||||
select(func.count(PaymentOrder.id)).where(
|
||||
PaymentOrder.status == REVIEW_PAYMENT_STATUS
|
||||
)
|
||||
)
|
||||
pending_referral_bonuses = await session.scalar(
|
||||
select(func.count(ReferralBonus.id)).where(
|
||||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
)
|
||||
)
|
||||
|
||||
return PaymentAdminStats(
|
||||
pending_orders=int(pending_orders or 0),
|
||||
review_orders=int(review_orders or 0),
|
||||
pending_referral_bonuses=int(pending_referral_bonuses or 0),
|
||||
)
|
||||
|
||||
async def _get_order(self, order_uuid: str) -> PaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
return order
|
||||
|
||||
async def _claim_order_for_approval(self, order_uuid: str) -> PaymentOrder:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
raise ValueError("Заказ не найден.")
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
raise ValueError("Этот заказ уже подтверждён.")
|
||||
if order.status != REVIEW_PAYMENT_STATUS:
|
||||
raise ValueError("Некорректный статус заказа для подтверждения.")
|
||||
|
||||
order.status = PROCESSING_PAYMENT_STATUS
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return order
|
||||
|
||||
async def _create_remnawave_user(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
expire_at = datetime.now(timezone.utc) + timedelta(days=order.plan_duration_days)
|
||||
body: dict[str, object] = {
|
||||
"username": order.provision_username,
|
||||
"expireAt": expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"trafficLimitBytes": order.traffic_limit_bytes,
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Created by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
if config.payment_external_squad_uuid_normalized:
|
||||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||||
return await self._remnawave_client.create_user(body)
|
||||
|
||||
async def _extend_remnawave_user(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
remote_user: RemnawaveUser,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> RemnawaveUser:
|
||||
current_expire_at = remote_user.expire_at
|
||||
if current_expire_at.tzinfo is None:
|
||||
current_expire_at = current_expire_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
base_expire_at = max(current_expire_at, datetime.now(timezone.utc))
|
||||
next_expire_at = base_expire_at + timedelta(days=order.plan_duration_days)
|
||||
|
||||
traffic_limit_bytes = order.traffic_limit_bytes
|
||||
if traffic_limit_bytes <= 0:
|
||||
traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||||
|
||||
body: dict[str, object] = {
|
||||
"uuid": str(remote_user.uuid),
|
||||
"username": remote_user.username,
|
||||
"expireAt": next_expire_at.isoformat().replace("+00:00", "Z"),
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"trafficLimitStrategy": order.traffic_limit_strategy,
|
||||
"telegramId": order.telegram_id,
|
||||
"description": f"Updated by Telegram bot manual payment {order.order_uuid}",
|
||||
"activeInternalSquads": config.payment_internal_squad_uuids,
|
||||
}
|
||||
if config.payment_user_tag_normalized:
|
||||
body["tag"] = config.payment_user_tag_normalized
|
||||
if config.payment_external_squad_uuid_normalized:
|
||||
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
|
||||
return await self._remnawave_client.update_user(body)
|
||||
|
||||
async def _save_fulfilled_order(self, order_uuid: str, remote_user: RemnawaveUser) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
order.status = FULFILLED_PAYMENT_STATUS
|
||||
order.remnawave_user_uuid = str(remote_user.uuid)
|
||||
order.subscription_url = remote_user.subscription_url
|
||||
order.error_message = None
|
||||
order.paid_at = utcnow()
|
||||
order.fulfilled_at = utcnow()
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _save_order_error(self, order_uuid: str, error_message: str) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
order.error_message = error_message
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _restore_review_after_failed_approval(
|
||||
self,
|
||||
order_uuid: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder)
|
||||
.where(PaymentOrder.order_uuid == order_uuid)
|
||||
.with_for_update()
|
||||
)
|
||||
if order is None:
|
||||
return
|
||||
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
order.status = REVIEW_PAYMENT_STATUS
|
||||
order.error_message = error_message
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _grant_referral_bonus(
|
||||
self,
|
||||
order: PaymentOrder,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
) -> ReferralBonusGrant | None:
|
||||
if not config.referral_enabled:
|
||||
return None
|
||||
|
||||
bonus_days = max(config.referral_bonus_days, 0)
|
||||
if bonus_days <= 0:
|
||||
return None
|
||||
|
||||
inviter_telegram_id = await self._get_inviter_telegram_id(order.telegram_id)
|
||||
if inviter_telegram_id is None or inviter_telegram_id == order.telegram_id:
|
||||
return None
|
||||
|
||||
await self._create_referral_bonus(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=order.telegram_id,
|
||||
source_order_uuid=order.order_uuid,
|
||||
bonus_days=bonus_days,
|
||||
)
|
||||
|
||||
try:
|
||||
applied_bonus = await self.apply_pending_referral_bonuses(
|
||||
telegram_id=inviter_telegram_id,
|
||||
)
|
||||
except RemnawaveApiError as exc:
|
||||
logger.warning(
|
||||
"Could not apply referral bonus for inviter=%s after order=%s: %s",
|
||||
inviter_telegram_id,
|
||||
order.order_uuid,
|
||||
exc.message,
|
||||
)
|
||||
applied_bonus = None
|
||||
|
||||
if applied_bonus is None:
|
||||
return ReferralBonusGrant(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
|
||||
return ReferralBonusGrant(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_APPLIED_STATUS,
|
||||
total_applied_days=applied_bonus.total_bonus_days,
|
||||
expire_at=applied_bonus.expire_at,
|
||||
applied_to_user_uuid=applied_bonus.applied_to_user_uuid,
|
||||
)
|
||||
|
||||
async def _get_inviter_telegram_id(self, invited_telegram_id: int) -> int | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.scalar(
|
||||
select(ReferralInvite.inviter_telegram_id).where(
|
||||
ReferralInvite.invited_telegram_id == invited_telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
async def _create_referral_bonus(
|
||||
self,
|
||||
*,
|
||||
inviter_telegram_id: int,
|
||||
invited_telegram_id: int,
|
||||
source_order_uuid: str,
|
||||
bonus_days: int,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
existing = await session.scalar(
|
||||
select(ReferralBonus).where(
|
||||
ReferralBonus.source_order_uuid == source_order_uuid
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
session.add(
|
||||
ReferralBonus(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=invited_telegram_id,
|
||||
source_order_uuid=source_order_uuid,
|
||||
bonus_days=bonus_days,
|
||||
status=REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _claim_pending_referral_bonuses(self, telegram_id: int) -> list[ReferralBonus]:
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(
|
||||
ReferralBonus.inviter_telegram_id == telegram_id,
|
||||
ReferralBonus.status == REFERRAL_BONUS_PENDING_STATUS,
|
||||
)
|
||||
.order_by(ReferralBonus.created_at.asc(), ReferralBonus.id.asc())
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not bonuses:
|
||||
return []
|
||||
|
||||
for bonus in bonuses:
|
||||
bonus.status = REFERRAL_BONUS_PROCESSING_STATUS
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return bonuses
|
||||
|
||||
async def _restore_claimed_referral_bonuses(
|
||||
self,
|
||||
bonus_ids: list[int],
|
||||
*,
|
||||
status: str,
|
||||
) -> None:
|
||||
if not bonus_ids:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(ReferralBonus.id.in_(bonus_ids))
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for bonus in bonuses:
|
||||
bonus.status = status
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def _mark_referral_bonuses_applied(
|
||||
self,
|
||||
bonus_ids: list[int],
|
||||
*,
|
||||
applied_to_user_uuid: str,
|
||||
) -> None:
|
||||
if not bonus_ids:
|
||||
return
|
||||
|
||||
async with self._session_factory() as session:
|
||||
bonuses = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ReferralBonus)
|
||||
.where(ReferralBonus.id.in_(bonus_ids))
|
||||
.with_for_update()
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for bonus in bonuses:
|
||||
bonus.status = REFERRAL_BONUS_APPLIED_STATUS
|
||||
bonus.applied_to_user_uuid = applied_to_user_uuid
|
||||
bonus.applied_at = utcnow()
|
||||
bonus.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _select_referral_bonus_target(
|
||||
remote_users: list[RemnawaveUser],
|
||||
) -> RemnawaveUser | None:
|
||||
if not remote_users:
|
||||
return None
|
||||
|
||||
def _sort_key(user: RemnawaveUser) -> tuple[int, datetime, str]:
|
||||
expire_at = user.expire_at
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
return (0 if user.status.upper() == "ACTIVE" else 1, expire_at, user.username)
|
||||
|
||||
return sorted(remote_users, key=_sort_key)[0]
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> TelegramUser:
|
||||
record = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = TelegramUser(telegram_id=telegram_id)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.language_code = language_code
|
||||
record.is_admin = self._settings.is_admin(telegram_id)
|
||||
record.last_seen_at = utcnow()
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
def _build_provision_username(
|
||||
self,
|
||||
*,
|
||||
config: BotConfigSnapshot,
|
||||
order_uuid: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
) -> str:
|
||||
base_name = username or first_name or "user"
|
||||
normalized_name = "".join(
|
||||
ch for ch in base_name if ch.isalnum() or ch in "_-"
|
||||
).strip("-_")
|
||||
if not normalized_name:
|
||||
normalized_name = "user"
|
||||
|
||||
prefix = config.payment_username_prefix_normalized or "Oreol"
|
||||
unique_suffix = order_uuid.replace("-", "")[:6]
|
||||
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
|
||||
return provision_username[:36]
|
||||
224
app/services/remnawave_client.py
Normal file
224
app/services/remnawave_client.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
|
||||
|
||||
|
||||
UUID_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{8}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{4}-"
|
||||
r"[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
|
||||
class RemnawaveApiError(RuntimeError):
|
||||
def __init__(self, *, status_code: int, message: str, payload: Any | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.payload = payload
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class RemnawaveApiClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
caddy_api_key: str = "",
|
||||
timeout_seconds: float = 20.0,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._managed_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
base_url=self.normalize_base_url(base_url),
|
||||
timeout=timeout_seconds,
|
||||
headers=self._build_headers(
|
||||
base_url=base_url,
|
||||
api_token=api_token,
|
||||
caddy_api_key=caddy_api_key,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_base_url(base_url: str) -> str:
|
||||
normalized = base_url.strip().rstrip("/")
|
||||
if not normalized.endswith("/api"):
|
||||
normalized = f"{normalized}/api"
|
||||
return f"{normalized}/"
|
||||
|
||||
@staticmethod
|
||||
def unwrap_payload(payload: Any) -> Any:
|
||||
if isinstance(payload, Mapping) and "response" in payload:
|
||||
return payload["response"]
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _build_headers(
|
||||
*,
|
||||
base_url: str,
|
||||
api_token: str,
|
||||
caddy_api_key: str,
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if api_token:
|
||||
headers["Authorization"] = (
|
||||
api_token if api_token.startswith("Bearer ") else f"Bearer {api_token}"
|
||||
)
|
||||
if caddy_api_key:
|
||||
headers["X-Api-Key"] = caddy_api_key
|
||||
if base_url.startswith("http://"):
|
||||
headers["x-forwarded-proto"] = "https"
|
||||
headers["x-forwarded-for"] = "127.0.0.1"
|
||||
return headers
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._managed_client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int) -> list[RemnawaveUser]:
|
||||
payload = await self._request_json("GET", f"users/by-telegram-id/{telegram_id}")
|
||||
return [RemnawaveUser.model_validate(item) for item in payload]
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/{user_uuid}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_user_by_short_uuid(self, short_uuid: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/by-short-uuid/{short_uuid}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_user_by_username(self, username: str) -> RemnawaveUser:
|
||||
payload = await self._request_json("GET", f"users/by-username/{username}")
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def create_user(self, body: dict[str, Any]) -> RemnawaveUser:
|
||||
payload = await self._request_json("POST", "users", json=body)
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def update_user(self, body: dict[str, Any]) -> RemnawaveUser:
|
||||
payload = await self._request_json("PATCH", "users", json=body)
|
||||
return RemnawaveUser.model_validate(payload)
|
||||
|
||||
async def get_all_users(self, *, start: int = 0, size: int = 100) -> PaginatedUsers:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
"users",
|
||||
params={"start": start, "size": size},
|
||||
)
|
||||
return PaginatedUsers.model_validate(payload)
|
||||
|
||||
async def get_user_subscription_history(self, user_uuid: str) -> SubscriptionRequestHistory:
|
||||
payload = await self._request_json(
|
||||
"GET",
|
||||
f"users/{user_uuid}/subscription-request-history",
|
||||
)
|
||||
return SubscriptionRequestHistory.model_validate(payload)
|
||||
|
||||
async def resolve_user(self, identifier: str) -> ResolvedUser:
|
||||
body: dict[str, Any]
|
||||
|
||||
if UUID_RE.match(identifier):
|
||||
body = {"uuid": identifier}
|
||||
elif identifier.isdigit():
|
||||
body = {"id": int(identifier)}
|
||||
else:
|
||||
try:
|
||||
payload = await self._request_json(
|
||||
"POST",
|
||||
"users/resolve",
|
||||
json={"shortUuid": identifier},
|
||||
)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
body = {"username": identifier}
|
||||
|
||||
payload = await self._request_json("POST", "users/resolve", json=body)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
response = await self._client.request(
|
||||
method=method,
|
||||
url=path.lstrip("/"),
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
|
||||
if response.is_error:
|
||||
raise self._build_error(response)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
return self.unwrap_payload(payload)
|
||||
|
||||
def _build_error(self, response: httpx.Response) -> RemnawaveApiError:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
|
||||
message = "Unknown Remnawave API error"
|
||||
if isinstance(payload, Mapping):
|
||||
for key in ("message", "error", "code"):
|
||||
candidate = payload.get(key)
|
||||
if candidate:
|
||||
message = str(candidate)
|
||||
break
|
||||
|
||||
error_code = payload.get("errorCode")
|
||||
if error_code:
|
||||
message = f"{message} [{error_code}]"
|
||||
|
||||
error_items = payload.get("errors")
|
||||
if isinstance(error_items, list):
|
||||
details: list[str] = []
|
||||
for item in error_items:
|
||||
if not isinstance(item, Mapping):
|
||||
continue
|
||||
|
||||
path_value = item.get("path")
|
||||
if isinstance(path_value, list):
|
||||
path_text = ".".join(str(part) for part in path_value if part is not None)
|
||||
elif path_value is not None:
|
||||
path_text = str(path_value)
|
||||
else:
|
||||
path_text = ""
|
||||
|
||||
detail_message = str(item.get("message") or item.get("code") or "").strip()
|
||||
if not detail_message:
|
||||
continue
|
||||
|
||||
if path_text:
|
||||
details.append(f"{path_text}: {detail_message}")
|
||||
else:
|
||||
details.append(detail_message)
|
||||
|
||||
if details:
|
||||
message = f"{message} | {'; '.join(details)}"
|
||||
elif response.text:
|
||||
message = response.text
|
||||
|
||||
return RemnawaveApiError(
|
||||
status_code=response.status_code,
|
||||
message=message,
|
||||
payload=payload,
|
||||
)
|
||||
72
app/services/support_ticket_service.py
Normal file
72
app/services/support_ticket_service.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import SupportTicket
|
||||
|
||||
|
||||
class SupportTicketService:
|
||||
def __init__(self, *, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
async def create_ticket(
|
||||
self,
|
||||
*,
|
||||
public_id: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
display_name: str,
|
||||
user_message: str,
|
||||
support_chat_id: int,
|
||||
support_thread_id: int | None,
|
||||
support_message_id: int,
|
||||
) -> SupportTicket:
|
||||
async with self._session_factory() as session:
|
||||
ticket = SupportTicket(
|
||||
public_id=public_id,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
user_message=user_message,
|
||||
support_chat_id=support_chat_id,
|
||||
support_thread_id=support_thread_id,
|
||||
support_message_id=support_message_id,
|
||||
status="OPEN",
|
||||
)
|
||||
session.add(ticket)
|
||||
await session.commit()
|
||||
await session.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
async def get_ticket_by_support_message(
|
||||
self,
|
||||
*,
|
||||
support_chat_id: int,
|
||||
support_message_id: int,
|
||||
) -> SupportTicket | None:
|
||||
async with self._session_factory() as session:
|
||||
return await session.scalar(
|
||||
select(SupportTicket).where(
|
||||
SupportTicket.support_chat_id == support_chat_id,
|
||||
SupportTicket.support_message_id == support_message_id,
|
||||
)
|
||||
)
|
||||
|
||||
async def mark_answered(self, *, ticket_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
ticket = await session.get(SupportTicket, ticket_id)
|
||||
if ticket is None:
|
||||
return
|
||||
|
||||
ticket.status = "ANSWERED"
|
||||
ticket.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def get_open_tickets_count(self) -> int:
|
||||
async with self._session_factory() as session:
|
||||
total = await session.scalar(
|
||||
select(func.count(SupportTicket.id)).where(SupportTicket.status == "OPEN")
|
||||
)
|
||||
return int(total or 0)
|
||||
811
app/services/sync_service.py
Normal file
811
app/services/sync_service.py
Normal file
@@ -0,0 +1,811 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import (
|
||||
ReferralCode,
|
||||
ReferralInvite,
|
||||
InternalSquad,
|
||||
RemnawaveUser,
|
||||
RemnawaveUserInternalSquad,
|
||||
SubscriptionRequestLog,
|
||||
TelegramUser,
|
||||
)
|
||||
from app.schemas.remnawave import RemnawaveUser as RemoteRemnawaveUser
|
||||
from app.schemas.remnawave import SubscriptionRequestHistory
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CachedUserView:
|
||||
record: RemnawaveUser
|
||||
internal_squads: list[str] = field(default_factory=list)
|
||||
recent_requests: list[SubscriptionRequestLog] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ReferralSummary:
|
||||
total_invited: int = 0
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
recent_names: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminStats:
|
||||
total_telegram_users: int = 0
|
||||
total_cached_users: int = 0
|
||||
active_cached_users: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUserListItem:
|
||||
telegram_id: int
|
||||
username: str | None
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
language_code: str | None
|
||||
is_admin: bool
|
||||
is_blocked: bool
|
||||
last_seen_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
value = " ".join(part for part in [self.first_name, self.last_name] if part).strip()
|
||||
return value or self.username or str(self.telegram_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUsersPage:
|
||||
items: list[AdminTelegramUserListItem] = field(default_factory=list)
|
||||
page: int = 1
|
||||
page_size: int = 8
|
||||
total_items: int = 0
|
||||
total_pages: int = 1
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AdminTelegramUserProfile:
|
||||
user: TelegramUser
|
||||
referral_code: str = ""
|
||||
applied_referral_code: str = ""
|
||||
invited_count: int = 0
|
||||
recent_invited_names: list[str] = field(default_factory=list)
|
||||
accesses: list[CachedUserView] = field(default_factory=list)
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
remnawave_client: RemnawaveApiClient,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._session_factory = session_factory
|
||||
self._remnawave_client = remnawave_client
|
||||
|
||||
async def is_user_blocked(self, telegram_id: int) -> bool:
|
||||
async with self._session_factory() as session:
|
||||
is_blocked = await session.scalar(
|
||||
select(TelegramUser.is_blocked).where(
|
||||
TelegramUser.telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
return bool(is_blocked)
|
||||
|
||||
async def block_telegram_user(self, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is not None:
|
||||
user.is_blocked = True
|
||||
user.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def unblock_telegram_user(self, telegram_id: int) -> None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is not None:
|
||||
user.is_blocked = False
|
||||
user.updated_at = utcnow()
|
||||
await session.commit()
|
||||
|
||||
async def get_expiring_users(
|
||||
self,
|
||||
*,
|
||||
days_before: int,
|
||||
tolerance_hours: int = 6,
|
||||
) -> list[tuple[int, int, datetime]]:
|
||||
"""Return (remnawave_user.id, telegram_id, expire_at) for users expiring within window."""
|
||||
from datetime import timedelta
|
||||
|
||||
now = utcnow()
|
||||
window_start = now + timedelta(days=days_before) - timedelta(hours=tolerance_hours)
|
||||
window_end = now + timedelta(days=days_before) + timedelta(hours=tolerance_hours)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(
|
||||
RemnawaveUser.id,
|
||||
RemnawaveUser.telegram_id,
|
||||
RemnawaveUser.expire_at,
|
||||
)
|
||||
.where(
|
||||
RemnawaveUser.status == "ACTIVE",
|
||||
RemnawaveUser.telegram_id.isnot(None),
|
||||
RemnawaveUser.expire_at >= window_start,
|
||||
RemnawaveUser.expire_at <= window_end,
|
||||
)
|
||||
)
|
||||
return [(row[0], row[1], row[2]) for row in rows.all()]
|
||||
|
||||
async def register_telegram_user(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> None:
|
||||
async with self._session_factory() as session:
|
||||
await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
await session.commit()
|
||||
|
||||
async def apply_referral_code(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
referral_code: str,
|
||||
) -> str:
|
||||
async with self._session_factory() as session:
|
||||
invited = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._get_or_create_referral_code(session, telegram_id=telegram_id)
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError("Реферальный код уже применен к вашему аккаунту.")
|
||||
|
||||
inviter_telegram_id = await self._find_inviter_by_referral_code(session, referral_code)
|
||||
if inviter_telegram_id is None:
|
||||
raise ValueError("Реферальный код не найден.")
|
||||
if inviter_telegram_id == telegram_id:
|
||||
raise ValueError("Нельзя использовать собственный реферальный код.")
|
||||
|
||||
invite_created = await self._upsert_referral_invite(
|
||||
session,
|
||||
invited=invited,
|
||||
referred_by_telegram_id=inviter_telegram_id,
|
||||
)
|
||||
if not invite_created:
|
||||
raise ValueError("Не удалось применить реферальный код.")
|
||||
|
||||
await session.commit()
|
||||
return referral_code.strip().upper()
|
||||
|
||||
async def get_referral_summary(self, telegram_id: int) -> ReferralSummary:
|
||||
async with self._session_factory() as session:
|
||||
referral_code = await self._get_or_create_referral_code(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
await session.commit()
|
||||
total_invited = await session.scalar(
|
||||
select(func.count(ReferralInvite.id)).where(
|
||||
ReferralInvite.inviter_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
recent_rows = await session.execute(
|
||||
select(ReferralInvite.invited_display_name)
|
||||
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
||||
.order_by(ReferralInvite.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_names = list(recent_rows.scalars().all())
|
||||
applied_referral_code = await session.scalar(
|
||||
select(ReferralCode.code)
|
||||
.join(
|
||||
ReferralInvite,
|
||||
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
return ReferralSummary(
|
||||
total_invited=int(total_invited or 0),
|
||||
referral_code=referral_code,
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
recent_names=recent_names,
|
||||
)
|
||||
|
||||
async def get_admin_stats(self) -> AdminStats:
|
||||
async with self._session_factory() as session:
|
||||
total_telegram_users = await session.scalar(
|
||||
select(func.count(TelegramUser.id))
|
||||
)
|
||||
total_cached_users = await session.scalar(
|
||||
select(func.count(RemnawaveUser.id))
|
||||
)
|
||||
active_cached_users = await session.scalar(
|
||||
select(func.count(RemnawaveUser.id)).where(RemnawaveUser.status == "ACTIVE")
|
||||
)
|
||||
|
||||
return AdminStats(
|
||||
total_telegram_users=int(total_telegram_users or 0),
|
||||
total_cached_users=int(total_cached_users or 0),
|
||||
active_cached_users=int(active_cached_users or 0),
|
||||
)
|
||||
|
||||
async def get_admin_telegram_users_page(
|
||||
self,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int = 8,
|
||||
) -> AdminTelegramUsersPage:
|
||||
resolved_page_size = max(1, min(page_size, 20))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
total_items = int(
|
||||
await session.scalar(select(func.count(TelegramUser.id))) or 0
|
||||
)
|
||||
total_pages = max(1, math.ceil(total_items / resolved_page_size)) if total_items else 1
|
||||
resolved_page = min(max(page, 1), total_pages)
|
||||
|
||||
rows = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(TelegramUser)
|
||||
.order_by(
|
||||
TelegramUser.last_seen_at.desc(),
|
||||
TelegramUser.created_at.desc(),
|
||||
TelegramUser.id.desc(),
|
||||
)
|
||||
.offset((resolved_page - 1) * resolved_page_size)
|
||||
.limit(resolved_page_size)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
return AdminTelegramUsersPage(
|
||||
items=[
|
||||
AdminTelegramUserListItem(
|
||||
telegram_id=row.telegram_id,
|
||||
username=row.username,
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
language_code=row.language_code,
|
||||
is_admin=row.is_admin,
|
||||
is_blocked=row.is_blocked,
|
||||
last_seen_at=row.last_seen_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
page=resolved_page,
|
||||
page_size=resolved_page_size,
|
||||
total_items=total_items,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
async def get_admin_telegram_user_profile(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> AdminTelegramUserProfile | None:
|
||||
async with self._session_factory() as session:
|
||||
user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
referral_code = await session.scalar(
|
||||
select(ReferralCode.code).where(ReferralCode.telegram_id == telegram_id)
|
||||
)
|
||||
invited_count = await session.scalar(
|
||||
select(func.count(ReferralInvite.id)).where(
|
||||
ReferralInvite.inviter_telegram_id == telegram_id
|
||||
)
|
||||
)
|
||||
recent_rows = await session.execute(
|
||||
select(ReferralInvite.invited_display_name)
|
||||
.where(ReferralInvite.inviter_telegram_id == telegram_id)
|
||||
.order_by(ReferralInvite.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_invited_names = list(recent_rows.scalars().all())
|
||||
applied_referral_code = await session.scalar(
|
||||
select(ReferralCode.code)
|
||||
.join(
|
||||
ReferralInvite,
|
||||
ReferralInvite.inviter_telegram_id == ReferralCode.telegram_id,
|
||||
)
|
||||
.where(ReferralInvite.invited_telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
accesses = await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
return AdminTelegramUserProfile(
|
||||
user=user,
|
||||
referral_code=referral_code or "",
|
||||
applied_referral_code=applied_referral_code or "",
|
||||
invited_count=int(invited_count or 0),
|
||||
recent_invited_names=recent_invited_names,
|
||||
accesses=accesses,
|
||||
)
|
||||
|
||||
async def sync_users_for_telegram(
|
||||
self,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> list[CachedUserView]:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._sync_remote_users_for_telegram(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
remote_users=remote_users,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
async def refresh_cached_users_for_telegram(self, *, telegram_id: int) -> list[CachedUserView]:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(telegram_id)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
await self._sync_remote_users_for_telegram(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
remote_users=remote_users,
|
||||
owner=owner,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return await self.get_cached_users_for_telegram(telegram_id)
|
||||
|
||||
async def link_user_by_short_uuid(
|
||||
self,
|
||||
*,
|
||||
short_uuid: str,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> CachedUserView:
|
||||
remote_user = await self._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||||
|
||||
if remote_user.telegram_id and remote_user.telegram_id != telegram_id:
|
||||
raise ValueError("Этот аккаунт уже привязан к другому Telegram ID.")
|
||||
|
||||
updated_user = await self._remnawave_client.update_user(
|
||||
{
|
||||
"uuid": str(remote_user.uuid),
|
||||
"telegramId": telegram_id,
|
||||
}
|
||||
)
|
||||
|
||||
async with self._session_factory() as session:
|
||||
owner = await self._upsert_telegram_user(
|
||||
session,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language_code=language_code,
|
||||
)
|
||||
await self._upsert_remnawave_user(session, updated_user, owner)
|
||||
if self._settings.sync_subscription_history:
|
||||
history = await self._remnawave_client.get_user_subscription_history(
|
||||
str(updated_user.uuid)
|
||||
)
|
||||
await self._upsert_history(session, str(updated_user.uuid), history)
|
||||
await session.commit()
|
||||
|
||||
user = await self.get_cached_user_by_uuid(str(updated_user.uuid), include_history=True)
|
||||
if user is None:
|
||||
raise ValueError("Не удалось получить локальный кэш пользователя после привязки.")
|
||||
return user
|
||||
|
||||
async def sync_user_by_identifier(
|
||||
self,
|
||||
*,
|
||||
identifier: str,
|
||||
include_history: bool = True,
|
||||
) -> CachedUserView:
|
||||
resolved = await self._remnawave_client.resolve_user(identifier)
|
||||
remote_user = await self._remnawave_client.get_user_by_uuid(str(resolved.uuid))
|
||||
history = None
|
||||
|
||||
if include_history and self._settings.sync_subscription_history:
|
||||
history = await self._remnawave_client.get_user_subscription_history(str(resolved.uuid))
|
||||
|
||||
async with self._session_factory() as session:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
||||
if history is not None:
|
||||
await self._upsert_history(session, str(resolved.uuid), history)
|
||||
await session.commit()
|
||||
|
||||
user = await self.get_cached_user_by_uuid(str(resolved.uuid), include_history=include_history)
|
||||
if user is None:
|
||||
raise ValueError("Не удалось получить локальный кэш пользователя после синхронизации.")
|
||||
return user
|
||||
|
||||
async def sync_all_users(self, batch_size: int) -> int:
|
||||
start = 0
|
||||
total_synced = 0
|
||||
|
||||
while True:
|
||||
page = await self._remnawave_client.get_all_users(start=start, size=batch_size)
|
||||
if not page.users:
|
||||
break
|
||||
|
||||
async with self._session_factory() as session:
|
||||
for remote_user in page.users:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner=None)
|
||||
total_synced += 1
|
||||
await session.commit()
|
||||
|
||||
start += len(page.users)
|
||||
if start >= page.total:
|
||||
break
|
||||
|
||||
return total_synced
|
||||
|
||||
async def get_cached_users_for_telegram(self, telegram_id: int) -> list[CachedUserView]:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(RemnawaveUser, InternalSquad.name)
|
||||
.outerjoin(
|
||||
RemnawaveUserInternalSquad,
|
||||
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
||||
)
|
||||
.outerjoin(
|
||||
InternalSquad,
|
||||
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
||||
)
|
||||
.where(RemnawaveUser.telegram_id == telegram_id)
|
||||
.order_by(RemnawaveUser.username.asc())
|
||||
)
|
||||
return self._build_views(rows.all())
|
||||
|
||||
async def get_cached_user_by_uuid(
|
||||
self,
|
||||
user_uuid: str,
|
||||
*,
|
||||
include_history: bool = False,
|
||||
) -> CachedUserView | None:
|
||||
async with self._session_factory() as session:
|
||||
rows = await session.execute(
|
||||
select(RemnawaveUser, InternalSquad.name)
|
||||
.outerjoin(
|
||||
RemnawaveUserInternalSquad,
|
||||
RemnawaveUserInternalSquad.user_id == RemnawaveUser.id,
|
||||
)
|
||||
.outerjoin(
|
||||
InternalSquad,
|
||||
InternalSquad.uuid == RemnawaveUserInternalSquad.squad_uuid,
|
||||
)
|
||||
.where(RemnawaveUser.rw_uuid == user_uuid)
|
||||
)
|
||||
views = self._build_views(rows.all())
|
||||
if not views:
|
||||
return None
|
||||
|
||||
view = views[0]
|
||||
if include_history:
|
||||
history_rows = await session.execute(
|
||||
select(SubscriptionRequestLog)
|
||||
.where(SubscriptionRequestLog.user_uuid == user_uuid)
|
||||
.order_by(SubscriptionRequestLog.request_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
view.recent_requests = list(history_rows.scalars().all())
|
||||
return view
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
language_code: str | None,
|
||||
) -> TelegramUser:
|
||||
record = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = TelegramUser(telegram_id=telegram_id)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.language_code = language_code
|
||||
record.is_admin = self._settings.is_admin(telegram_id)
|
||||
record.last_seen_at = utcnow()
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
async def _sync_remote_users_for_telegram(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
remote_users: list[RemoteRemnawaveUser],
|
||||
owner: TelegramUser | None,
|
||||
) -> None:
|
||||
active_uuids = {str(remote_user.uuid) for remote_user in remote_users}
|
||||
for remote_user in remote_users:
|
||||
await self._upsert_remnawave_user(session, remote_user, owner)
|
||||
|
||||
filters = [RemnawaveUser.telegram_id == telegram_id]
|
||||
if owner is not None:
|
||||
filters.append(RemnawaveUser.owner_telegram_user_id == owner.id)
|
||||
|
||||
stale_records = await session.scalars(
|
||||
select(RemnawaveUser).where(or_(*filters))
|
||||
)
|
||||
for record in stale_records:
|
||||
if record.rw_uuid in active_uuids:
|
||||
continue
|
||||
|
||||
if record.telegram_id == telegram_id:
|
||||
record.telegram_id = None
|
||||
if owner is not None and record.owner_telegram_user_id == owner.id:
|
||||
record.owner_telegram_user_id = None
|
||||
record.synced_at = utcnow()
|
||||
|
||||
async def _upsert_remnawave_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
remote_user: RemoteRemnawaveUser,
|
||||
owner: TelegramUser | None,
|
||||
) -> RemnawaveUser:
|
||||
record = await session.scalar(
|
||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
|
||||
)
|
||||
|
||||
if record is None:
|
||||
record = RemnawaveUser(rw_uuid=str(remote_user.uuid))
|
||||
session.add(record)
|
||||
|
||||
owner_id = owner.id if owner is not None else await self._find_owner_id(session, remote_user.telegram_id)
|
||||
|
||||
record.owner_telegram_user_id = owner_id
|
||||
record.rw_id = remote_user.id
|
||||
record.short_uuid = remote_user.short_uuid
|
||||
record.username = remote_user.username
|
||||
record.status = remote_user.status
|
||||
record.traffic_limit_bytes = remote_user.traffic_limit_bytes
|
||||
record.used_traffic_bytes = remote_user.user_traffic.used_traffic_bytes
|
||||
record.lifetime_used_traffic_bytes = remote_user.user_traffic.lifetime_used_traffic_bytes
|
||||
record.expire_at = self._normalize_datetime(remote_user.expire_at)
|
||||
record.telegram_id = remote_user.telegram_id
|
||||
record.email = remote_user.email
|
||||
record.description = remote_user.description
|
||||
record.tag = remote_user.tag
|
||||
record.hwid_device_limit = remote_user.hwid_device_limit
|
||||
record.external_squad_uuid = self._maybe_str(remote_user.external_squad_uuid)
|
||||
record.trojan_password = remote_user.trojan_password
|
||||
record.vless_uuid = str(remote_user.vless_uuid)
|
||||
record.ss_password = remote_user.ss_password
|
||||
record.last_triggered_threshold = remote_user.last_triggered_threshold
|
||||
record.sub_revoked_at = self._normalize_datetime(remote_user.sub_revoked_at)
|
||||
record.sub_last_user_agent = remote_user.sub_last_user_agent
|
||||
record.sub_last_opened_at = self._normalize_datetime(remote_user.sub_last_opened_at)
|
||||
record.last_traffic_reset_at = self._normalize_datetime(remote_user.last_traffic_reset_at)
|
||||
record.subscription_url = remote_user.subscription_url
|
||||
record.online_at = self._normalize_datetime(remote_user.user_traffic.online_at)
|
||||
record.first_connected_at = self._normalize_datetime(remote_user.user_traffic.first_connected_at)
|
||||
record.last_connected_node_uuid = self._maybe_str(remote_user.user_traffic.last_connected_node_uuid)
|
||||
record.remote_created_at = self._normalize_datetime(remote_user.created_at)
|
||||
record.remote_updated_at = self._normalize_datetime(remote_user.updated_at)
|
||||
record.synced_at = utcnow()
|
||||
|
||||
await session.flush()
|
||||
await session.execute(
|
||||
delete(RemnawaveUserInternalSquad).where(
|
||||
RemnawaveUserInternalSquad.user_id == record.id
|
||||
)
|
||||
)
|
||||
|
||||
for squad in remote_user.active_internal_squads:
|
||||
squad_uuid = str(squad.uuid)
|
||||
squad_record = await session.get(InternalSquad, squad_uuid)
|
||||
if squad_record is None:
|
||||
squad_record = InternalSquad(uuid=squad_uuid, name=squad.name, synced_at=utcnow())
|
||||
session.add(squad_record)
|
||||
else:
|
||||
squad_record.name = squad.name
|
||||
squad_record.synced_at = utcnow()
|
||||
|
||||
session.add(
|
||||
RemnawaveUserInternalSquad(
|
||||
user_id=record.id,
|
||||
squad_uuid=squad_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
await session.flush()
|
||||
return record
|
||||
|
||||
async def _upsert_history(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_uuid: str,
|
||||
history: SubscriptionRequestHistory,
|
||||
) -> None:
|
||||
for item in history.records:
|
||||
existing = await session.scalar(
|
||||
select(SubscriptionRequestLog).where(
|
||||
SubscriptionRequestLog.remote_id == item.id
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
existing = SubscriptionRequestLog(remote_id=item.id, user_uuid=user_uuid)
|
||||
session.add(existing)
|
||||
|
||||
existing.user_uuid = user_uuid
|
||||
existing.request_at = self._normalize_datetime(item.request_at)
|
||||
existing.request_ip = item.request_ip
|
||||
existing.user_agent = item.user_agent
|
||||
existing.synced_at = utcnow()
|
||||
|
||||
async def _upsert_referral_invite(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
invited: TelegramUser,
|
||||
referred_by_telegram_id: int | None,
|
||||
) -> bool:
|
||||
if referred_by_telegram_id is None or referred_by_telegram_id == invited.telegram_id:
|
||||
return False
|
||||
|
||||
existing = await session.scalar(
|
||||
select(ReferralInvite).where(
|
||||
ReferralInvite.invited_telegram_id == invited.telegram_id
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return False
|
||||
|
||||
display_name = " ".join(
|
||||
part for part in [invited.first_name, invited.last_name] if part
|
||||
).strip() or invited.username or str(invited.telegram_id)
|
||||
|
||||
session.add(
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=referred_by_telegram_id,
|
||||
invited_telegram_id=invited.telegram_id,
|
||||
invited_username=invited.username,
|
||||
invited_display_name=display_name,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def _get_or_create_referral_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
) -> str:
|
||||
existing = await session.scalar(
|
||||
select(ReferralCode).where(ReferralCode.telegram_id == telegram_id)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.code
|
||||
|
||||
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
while True:
|
||||
code = "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
duplicate = await session.scalar(
|
||||
select(ReferralCode.id).where(ReferralCode.code == code)
|
||||
)
|
||||
if duplicate is None:
|
||||
session.add(ReferralCode(telegram_id=telegram_id, code=code))
|
||||
await session.flush()
|
||||
return code
|
||||
|
||||
async def _find_inviter_by_referral_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
referred_by_referral_code: str | None,
|
||||
) -> int | None:
|
||||
raw_code = (referred_by_referral_code or "").strip().upper()
|
||||
if not raw_code:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(ReferralCode.telegram_id).where(ReferralCode.code == raw_code)
|
||||
)
|
||||
|
||||
async def _find_owner_id(self, session: AsyncSession, telegram_id: int | None) -> int | None:
|
||||
if telegram_id is None:
|
||||
return None
|
||||
|
||||
return await session.scalar(
|
||||
select(TelegramUser.id).where(TelegramUser.telegram_id == telegram_id)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@staticmethod
|
||||
def _maybe_str(value: object | None) -> str | None:
|
||||
return str(value) if value is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _build_views(rows: list[tuple[RemnawaveUser, str | None]]) -> list[CachedUserView]:
|
||||
view_map: dict[int, CachedUserView] = {}
|
||||
for user, squad_name in rows:
|
||||
view = view_map.setdefault(user.id, CachedUserView(record=user))
|
||||
if squad_name and squad_name not in view.internal_squads:
|
||||
view.internal_squads.append(squad_name)
|
||||
return list(view_map.values())
|
||||
Reference in New Issue
Block a user