Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9e36928c | ||
|
|
c245ccb55b | ||
|
|
ba348eb639 | ||
|
|
f6ce39f7c1 | ||
|
|
4732a97611 | ||
|
|
0fe29c19b1 | ||
|
|
7da42c9e25 | ||
|
|
e490769044 |
+11
-2
@@ -1,11 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -47,7 +47,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
||||
* **Python 3.11**
|
||||
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
||||
* **aiohttp:** For running the webhook server
|
||||
* **aiosqlite:** Asynchronous SQLite database interaction
|
||||
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
|
||||
* **YooKassa SDK:** For payment processing
|
||||
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
||||
* **Pydantic:** For settings management (loading from `.env`)
|
||||
@@ -103,26 +103,26 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
||||
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
||||
|
||||
3. **Locales:**
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. The `bot_database.sqlite3` and `locales` directory will be mounted as volumes in Docker. `locales` mounting is optional.
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
|
||||
|
||||
4. **Build and Run with Docker Compose:**
|
||||
4. **Run with Docker Compose:**
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker compose up -d
|
||||
```
|
||||
This command will build the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `vpn-shop` service in detached mode.
|
||||
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
|
||||
|
||||
5. **Webhook Setup (Important if using webhooks):**
|
||||
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
|
||||
* Forward requests for `https://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://vpn-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `vpn-shop` is the service name in `docker-compose.yml`).
|
||||
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://vpn-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* Forward requests for `https://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
|
||||
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `TELEGRAM_WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
|
||||
|
||||
6. **Database:**
|
||||
* A SQLite database file (`bot_database.sqlite3`) will be created in your project root (or wherever you map the volume). The schema is initialized automatically on the first run if the file doesn't exist.
|
||||
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
|
||||
|
||||
7. **Viewing Logs:**
|
||||
```bash
|
||||
docker compose logs -f vpn-shop
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
## 🐳 Docker Setup
|
||||
@@ -154,31 +154,39 @@ CMD ["python", "main.py"]
|
||||
```
|
||||
|
||||
### `docker-compose.yml`
|
||||
|
||||
YAML
|
||||
|
||||
```
|
||||
services:
|
||||
vpn-shop:
|
||||
build: .
|
||||
container_name: vpn-shop
|
||||
hostname: vpn-shop
|
||||
networks:
|
||||
- remnawave-network # Ensure this external network exists or define it
|
||||
volumes:
|
||||
- ./bot_database.sqlite3:/app/bot_database.sqlite3
|
||||
# - ./locales:/app/locales
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
# volumes:
|
||||
# - ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
# Optionally, expose ports if you are not using a shared Docker network
|
||||
# and want to access the bot's webserver directly (not recommended for production without a reverse proxy)
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
external: true # Assumes 'remnawave-network' is an existing external Docker network
|
||||
# If not, you might want to define it here or use a default bridge.
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
```
|
||||
|
||||
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
|
||||
@@ -196,17 +204,21 @@ networks:
|
||||
│ ├── states/ # FSM states
|
||||
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
||||
├── config/
|
||||
│ └── settings.py # Pydantic settings model
|
||||
│ └── settings.py # Pydantic settings and config parser
|
||||
├── db/
|
||||
│ └── database.py # Database schema, connection, and CRUD functions
|
||||
├── locales/ # Localization files (en.json, ru.json)
|
||||
├── .env.example # Example environment variables
|
||||
├── .env # Your local environment variables (ignored by git)
|
||||
├── Dockerfile # Instructions to build the Docker image
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── requirements.txt # Python dependencies
|
||||
└── main.py # Main entry point to run the bot
|
||||
|
||||
│ ├── dal/ # Data Access Layer (queries, transactions)
|
||||
│ ├── database_setup.py # DB connection/init setup
|
||||
│ └── models.py # ORM models (e.g., SQLAlchemy)
|
||||
├── locales/ # Localization files
|
||||
│ ├── en.json # English locale
|
||||
│ └── ru.json # Russian locale
|
||||
├── .env.example # Example environment variables for local setup
|
||||
├── .env # Actual environment variables (ignored by Git)
|
||||
├── Dockerfile # Docker image build instructions
|
||||
├── docker-compose.yml # Docker Compose orchestration config
|
||||
├── requirements.txt # List of Python dependencies
|
||||
├── README.md # Project documentation
|
||||
└── main.py # Entry point to launch the bot
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
+113
-88
@@ -11,16 +11,23 @@ from config.settings import Settings
|
||||
from db.dal import user_dal, message_log_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_broadcast_confirmation_keyboard,
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_admin_panel_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
|
||||
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def broadcast_message_prompt_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
||||
)
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
||||
async def process_broadcast_message_handler(message: types.Message,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def process_broadcast_message_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -63,28 +73,35 @@ async def process_broadcast_message_handler(message: types.Message,
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
broadcast_message_text = message.html_text
|
||||
await state.update_data(broadcast_message=broadcast_message_text)
|
||||
# Сохраняем в state исходный текст и entities
|
||||
text = message.text or message.caption or ""
|
||||
entities = message.entities or message.caption_entities or []
|
||||
|
||||
preview_snippet = (
|
||||
broadcast_message_text[:200] +
|
||||
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt",
|
||||
message_preview=preview_snippet)
|
||||
await state.update_data(
|
||||
broadcast_text=text,
|
||||
broadcast_entities=entities,
|
||||
)
|
||||
|
||||
await message.answer(confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
|
||||
|
||||
await message.answer(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_broadcast_message)
|
||||
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
|
||||
)
|
||||
async def cancel_broadcast_at_prompt_stage(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
@@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||
|
||||
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
||||
await state.clear()
|
||||
|
||||
await callback.message.answer(_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
await callback.message.answer(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast)
|
||||
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict, bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
@router.callback_query(
|
||||
F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast,
|
||||
)
|
||||
async def confirm_broadcast_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing broadcast confirmation.",
|
||||
show_alert=True)
|
||||
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
action = callback.data.split(":")[1]
|
||||
user_fsm_data = await state.get_data()
|
||||
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
|
||||
|
||||
if action == "send":
|
||||
if not broadcast_message_to_send:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_error_no_message"))
|
||||
text = user_fsm_data.get("broadcast_text")
|
||||
entities = user_fsm_data.get("broadcast_entities", [])
|
||||
|
||||
if not text:
|
||||
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_broadcast_error_no_message_alert"),
|
||||
show_alert=True)
|
||||
await callback.answer(
|
||||
_("admin_broadcast_error_no_message_alert"), show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"),
|
||||
reply_markup=None)
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||
await callback.answer()
|
||||
|
||||
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
|
||||
session)
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
logging.info(
|
||||
f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
|
||||
)
|
||||
admin_user = callback.from_user
|
||||
logging.info(
|
||||
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
|
||||
)
|
||||
|
||||
for user_id_to_send in user_ids_for_broadcast:
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await bot.send_message(user_id_to_send,
|
||||
broadcast_message_to_send,
|
||||
parse_mode="HTML")
|
||||
await bot.send_message(
|
||||
chat_id=uid,
|
||||
text=text,
|
||||
entities=entities,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
log_payload_sent = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content":
|
||||
f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_sent)
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content": f"To user {uid}: {text[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
|
||||
f"Failed to send broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
)
|
||||
log_payload_failed = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content":
|
||||
f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_failed)
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content": f"For user {uid}: {type(e).__name__} – {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
try:
|
||||
@@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
result_message = _("admin_broadcast_finished_stats",
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count)
|
||||
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
|
||||
await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
|
||||
elif action == "cancel":
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -257,97 +258,88 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
|
||||
settings: Settings, panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService, session: AsyncSession,
|
||||
bot: Bot):
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
user = event.from_user
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target_message_obj:
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
logging.error(
|
||||
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
|
||||
)
|
||||
await target_message_obj.answer(get_text("error_service_unavailable"))
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active_sub_details = await subscription_service.get_active_subscription_details(
|
||||
session, user.id)
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
sub_info_text_content = ""
|
||||
if active_sub_details:
|
||||
end_date_obj = active_sub_details.get('end_date')
|
||||
days_left = 0
|
||||
if end_date_obj:
|
||||
if end_date_obj.tzinfo is None:
|
||||
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
|
||||
days_left = (end_date_obj.date() - datetime.now().date()).days
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
actual_config_link = active_sub_details.get('config_link') or get_text(
|
||||
"config_link_not_available")
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"),
|
||||
callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
|
||||
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
|
||||
kb = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[buy_button],
|
||||
*back_markup.inline_keyboard
|
||||
]
|
||||
)
|
||||
|
||||
traffic_limit_gb_str = get_text("traffic_unlimited")
|
||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
||||
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
traffic_used_gb_str = get_text("traffic_na")
|
||||
if traffic_used_bytes is not None:
|
||||
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
|
||||
end_date = active.get("end_date")
|
||||
days_left = (
|
||||
(end_date.date() - datetime.now().date()).days
|
||||
if end_date else 0
|
||||
)
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(
|
||||
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_limit_bytes")
|
||||
else get_text("traffic_unlimited")
|
||||
),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_used_bytes") is not None
|
||||
else get_text("traffic_na")
|
||||
)
|
||||
)
|
||||
markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
sub_info_text_content = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date_obj.strftime("%Y-%m-%d")
|
||||
if end_date_obj else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active_sub_details.get(
|
||||
'status_from_panel', get_text('status_active')).capitalize(),
|
||||
config_link=actual_config_link,
|
||||
traffic_limit=traffic_limit_gb_str,
|
||||
traffic_used=traffic_used_gb_str)
|
||||
else:
|
||||
sub_info_text_content = get_text("subscription_not_active")
|
||||
logging.info(
|
||||
f"User {user.id} no active sub details for 'my_subscription'.")
|
||||
|
||||
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.CallbackQuery) and event.message:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
|
||||
)
|
||||
await bot.send_message(chat_id=target_message_obj.chat.id,
|
||||
text=sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except:
|
||||
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
else:
|
||||
await target_message_obj.answer(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
|
||||
+1
-8
@@ -105,14 +105,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
telegram_webhook_url_to_set = getattr(settings,
|
||||
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
||||
if telegram_webhook_url_to_set:
|
||||
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
|
||||
logging.error(
|
||||
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
|
||||
"This is a major security vulnerability. Webhook will NOT be set."
|
||||
)
|
||||
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
|
||||
else:
|
||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
|
||||
+11
-10
@@ -6,26 +6,27 @@ services:
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
# volumes:
|
||||
# - ./locales:/app/locales
|
||||
networks:
|
||||
- remnawave-network
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
networks:
|
||||
remnawave-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_create_promo_button": "🎁 Создать промо",
|
||||
"admin_view_promos_button": "👀 Спиок промо",
|
||||
"admin_view_promos_button": "👀 Список промо",
|
||||
"admin_ban_user_button": "🚫 Забанить",
|
||||
"admin_unban_user_button": "✅ Разбанить",
|
||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
aiogram==3.20.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.9.5
|
||||
aiohttp==3.10.11
|
||||
pydantic==2.7.1
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
|
||||
Reference in New Issue
Block a user