hwid devices integration
This commit is contained in:
@@ -20,6 +20,8 @@ TERMS_OF_SERVICE_URL=https://example.com/tos #
|
|||||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||||
|
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||||
|
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||||
|
|
||||||
# Required channel subscription
|
# Required channel subscription
|
||||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
### Для пользователей:
|
### Для пользователей:
|
||||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||||
|
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||||
|
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||||
</details>
|
</details>
|
||||||
@@ -114,6 +116,9 @@
|
|||||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||||
|
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||||
|
|
||||||
|
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||||
</gidetails>
|
</gidetails>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -173,12 +173,27 @@ async def my_subscription_command_handler(
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
prepend_rows.append([
|
if settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
InlineKeyboardButton(
|
max_devices_value = active.get("max_devices")
|
||||||
text=get_text("devices_button"),
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
callback_data="main_action:my_devices",
|
if max_devices_value not in (None, 0):
|
||||||
|
try:
|
||||||
|
max_devices_int = int(max_devices_value)
|
||||||
|
if max_devices_int >= 0:
|
||||||
|
max_devices_display = str(max_devices_int)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_devices_display = str(max_devices_value)
|
||||||
|
devices_button_text = get_text(
|
||||||
|
"devices_button",
|
||||||
|
current_devices="?",
|
||||||
|
max_devices=max_devices_display,
|
||||||
)
|
)
|
||||||
])
|
prepend_rows.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=devices_button_text,
|
||||||
|
callback_data="main_action:my_devices",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
# 2) Auto-renew toggle (if supported and not tribute)
|
# 2) Auto-renew toggle (if supported and not tribute)
|
||||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||||
@@ -243,18 +258,52 @@ async def my_devices_command_handler(
|
|||||||
await event.answer(get_text("error_occurred_try_again"))
|
await event.answer(get_text("error_occurred_try_again"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(get_text("my_devices_feature_disabled"))
|
||||||
|
return
|
||||||
|
|
||||||
# TODO: context?
|
# TODO: context?
|
||||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||||
|
if not active or not active.get("user_id"):
|
||||||
|
message = get_text("subscription_not_active")
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(message, show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(message)
|
||||||
|
return
|
||||||
|
|
||||||
devices = await panel_service.get_user_devices(active.get("user_id")) if active else None
|
devices = await panel_service.get_user_devices(active.get("user_id")) if active else None
|
||||||
if not devices:
|
if not devices:
|
||||||
await target.answer(get_text("no_devices_found"))
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
await event.answer(get_text("no_devices_found"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
await target.answer(get_text("no_devices_found"))
|
||||||
return
|
return
|
||||||
|
|
||||||
max_devices = active.get("max_devices")
|
max_devices_value = active.get("max_devices")
|
||||||
|
max_devices_display = get_text("devices_unlimited_label")
|
||||||
|
if max_devices_value not in (None, 0):
|
||||||
|
try:
|
||||||
|
max_devices_int = int(max_devices_value)
|
||||||
|
if max_devices_int >= 0:
|
||||||
|
max_devices_display = str(max_devices_int)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_devices_display = str(max_devices_value)
|
||||||
|
|
||||||
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||||
text = get_text("no_devices_details_found_message", max_devices=max_devices)
|
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||||
else:
|
else:
|
||||||
devices_list = []
|
devices_list = []
|
||||||
current_devices = len(devices.get('devices') or [])
|
current_devices = len(devices.get('devices') or [])
|
||||||
@@ -270,7 +319,7 @@ async def my_devices_command_handler(
|
|||||||
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||||
devices_list.append(device_details)
|
devices_list.append(device_details)
|
||||||
|
|
||||||
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices)
|
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
|
||||||
|
|
||||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
|
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
|
||||||
kb = base_markup.inline_keyboard
|
kb = base_markup.inline_keyboard
|
||||||
@@ -311,6 +360,13 @@ async def disconnect_device_handler(
|
|||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
|
|
||||||
|
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, hwid = callback.data.split(":", 1)
|
_, hwid = callback.data.split(":", 1)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -480,5 +536,3 @@ async def connect_command_handler(
|
|||||||
):
|
):
|
||||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||||
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ class PanelApiService:
|
|||||||
default_expire_days: int = 1,
|
default_expire_days: int = 1,
|
||||||
default_traffic_limit_bytes: int = 0,
|
default_traffic_limit_bytes: int = 0,
|
||||||
default_traffic_limit_strategy: str = "NO_RESET",
|
default_traffic_limit_strategy: str = "NO_RESET",
|
||||||
|
hwid_device_limit: Optional[int] = None,
|
||||||
specific_squad_uuids: Optional[List[str]] = None,
|
specific_squad_uuids: Optional[List[str]] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
@@ -368,6 +369,18 @@ class PanelApiService:
|
|||||||
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
||||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||||
}
|
}
|
||||||
|
hwid_limit_value = hwid_device_limit
|
||||||
|
if hwid_limit_value is None:
|
||||||
|
hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
if hwid_limit_value is not None:
|
||||||
|
try:
|
||||||
|
hwid_limit_int = int(hwid_limit_value)
|
||||||
|
if hwid_limit_int >= 0:
|
||||||
|
payload["hwidDeviceLimit"] = hwid_limit_int
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logging.warning(
|
||||||
|
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'."
|
||||||
|
)
|
||||||
if specific_squad_uuids:
|
if specific_squad_uuids:
|
||||||
payload["activeInternalSquads"] = specific_squad_uuids
|
payload["activeInternalSquads"] = specific_squad_uuids
|
||||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||||
|
|||||||
@@ -739,6 +739,9 @@ class SubscriptionService:
|
|||||||
if panel_user_data.get("expireAt")
|
if panel_user_data.get("expireAt")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||||
|
if hwid_limit is None:
|
||||||
|
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user_id": panel_user_data.get("uuid"),
|
"user_id": panel_user_data.get("uuid"),
|
||||||
@@ -749,7 +752,7 @@ class SubscriptionService:
|
|||||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||||
"user_bot_username": db_user.username,
|
"user_bot_username": db_user.username,
|
||||||
"is_panel_data": True,
|
"is_panel_data": True,
|
||||||
"max_devices": panel_user_data.get("hwidDeviceLimit"),
|
"max_devices": hwid_limit,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_subscriptions_ending_soon(
|
async def get_subscriptions_ending_soon(
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ class Settings(BaseSettings):
|
|||||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||||
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
||||||
|
|
||||||
|
MY_DEVICES_SECTION_ENABLED: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Enable the My Devices section in the subscription menu"
|
||||||
|
)
|
||||||
|
USER_HWID_DEVICE_LIMIT: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Default hardware device limit for panel users (0 = unlimited)"
|
||||||
|
)
|
||||||
|
|
||||||
# Inline mode thumbnail URLs
|
# Inline mode thumbnail URLs
|
||||||
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
||||||
INLINE_USER_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/681/681494.png")
|
INLINE_USER_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/681/681494.png")
|
||||||
@@ -366,6 +375,15 @@ class Settings(BaseSettings):
|
|||||||
return None
|
return None
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def validate_optional_int(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
# Notification types
|
# Notification types
|
||||||
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
||||||
LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments")
|
LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments")
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
"disconnect_device_button": "🚫 Disconnect {hwid} ({index})",
|
"disconnect_device_button": "🚫 Disconnect {hwid} ({index})",
|
||||||
"device_disconnected": "Device disconnected successfully.",
|
"device_disconnected": "Device disconnected successfully.",
|
||||||
"no_devices_found": "Error loading device list.",
|
"no_devices_found": "Error loading device list.",
|
||||||
|
"devices_unlimited_label": "Unlimited",
|
||||||
|
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||||
|
|
||||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||||
|
|||||||
+3
-1
@@ -43,13 +43,15 @@
|
|||||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||||
"connect_button": "🔗 Подключиться",
|
"connect_button": "🔗 Подключиться",
|
||||||
|
|
||||||
"devices_button": "📱 Мои устройства",
|
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||||
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||||
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||||
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nДата подключения: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nДата подключения: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||||
"disconnect_device_button": "🚫 Отключить {hwid} ({index})",
|
"disconnect_device_button": "🚫 Отключить {hwid} ({index})",
|
||||||
"device_disconnected": "Устройство успешно отключено.",
|
"device_disconnected": "Устройство успешно отключено.",
|
||||||
"no_devices_found": "Ошибка загрузки списка устройств.",
|
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||||
|
"devices_unlimited_label": "Без ограничений",
|
||||||
|
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||||
|
|
||||||
"cancel_button": "❌ Отмена",
|
"cancel_button": "❌ Отмена",
|
||||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||||
|
|||||||
Reference in New Issue
Block a user