add
This commit is contained in:
@@ -28,6 +28,7 @@ class BotConfigSnapshot:
|
||||
bot_terms_url: str
|
||||
bot_support_ticket_link: str
|
||||
payment_review_link: str
|
||||
broadcast_results_link: str
|
||||
bot_start_image_enabled: bool
|
||||
bot_start_image_path: str
|
||||
referral_enabled: bool
|
||||
@@ -36,6 +37,7 @@ class BotConfigSnapshot:
|
||||
payment_plans_raw: str
|
||||
payment_product_plans_raw: str
|
||||
payment_transfer_text: str
|
||||
payment_success_text: str
|
||||
payment_support_text: str
|
||||
payment_plan_traffic_limit_gb: int
|
||||
payment_plan_traffic_reset_period: str
|
||||
@@ -52,6 +54,8 @@ class BotConfigSnapshot:
|
||||
fallback_support_ticket_thread_id: int | None = None
|
||||
fallback_payment_review_chat_id: int | None = None
|
||||
fallback_payment_review_thread_id: int | None = None
|
||||
fallback_broadcast_results_chat_id: int | None = None
|
||||
fallback_broadcast_results_thread_id: int | None = None
|
||||
|
||||
@property
|
||||
def bot_public_username_normalized(self) -> str:
|
||||
@@ -245,6 +249,24 @@ class BotConfigSnapshot:
|
||||
else self.fallback_payment_review_thread_id
|
||||
)
|
||||
|
||||
@property
|
||||
def broadcast_results_chat_id(self) -> int | None:
|
||||
parsed_chat_id, _ = Settings._parse_private_topic_link(self.broadcast_results_link)
|
||||
return (
|
||||
parsed_chat_id
|
||||
if parsed_chat_id is not None
|
||||
else self.fallback_broadcast_results_chat_id
|
||||
)
|
||||
|
||||
@property
|
||||
def broadcast_results_message_thread_id(self) -> int | None:
|
||||
_, parsed_thread_id = Settings._parse_private_topic_link(self.broadcast_results_link)
|
||||
return (
|
||||
parsed_thread_id
|
||||
if parsed_thread_id is not None
|
||||
else self.fallback_broadcast_results_thread_id
|
||||
)
|
||||
|
||||
|
||||
class StaticBotConfigService:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
@@ -258,6 +280,7 @@ class StaticBotConfigService:
|
||||
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,
|
||||
broadcast_results_link=self._settings.broadcast_results_link,
|
||||
bot_start_image_enabled=self._settings.bot_start_image_enabled,
|
||||
bot_start_image_path=self._settings.bot_start_image_path,
|
||||
referral_enabled=True,
|
||||
@@ -266,6 +289,11 @@ class StaticBotConfigService:
|
||||
payment_plans_raw=self._settings.payment_plans_raw,
|
||||
payment_product_plans_raw=self._settings.payment_product_plans_raw,
|
||||
payment_transfer_text=self._settings.payment_transfer_text,
|
||||
payment_success_text=getattr(
|
||||
self._settings,
|
||||
"payment_success_text",
|
||||
Settings.model_fields["payment_success_text"].default,
|
||||
),
|
||||
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,
|
||||
@@ -282,6 +310,8 @@ class StaticBotConfigService:
|
||||
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,
|
||||
fallback_broadcast_results_chat_id=self._settings.broadcast_results_chat_id,
|
||||
fallback_broadcast_results_thread_id=self._settings.broadcast_results_message_thread_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -414,6 +444,14 @@ class BotConfigService(StaticBotConfigService):
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/1234567890/56/57",
|
||||
),
|
||||
"broadcast_results_link": BotConfigFieldSpec(
|
||||
key="broadcast_results_link",
|
||||
label="Ссылка на ответы рассылок",
|
||||
section="links",
|
||||
prompt="Введите приватную ссылку на чат/топик, куда будут приходить ответы опросов. Для очистки отправьте `-`.",
|
||||
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
|
||||
placeholder="https://t.me/c/3646494169/67/68",
|
||||
),
|
||||
"payment_transfer_text": BotConfigFieldSpec(
|
||||
key="payment_transfer_text",
|
||||
label="Инструкция по оплате",
|
||||
@@ -430,6 +468,14 @@ class BotConfigService(StaticBotConfigService):
|
||||
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
|
||||
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
|
||||
),
|
||||
"payment_success_text": BotConfigFieldSpec(
|
||||
key="payment_success_text",
|
||||
label="Текст после покупки",
|
||||
section="texts",
|
||||
prompt="Введите текст, который бот добавит к сообщению со ссылкой после подтверждения оплаты.",
|
||||
description="Показывается покупателю вместе со ссылкой на подписку после выдачи или продления доступа.",
|
||||
placeholder="Доступ выдан. Сохраните ссылку и используйте её для подключения.",
|
||||
),
|
||||
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
|
||||
key="payment_internal_squad_uuids_raw",
|
||||
label="UUID внутренних групп",
|
||||
@@ -533,6 +579,12 @@ class BotConfigService(StaticBotConfigService):
|
||||
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)),
|
||||
broadcast_results_link=str(
|
||||
overrides.get(
|
||||
"broadcast_results_link",
|
||||
self._settings.broadcast_results_link,
|
||||
)
|
||||
),
|
||||
bot_start_image_enabled=self._parse_bool(
|
||||
overrides.get("bot_start_image_enabled"),
|
||||
default=self._settings.bot_start_image_enabled,
|
||||
@@ -558,6 +610,16 @@ class BotConfigService(StaticBotConfigService):
|
||||
)
|
||||
),
|
||||
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
|
||||
payment_success_text=str(
|
||||
overrides.get(
|
||||
"payment_success_text",
|
||||
getattr(
|
||||
self._settings,
|
||||
"payment_success_text",
|
||||
Settings.model_fields["payment_success_text"].default,
|
||||
),
|
||||
)
|
||||
),
|
||||
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"),
|
||||
@@ -610,6 +672,8 @@ class BotConfigService(StaticBotConfigService):
|
||||
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,
|
||||
fallback_broadcast_results_chat_id=self._settings.broadcast_results_chat_id,
|
||||
fallback_broadcast_results_thread_id=self._settings.broadcast_results_message_thread_id,
|
||||
)
|
||||
|
||||
async def update_setting(
|
||||
@@ -672,6 +736,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"broadcast_results_link",
|
||||
"bot_start_image_path",
|
||||
"payment_product_plans_raw",
|
||||
"payment_vpn_squad_uuids_raw",
|
||||
@@ -703,7 +768,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
if key == "notification_check_interval_hours":
|
||||
return f"{max(int(value or 6), 1)} ч"
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
if key in {"payment_transfer_text", "payment_success_text", "payment_support_text"}:
|
||||
return cls._compact_text(str(value), max_length=120)
|
||||
|
||||
return cls._compact_text(str(value))
|
||||
@@ -732,6 +797,7 @@ class BotConfigService(StaticBotConfigService):
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"broadcast_results_link",
|
||||
"bot_start_image_path",
|
||||
"payment_external_squad_uuid",
|
||||
"payment_user_tag",
|
||||
@@ -798,10 +864,11 @@ class BotConfigService(StaticBotConfigService):
|
||||
"bot_terms_url",
|
||||
"bot_support_ticket_link",
|
||||
"payment_review_link",
|
||||
"broadcast_results_link",
|
||||
}:
|
||||
return value
|
||||
|
||||
if key in {"payment_transfer_text", "payment_support_text"}:
|
||||
if key in {"payment_transfer_text", "payment_success_text", "payment_support_text"}:
|
||||
if not value:
|
||||
raise ValueError("Текст не может быть пустым.")
|
||||
return value[:4000]
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import ceil
|
||||
@@ -30,6 +31,77 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CYRILLIC_TRANSLITERATION = str.maketrans(
|
||||
{
|
||||
"\u0410": "A",
|
||||
"\u0411": "B",
|
||||
"\u0412": "V",
|
||||
"\u0413": "G",
|
||||
"\u0414": "D",
|
||||
"\u0415": "E",
|
||||
"\u0401": "E",
|
||||
"\u0416": "Zh",
|
||||
"\u0417": "Z",
|
||||
"\u0418": "I",
|
||||
"\u0419": "Y",
|
||||
"\u041a": "K",
|
||||
"\u041b": "L",
|
||||
"\u041c": "M",
|
||||
"\u041d": "N",
|
||||
"\u041e": "O",
|
||||
"\u041f": "P",
|
||||
"\u0420": "R",
|
||||
"\u0421": "S",
|
||||
"\u0422": "T",
|
||||
"\u0423": "U",
|
||||
"\u0424": "F",
|
||||
"\u0425": "Kh",
|
||||
"\u0426": "Ts",
|
||||
"\u0427": "Ch",
|
||||
"\u0428": "Sh",
|
||||
"\u0429": "Sch",
|
||||
"\u042a": "",
|
||||
"\u042b": "Y",
|
||||
"\u042c": "",
|
||||
"\u042d": "E",
|
||||
"\u042e": "Yu",
|
||||
"\u042f": "Ya",
|
||||
"\u0430": "a",
|
||||
"\u0431": "b",
|
||||
"\u0432": "v",
|
||||
"\u0433": "g",
|
||||
"\u0434": "d",
|
||||
"\u0435": "e",
|
||||
"\u0451": "e",
|
||||
"\u0436": "zh",
|
||||
"\u0437": "z",
|
||||
"\u0438": "i",
|
||||
"\u0439": "y",
|
||||
"\u043a": "k",
|
||||
"\u043b": "l",
|
||||
"\u043c": "m",
|
||||
"\u043d": "n",
|
||||
"\u043e": "o",
|
||||
"\u043f": "p",
|
||||
"\u0440": "r",
|
||||
"\u0441": "s",
|
||||
"\u0442": "t",
|
||||
"\u0443": "u",
|
||||
"\u0444": "f",
|
||||
"\u0445": "kh",
|
||||
"\u0446": "ts",
|
||||
"\u0447": "ch",
|
||||
"\u0448": "sh",
|
||||
"\u0449": "sch",
|
||||
"\u044a": "",
|
||||
"\u044b": "y",
|
||||
"\u044c": "",
|
||||
"\u044d": "e",
|
||||
"\u044e": "yu",
|
||||
"\u044f": "ya",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppliedDiscount:
|
||||
@@ -196,14 +268,35 @@ class PaymentService:
|
||||
)
|
||||
|
||||
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
|
||||
renewal_target_record = self._select_cached_renewal_target(existing_accesses)
|
||||
renewal_target_uuid: str | None = None
|
||||
renewal_target_username: str | None = None
|
||||
if renewal_target_record is not None:
|
||||
renewal_target_uuid = str(renewal_target_record.rw_uuid)
|
||||
renewal_target_username = renewal_target_record.username
|
||||
else:
|
||||
try:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||
telegram_id
|
||||
)
|
||||
except RemnawaveApiError:
|
||||
remote_users = []
|
||||
remote_user = self._select_referral_bonus_target(remote_users)
|
||||
if remote_user is not None:
|
||||
renewal_target_uuid = str(remote_user.uuid)
|
||||
renewal_target_username = remote_user.username
|
||||
|
||||
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,
|
||||
provision_username = (
|
||||
renewal_target_username
|
||||
or 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:
|
||||
@@ -230,6 +323,7 @@ class PaymentService:
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
invoice_payload=invoice_payload,
|
||||
provision_username=provision_username,
|
||||
remnawave_user_uuid=renewal_target_uuid,
|
||||
error_message=plan.discount_code or None,
|
||||
)
|
||||
)
|
||||
@@ -240,7 +334,7 @@ class PaymentService:
|
||||
provision_username=provision_username,
|
||||
plan=plan,
|
||||
transfer_text=config.payment_transfer_text.strip(),
|
||||
extends_existing_access=bool(existing_accesses),
|
||||
extends_existing_access=bool(existing_accesses) or renewal_target_uuid is not None,
|
||||
)
|
||||
|
||||
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
|
||||
@@ -350,10 +444,17 @@ class PaymentService:
|
||||
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)
|
||||
try:
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||
order.telegram_id
|
||||
)
|
||||
remote_user = self._select_referral_bonus_target(remote_users)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to look up user by telegram_id=%s, will try to create",
|
||||
order.telegram_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if remote_user is None:
|
||||
remote_user = await self._create_remnawave_user(
|
||||
@@ -375,6 +476,19 @@ class PaymentService:
|
||||
except RemnawaveApiError as exc:
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_msg = f"Unexpected error: {exc.__class__.__name__}: {exc}"
|
||||
logger.error(
|
||||
"Unexpected error during order approval %s: %s",
|
||||
order.order_uuid,
|
||||
error_msg,
|
||||
exc_info=True,
|
||||
)
|
||||
await self._restore_review_after_failed_approval(order.order_uuid, error_msg)
|
||||
raise RemnawaveApiError(
|
||||
status_code=0,
|
||||
message=f"Ошибка подключения к панели. Попробуйте ещё раз. ({exc.__class__.__name__})",
|
||||
)
|
||||
|
||||
await self._save_fulfilled_order(order.order_uuid, remote_user)
|
||||
|
||||
@@ -772,7 +886,21 @@ class PaymentService:
|
||||
if order.status == PENDING_PAYMENT_STATUS:
|
||||
raise ValueError("Чек ещё не отправлен на проверку.")
|
||||
if order.status == PROCESSING_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
# If the order has been stuck in PROCESSING for more than 60s,
|
||||
# it means the previous approval attempt crashed (e.g. ConnectError).
|
||||
# Allow re-claiming it instead of blocking forever.
|
||||
stale_threshold = utcnow() - timedelta(seconds=60)
|
||||
if order.updated_at and order.updated_at > stale_threshold:
|
||||
raise ValueError("Заказ уже обрабатывается другим модератором.")
|
||||
logger.warning(
|
||||
"Order %s was stuck in PROCESSING since %s, reclaiming",
|
||||
order_uuid,
|
||||
order.updated_at,
|
||||
)
|
||||
# Already in PROCESSING — just refresh the timestamp and proceed
|
||||
order.updated_at = utcnow()
|
||||
await session.commit()
|
||||
return order
|
||||
if order.status == REJECTED_PAYMENT_STATUS:
|
||||
raise ValueError("Заказ уже отклонён.")
|
||||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||||
@@ -806,7 +934,33 @@ class PaymentService:
|
||||
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)
|
||||
|
||||
try:
|
||||
return await self._remnawave_client.create_user(body)
|
||||
except RemnawaveApiError as exc:
|
||||
if exc.status_code != 400:
|
||||
raise
|
||||
# Creation failed (likely duplicate telegramId) — fall back to
|
||||
# finding the existing user and extending their subscription.
|
||||
logger.warning(
|
||||
"POST /api/users returned 400 for order=%s telegram_id=%s: %s. "
|
||||
"Falling back to extend existing user.",
|
||||
order.order_uuid,
|
||||
order.telegram_id,
|
||||
exc.message,
|
||||
)
|
||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||
order.telegram_id
|
||||
)
|
||||
existing_user = self._select_referral_bonus_target(remote_users)
|
||||
if existing_user is None:
|
||||
raise # re-raise original 400 if we truly can't find anyone
|
||||
return await self._extend_remnawave_user(
|
||||
order,
|
||||
existing_user,
|
||||
plan=plan,
|
||||
config=config,
|
||||
)
|
||||
|
||||
async def _extend_remnawave_user(
|
||||
self,
|
||||
@@ -1068,6 +1222,28 @@ class PaymentService:
|
||||
|
||||
return sorted(remote_users, key=_sort_key)[0]
|
||||
|
||||
@staticmethod
|
||||
def _select_cached_renewal_target(cached_users: list[object]) -> object | None:
|
||||
records = [
|
||||
record
|
||||
for access in cached_users
|
||||
if (record := getattr(access, "record", None)) is not None
|
||||
and getattr(record, "rw_uuid", None)
|
||||
and getattr(record, "username", None)
|
||||
]
|
||||
if not records:
|
||||
return None
|
||||
|
||||
def _sort_key(record: object) -> tuple[int, datetime, str]:
|
||||
expire_at = getattr(record, "expire_at", None) or datetime.min
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
||||
status = str(getattr(record, "status", "")).upper()
|
||||
username = str(getattr(record, "username", ""))
|
||||
return (0 if status == "ACTIVE" else 1, expire_at, username)
|
||||
|
||||
return sorted(records, key=_sort_key)[0]
|
||||
|
||||
async def _upsert_telegram_user(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -1107,13 +1283,19 @@ class PaymentService:
|
||||
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"
|
||||
normalized_name = self._normalize_provision_name(base_name)
|
||||
|
||||
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]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_provision_name(raw_name: str) -> str:
|
||||
transliterated = raw_name.translate(_CYRILLIC_TRANSLITERATION)
|
||||
decomposed = unicodedata.normalize("NFKD", transliterated)
|
||||
ascii_name = decomposed.encode("ascii", "ignore").decode("ascii")
|
||||
normalized_name = "".join(
|
||||
ch for ch in ascii_name if ch.isalnum() or ch in "_-"
|
||||
).strip("-_")
|
||||
return normalized_name or "user"
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
|
||||
from app.schemas.remnawave import HwidDevicesResponse, PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
|
||||
|
||||
|
||||
UUID_RE = re.compile(
|
||||
@@ -145,6 +145,26 @@ class RemnawaveApiClient:
|
||||
payload = await self._request_json("POST", "users/resolve", json=body)
|
||||
return ResolvedUser.model_validate(payload)
|
||||
|
||||
async def get_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse:
|
||||
payload = await self._request_json("GET", f"hwid/devices/{user_uuid}")
|
||||
return HwidDevicesResponse.model_validate(payload)
|
||||
|
||||
async def delete_user_hwid_device(self, *, user_uuid: str, hwid: str) -> HwidDevicesResponse:
|
||||
payload = await self._request_json(
|
||||
"POST",
|
||||
"hwid/devices/delete",
|
||||
json={"userUuid": user_uuid, "hwid": hwid},
|
||||
)
|
||||
return HwidDevicesResponse.model_validate(payload)
|
||||
|
||||
async def delete_all_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse:
|
||||
payload = await self._request_json(
|
||||
"POST",
|
||||
"hwid/devices/delete-all",
|
||||
json={"userUuid": user_uuid},
|
||||
)
|
||||
return HwidDevicesResponse.model_validate(payload)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
@@ -153,12 +173,28 @@ class RemnawaveApiClient:
|
||||
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,
|
||||
)
|
||||
try:
|
||||
response = await self._client.request(
|
||||
method=method,
|
||||
url=path.lstrip("/"),
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise RemnawaveApiError(
|
||||
status_code=0,
|
||||
message=f"Remnawave API timeout: {exc}",
|
||||
) from exc
|
||||
except httpx.NetworkError as exc:
|
||||
raise RemnawaveApiError(
|
||||
status_code=0,
|
||||
message=f"Remnawave API network error: {exc}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RemnawaveApiError(
|
||||
status_code=0,
|
||||
message=f"Remnawave API HTTP error: {exc}",
|
||||
) from exc
|
||||
|
||||
if response.is_error:
|
||||
raise self._build_error(response)
|
||||
|
||||
@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from app.config import Settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.models import (
|
||||
BroadcastPoll,
|
||||
BroadcastPollAnswer,
|
||||
Guide,
|
||||
GuidePhoto,
|
||||
InternalSquad,
|
||||
@@ -124,6 +126,13 @@ class AdminTelegramUserProfile:
|
||||
accesses: list[CachedUserView] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BroadcastPollAnswerResult:
|
||||
poll: BroadcastPoll
|
||||
answer: BroadcastPollAnswer
|
||||
previous_answer: str | None = None
|
||||
|
||||
|
||||
class SyncService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -571,6 +580,69 @@ class SyncService:
|
||||
)
|
||||
return list(rows.all())
|
||||
|
||||
async def create_broadcast_poll(
|
||||
self,
|
||||
*,
|
||||
question_text: str,
|
||||
created_by_telegram_id: int,
|
||||
) -> BroadcastPoll:
|
||||
async with self._session_factory() as session:
|
||||
poll = BroadcastPoll(
|
||||
question_text=question_text,
|
||||
created_by_telegram_id=created_by_telegram_id,
|
||||
)
|
||||
session.add(poll)
|
||||
await session.commit()
|
||||
return poll
|
||||
|
||||
async def record_broadcast_poll_answer(
|
||||
self,
|
||||
*,
|
||||
poll_id: int,
|
||||
telegram_id: int,
|
||||
username: str | None,
|
||||
first_name: str | None,
|
||||
last_name: str | None,
|
||||
answer: str,
|
||||
) -> BroadcastPollAnswerResult | None:
|
||||
normalized_answer = answer.strip().lower()
|
||||
if normalized_answer not in {"yes", "no"}:
|
||||
raise ValueError("Unsupported answer")
|
||||
|
||||
async with self._session_factory() as session:
|
||||
poll = await session.scalar(
|
||||
select(BroadcastPoll).where(BroadcastPoll.id == poll_id)
|
||||
)
|
||||
if poll is None:
|
||||
return None
|
||||
|
||||
record = await session.scalar(
|
||||
select(BroadcastPollAnswer).where(
|
||||
BroadcastPollAnswer.poll_id == poll_id,
|
||||
BroadcastPollAnswer.telegram_id == telegram_id,
|
||||
)
|
||||
)
|
||||
previous_answer = record.answer if record is not None else None
|
||||
if record is None:
|
||||
record = BroadcastPollAnswer(
|
||||
poll_id=poll_id,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
session.add(record)
|
||||
|
||||
record.username = username
|
||||
record.first_name = first_name
|
||||
record.last_name = last_name
|
||||
record.answer = normalized_answer
|
||||
record.updated_at = utcnow()
|
||||
|
||||
await session.commit()
|
||||
return BroadcastPollAnswerResult(
|
||||
poll=poll,
|
||||
answer=record,
|
||||
previous_answer=previous_answer,
|
||||
)
|
||||
|
||||
async def get_admin_telegram_users_page(
|
||||
self,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user