Compare commits

..
8 Commits
Author SHA1 Message Date
machka df9e36928c Broadcast HTML fix and subscription keyboards improvements 2025-05-21 19:30:25 +00:00
machka c245ccb55b requirements.txt update 2025-05-21 15:45:41 +00:00
machka ba348eb639 Logger and docker-compose.yml update 2025-05-21 12:23:11 +00:00
machka f6ce39f7c1 Dockerfile update №2 2025-05-21 11:14:13 +00:00
machka 4732a97611 Dockerfile update 2025-05-21 11:04:05 +00:00
machka 0fe29c19b1 ru.json update 2025-05-21 07:59:38 +00:00
machka 7da42c9e25 README update 2 2025-05-21 07:55:38 +00:00
machka e490769044 README update 2025-05-21 07:53:23 +00:00
8 changed files with 255 additions and 223 deletions
+11 -2
View File
@@ -1,11 +1,20 @@
FROM python:3.11-slim FROM python:3.11-slim AS builder
WORKDIR /app WORKDIR /app
COPY requirements.txt . 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 . . COPY . .
RUN rm -rf /root/.cache
CMD ["python", "main.py"] CMD ["python", "main.py"]
+51 -39
View File
@@ -47,7 +47,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* **Python 3.11** * **Python 3.11**
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework * **Aiogram 3.x:** Asynchronous Telegram Bot Framework
* **aiohttp:** For running the webhook server * **aiohttp:** For running the webhook server
* **aiosqlite:** Asynchronous SQLite database interaction * **sqlalchemy:** Asynchronous PostgreSQL database interaction
* **YooKassa SDK:** For payment processing * **YooKassa SDK:** For payment processing
* **APScheduler:** For scheduled tasks (e.g., notifications) * **APScheduler:** For scheduled tasks (e.g., notifications)
* **Pydantic:** For settings management (loading from `.env`) * **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. * `LOGS_PAGE_SIZE`: For admin panel log pagination.
3. **Locales:** 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 ```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):** 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. * **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`). * 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://vpn-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`. * 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`. * **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:** 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:** 7. **Viewing Logs:**
```bash ```bash
docker compose logs -f vpn-shop docker compose logs -f remnawave-tg-shop
``` ```
## 🐳 Docker Setup ## 🐳 Docker Setup
@@ -154,31 +154,39 @@ CMD ["python", "main.py"]
``` ```
### `docker-compose.yml` ### `docker-compose.yml`
YAML
``` ```
services: services:
vpn-shop: remnawave-tg-shop:
build: . image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
container_name: vpn-shop # build: .
hostname: vpn-shop container_name: remnawave-tg-shop
networks: hostname: remnawave-tg-shop
- remnawave-network # Ensure this external network exists or define it env_file:
volumes: - .env
- ./bot_database.sqlite3:/app/bot_database.sqlite3 # networks:
# - ./locales:/app/locales # - remnawave-network
# volumes:
# - ./locales:/app/locales
restart: unless-stopped 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: postgres:
remnawave-network: image: postgres:17
external: true # Assumes 'remnawave-network' is an existing external Docker network container_name: remnawave-tg-shop-db
# If not, you might want to define it here or use a default bridge. 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. **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 │ ├── states/ # FSM states
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown │ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
├── config/ ├── config/
│ └── settings.py # Pydantic settings model │ └── settings.py # Pydantic settings and config parser
├── db/ ├── db/
── database.py # Database schema, connection, and CRUD functions ── dal/ # Data Access Layer (queries, transactions)
├── locales/ # Localization files (en.json, ru.json) │ ├── database_setup.py # DB connection/init setup
├── .env.example # Example environment variables │ └── models.py # ORM models (e.g., SQLAlchemy)
├── .env # Your local environment variables (ignored by git) ├── locales/ # Localization files
├── Dockerfile # Instructions to build the Docker image │ ├── en.json # English locale
├── docker-compose.yml # Docker Compose configuration │ └── ru.json # Russian locale
├── requirements.txt # Python dependencies ├── .env.example # Example environment variables for local setup
── main.py # Main entry point to run the bot ── .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 ## 🤝 Contributing
+113 -88
View File
@@ -11,16 +11,23 @@ from config.settings import Settings
from db.dal import user_dal, message_log_dal from db.dal import user_dal, message_log_dal
from bot.states.admin_states import AdminStates 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 from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_broadcast_router") router = Router(name="admin_broadcast_router")
async def broadcast_message_prompt_handler(callback: types.CallbackQuery, async def broadcast_message_prompt_handler(
state: FSMContext, i18n_data: dict, callback: types.CallbackQuery,
settings: Settings, state: FSMContext,
session: AsyncSession): i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: if not i18n:
@@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
try: try:
await callback.message.edit_text( await callback.message.edit_text(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard( reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
current_lang, i18n)) )
except Exception as e: except Exception as e:
logging.warning( logging.warning(
f"Could not edit message for broadcast prompt: {e}. Sending new." f"Could not edit message for broadcast prompt: {e}. Sending new."
) )
await callback.message.answer( await callback.message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard( reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
current_lang, i18n)) )
await callback.answer() await callback.answer()
await state.set_state(AdminStates.waiting_for_broadcast_message) await state.set_state(AdminStates.waiting_for_broadcast_message)
@router.message(AdminStates.waiting_for_broadcast_message, F.text) @router.message(AdminStates.waiting_for_broadcast_message, F.text)
async def process_broadcast_message_handler(message: types.Message, async def process_broadcast_message_handler(
state: FSMContext, i18n_data: dict, message: types.Message,
settings: Settings, state: FSMContext,
session: AsyncSession): i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: 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) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
broadcast_message_text = message.html_text # Сохраняем в state исходный текст и entities
await state.update_data(broadcast_message=broadcast_message_text) text = message.text or message.caption or ""
entities = message.entities or message.caption_entities or []
preview_snippet = ( await state.update_data(
broadcast_message_text[:200] + broadcast_text=text,
"...") if len(broadcast_message_text) > 200 else broadcast_message_text broadcast_entities=entities,
confirmation_prompt = _("admin_broadcast_confirm_prompt", )
message_preview=preview_snippet)
await message.answer(confirmation_prompt, preview_snippet = (text[:200] + "...") if len(text) > 200 else text
reply_markup=get_broadcast_confirmation_keyboard( confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
current_lang, i18n),
parse_mode="HTML") await message.answer(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
)
await state.set_state(AdminStates.confirming_broadcast) await state.set_state(AdminStates.confirming_broadcast)
@router.callback_query(F.data == "admin_action:main", @router.callback_query(
AdminStates.waiting_for_broadcast_message) F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery, )
state: FSMContext, async def cancel_broadcast_at_prompt_stage(
settings: Settings, i18n_data: dict, callback: types.CallbackQuery,
session: AsyncSession): state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: if not i18n or not callback.message:
@@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
try: try:
await callback.message.edit_text( await callback.message.edit_text(
_("admin_broadcast_cancelled_nav_back"), reply_markup=None) _("admin_broadcast_cancelled_nav_back"), reply_markup=None
)
except Exception: except Exception:
await callback.message.answer(_("admin_broadcast_cancelled_nav_back")) await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
await callback.answer(_("admin_broadcast_cancelled_alert")) await callback.answer(_("admin_broadcast_cancelled_alert"))
await state.clear() await state.clear()
await callback.message.answer(_(key="admin_panel_title"), await callback.message.answer(
reply_markup=get_admin_panel_keyboard( _(key="admin_panel_title"),
i18n, current_lang, settings)) reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(F.data.startswith("broadcast_final_action:"), @router.callback_query(
AdminStates.confirming_broadcast) F.data.startswith("broadcast_final_action:"),
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery, AdminStates.confirming_broadcast,
state: FSMContext, )
i18n_data: dict, bot: Bot, async def confirm_broadcast_callback_handler(
settings: Settings, callback: types.CallbackQuery,
session: AsyncSession): state: FSMContext,
i18n_data: dict,
bot: Bot,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: if not i18n or not callback.message:
await callback.answer("Error processing broadcast confirmation.", await callback.answer("Error processing broadcast confirmation.", show_alert=True)
show_alert=True)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action = callback.data.split(":")[1] action = callback.data.split(":")[1]
user_fsm_data = await state.get_data() user_fsm_data = await state.get_data()
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
if action == "send": if action == "send":
if not broadcast_message_to_send: text = user_fsm_data.get("broadcast_text")
await callback.message.edit_text( entities = user_fsm_data.get("broadcast_entities", [])
_("admin_broadcast_error_no_message"))
if not text:
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear() await state.clear()
await callback.answer(_("admin_broadcast_error_no_message_alert"), await callback.answer(
show_alert=True) _("admin_broadcast_error_no_message_alert"), show_alert=True
)
return return
await callback.message.edit_text(_("admin_broadcast_sending_started"), await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
reply_markup=None)
await callback.answer() await callback.answer()
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast( user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
session)
sent_count = 0 sent_count = 0
failed_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 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: try:
await bot.send_message(user_id_to_send, await bot.send_message(
broadcast_message_to_send, chat_id=uid,
parse_mode="HTML") text=text,
entities=entities,
)
sent_count += 1 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( 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: except Exception as e:
failed_count += 1 failed_count += 1
logging.warning( 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( 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) await asyncio.sleep(0.05)
try: try:
@@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
await session.rollback() await session.rollback()
logging.error(f"Error committing broadcast logs: {e_commit}") logging.error(f"Error committing broadcast logs: {e_commit}")
result_message = _("admin_broadcast_finished_stats", result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
sent_count=sent_count,
failed_count=failed_count)
await callback.message.answer( await callback.message.answer(
result_message, 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": elif action == "cancel":
await callback.message.edit_text( await callback.message.edit_text(
_("admin_broadcast_cancelled"), _("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 callback.answer()
await state.clear() await state.clear()
+66 -74
View File
@@ -1,6 +1,7 @@
import logging import logging
from aiogram import Router, F, types, Bot from aiogram import Router, F, types, Bot
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from typing import Optional, Dict, Any, Union from typing import Optional, Dict, Any, Union
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession 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( async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery], i18n_data: dict, event: Union[types.Message, types.CallbackQuery],
settings: Settings, panel_service: PanelApiService, i18n_data: dict,
subscription_service: SubscriptionService, session: AsyncSession, settings: Settings,
bot: Bot): panel_service: PanelApiService,
target_message_obj = event.message if isinstance( subscription_service: SubscriptionService,
event, types.CallbackQuery) else event session: AsyncSession,
user = event.from_user bot: Bot
):
if isinstance(event, types.CallbackQuery): target = event.message if isinstance(event, types.CallbackQuery) else event
await event.answer()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
) if i18n else key
if not i18n or not target_message_obj: if not i18n or not target:
if isinstance(event, types.Message): if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again")) await event.answer(get_text("error_occurred_try_again"))
return return
if not panel_service or not subscription_service: if not panel_service or not subscription_service:
logging.error( await target.answer(get_text("error_service_unavailable"))
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
)
await target_message_obj.answer(get_text("error_service_unavailable"))
return return
active_sub_details = await subscription_service.get_active_subscription_details( active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
session, user.id)
sub_info_text_content = "" if not active:
if active_sub_details: text = get_text("subscription_not_active")
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
actual_config_link = active_sub_details.get('config_link') or get_text( buy_button = InlineKeyboardButton(
"config_link_not_available") 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') kb = InlineKeyboardMarkup(
traffic_used_bytes = active_sub_details.get('traffic_used_bytes') inline_keyboard=[
[buy_button],
*back_markup.inline_keyboard
]
)
traffic_limit_gb_str = get_text("traffic_unlimited") if isinstance(event, types.CallbackQuery):
if traffic_limit_bytes and traffic_limit_bytes > 0: await event.answer()
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB" 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") end_date = active.get("end_date")
if traffic_used_bytes is not None: days_left = (
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB" (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( if isinstance(event, types.CallbackQuery):
"my_subscription_details", await event.answer()
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:
try: try:
await event.message.edit_text(sub_info_text_content, await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
reply_markup=reply_markup_val, except:
parse_mode="HTML", await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
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)
else: else:
await target_message_obj.answer(sub_info_text_content, await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
@router.message(Command("connect")) @router.message(Command("connect"))
+1 -8
View File
@@ -105,14 +105,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
telegram_webhook_url_to_set = getattr(settings, telegram_webhook_url_to_set = getattr(settings,
'TELEGRAM_WEBHOOK_BASE_URL', None) 'TELEGRAM_WEBHOOK_BASE_URL', None)
if telegram_webhook_url_to_set: if telegram_webhook_url_to_set:
if settings.BOT_TOKEN in telegram_webhook_url_to_set: full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
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}"
logging.info( 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'}" 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
View File
@@ -6,26 +6,27 @@ services:
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
env_file: env_file:
- .env - .env
# networks: networks:
# - remnawave-network - remnawave-network
# volumes: volumes:
# - ./locales:/app/locales - ./locales:/app/locales
restart: unless-stopped restart: unless-stopped
postgres: remnawave-tg-shop-db:
image: postgres:17 image: postgres:17
container_name: remnawave-tg-shop-db container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file: env_file:
- .env - .env
volumes: volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data - remnawave-tg-shop-db-data:/var/lib/postgresql/data
# networks: networks:
# - remnawave-network - remnawave-network
restart: unless-stopped restart: unless-stopped
# networks: networks:
# remnawave-network: remnawave-network:
# external: true external: true
volumes: volumes:
remnawave-tg-shop-db-data: remnawave-tg-shop-db-data:
+1 -1
View File
@@ -88,7 +88,7 @@
"admin_stats_button": "📊 Статистика", "admin_stats_button": "📊 Статистика",
"admin_broadcast_button": "📢 Рассылка", "admin_broadcast_button": "📢 Рассылка",
"admin_create_promo_button": "🎁 Создать промо", "admin_create_promo_button": "🎁 Создать промо",
"admin_view_promos_button": "👀 Спиок промо", "admin_view_promos_button": "👀 Список промо",
"admin_ban_user_button": "🚫 Забанить", "admin_ban_user_button": "🚫 Забанить",
"admin_unban_user_button": "✅ Разбанить", "admin_unban_user_button": "✅ Разбанить",
"admin_view_banned_users_button": "📜 Бан-лист", "admin_view_banned_users_button": "📜 Бан-лист",
+1 -1
View File
@@ -1,6 +1,6 @@
aiogram==3.20.0 aiogram==3.20.0
python-dotenv==1.0.1 python-dotenv==1.0.1
aiohttp==3.9.5 aiohttp==3.10.11
pydantic==2.7.1 pydantic==2.7.1
yookassa==3.5.0 yookassa==3.5.0
pycountry==23.12.11 pycountry==23.12.11