Compare commits

...

34 Commits

Author SHA1 Message Date
245741b182 feat(subscription): improve subscription sync and renewal logic. 2026-06-17 18:10:56 +07:00
7ab0e71108 style(keyboards): add emoji to device management button 2026-06-17 15:46:58 +07:00
3adcc90b74 chore(docker): remove exposed PostgreSQL port from compose 2026-06-17 15:45:01 +07:00
93f05e46b5 feat(bot): implement HWID device list and deletion 2026-06-17 15:40:35 +07:00
agony
edfe1e6874 feat(subs): UNFINISHED hwid control 2026-06-16 16:24:02 +07:00
greendevilll
7ab2293fe2 fix(notifications): multiply duration days by 30 for admin log accuracy 2026-06-06 23:49:41 +07:00
9552813f16 fix(autorenewal): remove duplicated response handling in toggle_autorenewal 2026-06-04 13:44:09 +07:00
4c5a16a3ca feat(admin): add admin notification system for deposits, purchases, and renewals 2026-06-04 12:35:32 +07:00
5ec9491e22 feat(pally): support customer-pays-fees scenario with BalanceAmount validation 2026-05-31 15:23:31 +07:00
18f994aed9 fix(pally): use InvId as primary bill identifier instead of custom field 2026-05-31 15:17:48 +07:00
936d1ad260 fix(api): enhance pally webhook with comprehensive error handling and logging 2026-05-31 15:11:54 +07:00
2a0f5c454e debugging 2026-05-31 14:53:49 +07:00
2103e6a882 Revert to b5976d0b21 2026-05-31 14:47:53 +07:00
102a0ebf96 fix(pally): strip 'payment-' prefix from InvId before database lookup 2026-05-31 14:44:43 +07:00
18f4997ca6 fix(pally): use InvId instead of custom field for order ID lookup 2026-05-31 14:37:34 +07:00
b5976d0b21 fix(pally): align webhook handler with official API spec 2026-05-31 14:25:05 +07:00
763e041a8b fix(api): change pally webhook endpoint from /success to /result 2026-05-31 14:14:17 +07:00
f9d3fbf937 refactor(api): update pally webhook endpoint path 2026-05-31 14:05:27 +07:00
a02af3b0f0 chore(docker): bind pally service port to localhost only 2026-05-31 13:16:04 +07:00
a0c8167da6 chore(docker): use .env instead of prod.env 2026-05-31 12:56:07 +07:00
717a1549e9 feat(ui): enhance text formatting with premium emojis and improve FAQ clarity 2026-05-24 15:43:54 +07:00
18866fc645 chore(texts): reorder constants and remove outdated mapping section 2026-05-23 21:26:20 +07:00
1228ec8b56 refactor(autorenewal): clean up imports and simplify error handling 2026-05-18 17:58:34 +07:00
a9a650f37b feat(config): mask sensitive credentials with SecretStr type 2026-05-18 17:48:34 +07:00
56f10d19c5 feat(autorenewal): implement automatic subscription renewal system 2026-05-18 17:32:04 +07:00
281a460974 refactoring: polished some files, .env.example update 2026-05-18 17:00:17 +07:00
acb8662a66 Refactor bot handlers and keyboards; implement admin features
- Removed unused prehandling, referrals, support, and states files.
- Updated admin and client handlers to improve structure and functionality.
- Introduced admin filters and routers for better access control.
- Added new billing and deposit functionalities for client interactions.
- Enhanced keyboard layouts for admin and client menus.
- Implemented promotion handling for admin users.
- Updated configuration to include admin settings.
- Improved utility functions for quote fetching and menu text building.
- Added common handlers for undefined commands and message deletions.
2026-05-13 10:34:06 +07:00
e9f84d9377 fix: enhance insufficient funds message and update subscription controls logic 2026-05-03 07:03:53 +07:00
ac61cec78c fix: added proxy support for the API telegram bot instance + removed deprecated plans 2026-05-02 13:51:27 +07:00
db2dabed58 feat: implement subscription management and billing enhancements 2026-04-29 20:10:26 +07:00
120e19e667 fix: fixed migration issues 2026-04-27 22:07:35 +07:00
fc656c5944 feat: remove exposed PostgreSQL port from docker-compose 2026-04-27 21:47:25 +07:00
287f8eebda feat: add subscription and transaction repositories
- Implemented SubscriptionRepository with methods to get, create, and update subscriptions.
- Added BalanceTXRepository for creating and retrieving balance transactions.
- Introduced a test script for simulating Pally webhook for local testing.
2026-04-27 21:33:25 +07:00
049f31118d feat: Implement payment notification system and API structure
- Added notifications for successful payments in `api/core/notifications.py`.
- Created main API entry point in `api/main.py` with FastAPI integration.
- Established routing structure in `api/routes/__init__.py` and `api/routes/pally.py` for handling payment callbacks.
- Developed billing handlers in `bot/handlers/billing.py` and `bot/handlers/buy.py` for subscription management.
- Introduced state management for user interactions in `bot/states/`.
- Created user interface elements in `bot/keyboards/` for navigation and payment processing.
- Set up middleware for dependency injection in `bot/middlewares/di.py`.
- Added support for user commands and interactions in `bot/handlers/common.py` and `bot/handlers/support.py`.
- Implemented logging and error handling throughout the bot's functionality.
- Created entry point script for API server in `entrypoints/api.sh`.
2026-04-24 17:41:42 +07:00
80 changed files with 2956 additions and 541 deletions

View File

@@ -1,25 +1,47 @@
# Telegram Bot Token (Required) # Database
# Get it from @BotFather on Telegram POSTGRES_USER=
BOT_TOKEN=your_bot_token_here POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_URL=postgresql+asyncpg://user:password@localhost:5432/database_name
# Remnawave Configuration (Required)
REMNAWAVE_URL=https://your-remnawave-instance.com
REMNAWAVE_TOKEN=your_remnawave_api_token_here
SUBSCRIPTION_URL=https://your-subscription-page.com
# PostgreSQL Database Configuration # Telegram bot
# These variables are used by docker-compose to set up the database container BOT_TOKEN=your_telegram_bot_token
POSTGRES_USER=malenia
POSTGRES_PASSWORD=change_me_to_a_secure_password
POSTGRES_DB=malenia_db
# Proxy Configuration (Optional) # Comma-separated list of Telegram user IDs
# Leave empty if no proxy is needed ADMINS=[123456789,987654321]
PROXY=
# Pricing and Limits Configuration (Optional - defaults provided in code) # Optional admin operation logs. Leave empty to disable.
ADMIN_LOG_CHAT_ID=
ADMIN_LOG_DEPOSIT_TOPIC_ID=
ADMIN_LOG_SUBSCRIPTIONS_TOPIC_ID=
# Pally
PALLY_SHOP_ID=your_pally_shop_id
PALLY_TOKEN=your_pally_token
# Referral system
REFERAL_BONUS=10
DEPOSIT_THRESHOLD=50
# Optional proxy
PROXY=http://user:password@host:port
# Remnawave
REMNAWAVE_URL=https://example.com
SUBSCRIPTION_URL=https://example.com/subscription
REMNAWAVE_TOKEN=your_remnawave_token
# Device pricing
MIN_DEVICES=3 MIN_DEVICES=3
MAX_DEVICES=12 MAX_DEVICES=12
PER_DEVICE_COST=50 PER_DEVICE_COST=50
WHITELIST_COST=100 WHITELIST_COST=100
WHITELIST_DEVICE_THRESHOLD=6 WHITELIST_DEVICE_THRESHOLD=6
# Squads
GENERAL_SQUAD=2de0b5ea-49b9-4181-916f-ae67b9c83b80
WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977
# Autorenewal
AUTORENEW_DAYS_BEFORE=1

3
.gitignore vendored
View File

@@ -130,6 +130,7 @@ celerybeat.pid
# Environments # Environments
.env .env
*.env
dev.env dev.env
.venv .venv
env/ env/
@@ -174,3 +175,5 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
plans

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.0

41
Dockerfile.api Normal file
View File

@@ -0,0 +1,41 @@
# Stage 1: Builder - Install dependencies
FROM python:3.13-slim AS builder
WORKDIR /build
# Install build-time dependencies required for compiling packages like asyncpg
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Runner - Final lightweight image
FROM python:3.13-slim AS runner
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Install runtime dependencies required by asyncpg
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from the builder stage
COPY --from=builder /install /usr/local
# Copy application source code
COPY . .
RUN sed -i 's/\r$//' ./entrypoints/api.sh && \
chmod +x ./entrypoints/api.sh
# Ensure the api script is executable
RUN chmod +x ./entrypoints/api.sh
# Default command runs migrations and starts the API
CMD ["sh", "./entrypoints/api.sh"]

View File

@@ -0,0 +1,40 @@
"""add_autorenew_to_balancetxtype
Revision ID: 0d71262de0d5
Revises: f5fbc1975807
Create Date: 2026-05-18 17:26:23.302066
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0d71262de0d5'
down_revision: Union[str, Sequence[str], None] = 'f5fbc1975807'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE balancetxtype ADD VALUE 'autorenew'")
def downgrade() -> None:
# PostgreSQL не поддерживает удаление значений из ENUM напрямую.
# Для даунгрейда нужно пересоздать тип, как в миграции f0668f8e7310.
op.execute("""
CREATE TYPE balancetxtype_old AS ENUM (
'deposit', 'purchase', 'refund', 'referral', 'manual'
)
""")
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
op.execute("""
ALTER TABLE balance_transactions
ALTER COLUMN tx_type TYPE balancetxtype_old
USING (tx_type::text::balancetxtype_old)
""")
op.execute("DROP TYPE balancetxtype")
op.execute("ALTER TYPE balancetxtype_old RENAME TO balancetxtype")

View File

@@ -0,0 +1,34 @@
"""add columns for better sub tracking
Revision ID: 92dfa9b572f1
Revises: f0668f8e7310
Create Date: 2026-04-27 21:56:51.493795
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '92dfa9b572f1'
down_revision: Union[str, Sequence[str], None] = 'f0668f8e7310'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('plan_price_paid', sa.INTEGER(), nullable=False))
op.add_column('subscriptions', sa.Column('duration_days', sa.INTEGER(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'duration_days')
op.drop_column('subscriptions', 'plan_price_paid')
# ### end Alembic commands ###

View File

@@ -0,0 +1,36 @@
"""plan specifications added
Revision ID: b2ecec0ef260
Revises: e8da1e4516fa
Create Date: 2026-04-27 20:16:04.252427
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2ecec0ef260'
down_revision: Union[str, Sequence[str], None] = 'e8da1e4516fa'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('devices', sa.INTEGER(), nullable=False, server_default="3"))
op.add_column('subscriptions', sa.Column('whitelists', sa.BOOLEAN(), nullable=False, server_default="false"))
# ### end Alembic commands ###
op.alter_column("subscriptions", "devices", server_default=None)
op.alter_column("subscriptions", "whitelists", server_default=None)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'whitelists')
op.drop_column('subscriptions', 'devices')
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""add_autorenew_to_balancetxtype
Revision ID: bb4199d71f9e
Revises: 0d71262de0d5
Create Date: 2026-05-18 17:26:23.454401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bb4199d71f9e'
down_revision: Union[str, Sequence[str], None] = '0d71262de0d5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -19,23 +19,25 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" op.create_table(
# 1. Сначала создаем тип в базе данных 'bills',
# Имя 'billstatus' должно совпадать с тем, что в ошибке sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
bind = op.get_bind() sa.Column('creator_id', sa.BigInteger(), nullable=False),
if bind.dialect.name == 'postgresql': sa.Column('amount', sa.Integer(), nullable=False),
op.execute("CREATE TYPE billstatus AS ENUM ('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL')") sa.Column(
'status',
# 2. Теперь добавляем колонку, которая использует этот тип sa.Enum(
op.add_column('bills', sa.Column('status', sa.Enum('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL', name='billstatus'), nullable=False)) 'NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL',
name='billstatus',
),
nullable=False,
),
sa.ForeignKeyConstraint(['creator_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
)
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" op.drop_table('bills')
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('bills', 'status')
bind = op.get_bind()
if bind.dialect.name == 'postgresql':
op.execute("DROP TYPE billstatus") op.execute("DROP TYPE billstatus")
# ### end Alembic commands ###

View File

@@ -0,0 +1,81 @@
"""replace_billstatus_with_depositstatus
Revision ID: e6541f81f7de
Revises: b2ecec0ef260
Create Date: 2026-04-27 20:38:12.226592
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'e6541f81f7de'
down_revision: Union[str, Sequence[str], None] = 'b2ecec0ef260'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('balance_transactions',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.BIGINT(), nullable=False),
sa.Column('amount', sa.INTEGER(), nullable=False),
sa.Column('tx_type', sa.Enum('DEPOSIT', 'PURCHASE', 'REFUND', 'REFERRAL_BONUS', 'MANUAL', name='balancetxtype'), nullable=False),
sa.Column('balance_before', sa.INTEGER(), nullable=False),
sa.Column('balance_after', sa.INTEGER(), nullable=False),
sa.Column('description', sa.TEXT(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_balance_transactions_user_id'), 'balance_transactions', ['user_id'], unique=False)
op.execute("CREATE TYPE depositstatus AS ENUM ('pending', 'success', 'failed', 'expired')")
op.execute("ALTER TABLE bills ALTER COLUMN status DROP DEFAULT")
op.execute("""
ALTER TABLE bills
ALTER COLUMN status TYPE depositstatus
USING (
CASE status::text
WHEN 'NEW' THEN 'pending'::depositstatus
WHEN 'PROCESS' THEN 'pending'::depositstatus
WHEN 'UNDERPAID' THEN 'pending'::depositstatus
WHEN 'SUCCESS' THEN 'success'::depositstatus
WHEN 'OVERPAID' THEN 'success'::depositstatus
WHEN 'FAIL' THEN 'failed'::depositstatus
ELSE 'pending'::depositstatus
END
)
""")
op.execute("ALTER TABLE bills ALTER COLUMN status SET DEFAULT 'pending'::depositstatus")
op.execute("DROP TYPE billstatus")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.execute("CREATE TYPE billstatus AS ENUM ('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL')")
op.execute("ALTER TABLE bills ALTER COLUMN status DROP DEFAULT")
op.execute("""
ALTER TABLE bills
ALTER COLUMN status TYPE billstatus
USING (
CASE status::text
WHEN 'pending' THEN 'NEW'::billstatus
WHEN 'success' THEN 'SUCCESS'::billstatus
WHEN 'failed' THEN 'FAIL'::billstatus
WHEN 'expired' THEN 'FAIL'::billstatus
ELSE 'NEW'::billstatus
END
)
""")
op.execute("ALTER TABLE bills ALTER COLUMN status SET DEFAULT 'NEW'::billstatus")
op.execute("DROP TYPE depositstatus")
op.drop_index(op.f('ix_balance_transactions_user_id'), table_name='balance_transactions')
op.drop_table('balance_transactions')
# ### end Alembic commands ###

View File

@@ -0,0 +1,39 @@
"""add subscription table
Revision ID: e8da1e4516fa
Revises: d21c5f9364ea
Create Date: 2026-04-25 16:29:08.186456
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e8da1e4516fa'
down_revision: Union[str, Sequence[str], None] = 'd21c5f9364ea'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'subscriptions',
sa.Column('user_id', sa.BIGINT(), nullable=False),
sa.Column('end_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('autorenew', sa.Boolean(), nullable=False),
sa.Column(
'status',
sa.Enum('expired', 'active', name='subscriptionstatus'),
nullable=False,
),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.PrimaryKeyConstraint('user_id'),
)
def downgrade() -> None:
op.drop_table('subscriptions')
op.execute("DROP TYPE subscriptionstatus")

View File

@@ -0,0 +1,69 @@
"""fix_balancetxtype_to_lowercase
Revision ID: f0668f8e7310
Revises: e6541f81f7de
Create Date: 2026-04-27 21:06:55.168624
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f0668f8e7310'
down_revision: Union[str, Sequence[str], None] = 'e6541f81f7de'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TYPE balancetxtype_new AS ENUM (
'deposit', 'purchase', 'refund', 'referral', 'manual'
)
""")
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
op.execute("""
ALTER TABLE balance_transactions
ALTER COLUMN tx_type TYPE balancetxtype_new
USING (
CASE tx_type::text
WHEN 'DEPOSIT' THEN 'deposit'::balancetxtype_new
WHEN 'PURCHASE' THEN 'purchase'::balancetxtype_new
WHEN 'REFUND' THEN 'refund'::balancetxtype_new
WHEN 'REFERRAL_BONUS' THEN 'referral'::balancetxtype_new
WHEN 'MANUAL' THEN 'manual'::balancetxtype_new
ELSE 'manual'::balancetxtype_new
END
)
""")
op.execute("DROP TYPE balancetxtype")
op.execute("ALTER TYPE balancetxtype_new RENAME TO balancetxtype")
def downgrade() -> None:
op.execute("""
CREATE TYPE balancetxtype_old AS ENUM (
'DEPOSIT', 'PURCHASE', 'REFUND', 'REFERRAL_BONUS', 'MANUAL'
)
""")
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
op.execute("""
ALTER TABLE balance_transactions
ALTER COLUMN tx_type TYPE balancetxtype_old
USING (
CASE tx_type::text
WHEN 'deposit' THEN 'DEPOSIT'::balancetxtype_old
WHEN 'purchase' THEN 'PURCHASE'::balancetxtype_old
WHEN 'refund' THEN 'REFUND'::balancetxtype_old
WHEN 'referral' THEN 'REFERRAL_BONUS'::balancetxtype_old
WHEN 'manual' THEN 'MANUAL'::balancetxtype_old
ELSE 'MANUAL'::balancetxtype_old
END
)
""")
op.execute("DROP TYPE balancetxtype")
op.execute("ALTER TYPE balancetxtype_old RENAME TO balancetxtype")

View File

@@ -0,0 +1,42 @@
"""id->unique, deleted deprecated tables
Revision ID: f5fbc1975807
Revises: 92dfa9b572f1
Create Date: 2026-05-18 17:08:56.792369
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'f5fbc1975807'
down_revision: Union[str, Sequence[str], None] = '92dfa9b572f1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tickets')
op.create_unique_constraint(None, 'bills', ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'bills', type_='unique')
op.create_table('tickets',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('creator_id', sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column('thread_id', sa.BIGINT(), autoincrement=False, nullable=True),
sa.Column('status', postgresql.ENUM('OPEN', 'CLOSED', 'PENDING', name='ticketstatus'), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('tickets_pkey')),
sa.UniqueConstraint('id', name=op.f('tickets_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False),
sa.UniqueConstraint('thread_id', name=op.f('tickets_thread_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###

18
api/core/deps.py Normal file
View File

@@ -0,0 +1,18 @@
from aiogram import Bot
from fastapi import Request
from db.session import async_session
from schemas.di import APIDependenciesDTO
def get_bot(request: Request) -> Bot:
return request.app.state.bot
async def get_db():
async with async_session() as db:
yield db
def get_deps(request: Request) -> APIDependenciesDTO:
return request.app.state.deps

13
api/core/notifications.py Normal file
View File

@@ -0,0 +1,13 @@
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
SUCCESSFUL_PAYMENT = "<b>💸 Зачислен платёж на {amount}₽</b>"
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="", callback_data="delete")]]
).as_markup()
async def successful_payment_notification(bot: Bot, user_id: int, amount: int):
await bot.send_message(user_id, SUCCESSFUL_PAYMENT.format(amount=amount), reply_markup=close_kb)

58
api/main.py Normal file
View File

@@ -0,0 +1,58 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from fastapi import FastAPI
from api.routes import routers
from config import settings
from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from schemas.di import APIDependenciesDTO
from services.billing_service import BillingService
from services.user_service import UserService
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
# Bot Init
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
app.state.bot = bot
user_repository = UserRepository()
bills_repository = BillsRepository()
subscription_repository = SubscriptionRepository()
user_service = UserService(user_repository, subscription_repository=subscription_repository)
bills_service = BillingService(
user_repository=user_repository, bills_repository=bills_repository
)
deps_dto = APIDependenciesDTO(
user_repository=user_repository,
user_service=user_service,
bills_repository=bills_repository,
billing_service=bills_service,
)
app.state.deps = deps_dto
for r in routers:
app.include_router(r)
yield # App's lifespan
await bot.session.close() # D IE!!!!!!!!
app = FastAPI(
title="Malenia API",
description="Payment webhook handler and user balance management",
version="1.0.0",
lifespan=lifespan,
docs_url="",
)

3
api/routes/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .pally import router as pally_router
routers = [pally_router]

237
api/routes/pally.py Normal file
View File

@@ -0,0 +1,237 @@
# ruff: noqa: N803
import hashlib
import hmac
import logging
import math
from aiogram import Bot
from fastapi import Depends, Form, HTTPException
from fastapi.routing import APIRouter
from sqlalchemy.ext.asyncio import AsyncSession
from api.core.deps import get_bot, get_db, get_deps
from api.core.notifications import successful_payment_notification
from config import settings
from db.models.bills import DepositStatus
from db.models.transactions import BalanceTxType
from misc.pally import BillStatus
from schemas.di import APIDependenciesDTO
from services.admin_notifications import notify_deposit
router = APIRouter(prefix="/pally")
logger = logging.getLogger(__name__)
@router.post("/result")
async def pally_callback( # noqa: PLR0911, PLR0912
InvId: str = Form(...),
OutSum: str = Form(...),
Commission: str = Form(...),
TrsId: str = Form(...),
Status: str = Form(...),
CurrencyIn: str = Form(...),
custom: str | None = Form(None),
SignatureValue: str = Form(...),
# Optional fields for additional information
AccountType: str | None = Form(None),
AccountNumber: str | None = Form(None),
BalanceAmount: str | None = Form(None),
BalanceCurrency: str | None = Form(None),
PayerPhone: str | None = Form(None),
PayerEmail: str | None = Form(None),
PayerName: str | None = Form(None),
PayerComment: str | None = Form(None),
ErrorCode: int | None = Form(None),
ErrorMessage: str | None = Form(None),
session: AsyncSession = Depends(get_db),
bot: Bot = Depends(get_bot),
deps: APIDependenciesDTO = Depends(get_deps),
):
# FIXED: Use InvId (which contains the order_id from bill creation) instead of custom field
# The InvId field contains the db_bill.id that was passed as order_id during bill creation
bill_id_str = InvId
logger.info(
"Pally webhook received - InvId: %s, OutSum: %s, Commission: %s, TrsId: %s, Status: %s, "
"CurrencyIn: %s, custom: %s, BalanceAmount: %s, SignatureValue: %s",
InvId,
OutSum,
Commission,
TrsId,
Status,
CurrencyIn,
custom,
BalanceAmount,
SignatureValue,
)
# Enhanced logging for failed payments
if Status != BillStatus.SUCCESS:
logger.warning(
"Non-success payment received - Status: %s, ErrorCode: %s, ErrorMessage: %s, TrsId: %s",
Status,
ErrorCode,
ErrorMessage,
TrsId,
)
# Validate signature
raw_string = f"{OutSum}:{InvId}:{settings.pally_token}"
expected_signature = hashlib.md5(raw_string.encode("utf-8")).hexdigest().upper()
logger.debug("Signature validation for TrsId %s", TrsId)
if not hmac.compare_digest(SignatureValue, expected_signature):
logger.critical(
"SECURITY ALERT: Invalid signature for TrsId %s - Expected: %s, Received: %s",
TrsId,
expected_signature,
SignatureValue,
)
raise HTTPException(403, detail="Invalid signature.")
# Only process successful payments
if Status != BillStatus.SUCCESS:
logger.info("Bill %s skipped: status=%s", TrsId, Status)
return "OK"
logger.info("Processing successfully paid bill %s", TrsId)
# Validate bill ID (from InvId field, which contains the order_id from bill creation)
if not bill_id_str or not bill_id_str.isdigit():
logger.critical(
"Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'", TrsId, bill_id_str
)
return "OK"
# Find bill in database
bill = await deps.bills_repository.get_bill_by_id(session, int(bill_id_str))
if not bill:
logger.critical("Bill %s not found in database for TrsId %s", bill_id_str, TrsId)
return "OK"
# Check if already processed
if bill.status != DepositStatus.PENDING:
logger.warning(
"Bill %s (TrsId: %s) is already processed with status: %s", bill.id, TrsId, bill.status
)
return "OK"
try:
# Handle fee scenarios: use BalanceAmount if available (net amount after fees),
# otherwise use OutSum (gross amount paid by customer)
if BalanceAmount is not None:
# Customer pays fees - BalanceAmount is the net amount credited to merchant
credited_amount = int(float(BalanceAmount))
gross_amount = int(float(OutSum))
logger.info(
"Customer-pays-fees payment: bill_id=%s, expected=%s, gross_paid=%s, net_credited=%s",
bill.id,
bill.amount,
gross_amount,
credited_amount,
)
# Validate that the net credited amount matches our bill amount
if bill.amount != credited_amount:
logger.error(
"Net amount mismatch for bill %s (TrsId: %s) - Expected: %s, Net credited: %s, Gross paid: %s",
bill.id,
TrsId,
bill.amount,
credited_amount,
gross_amount,
)
return "OK"
amount = credited_amount # Credit the net amount (without fees)
else:
# Standard payment - OutSum should match bill amount exactly
amount = int(float(OutSum))
logger.info(
"Standard payment: bill_id=%s, expected=%s, received=%s",
bill.id,
bill.amount,
amount,
)
if bill.amount != amount:
logger.error(
"Amount mismatch for bill %s (TrsId: %s) - Expected: %s, Received: %s",
bill.id,
TrsId,
bill.amount,
amount,
)
return "OK"
logger.info(
"Processing payment: bill_id=%s, user_id=%s, amount=%s",
bill.id,
bill.creator_id,
amount,
)
# Credit user balance
credited_user = await deps.user_repository.increase_user_balance(
session,
bill.creator_id,
amount=amount,
tx_type=BalanceTxType.DEPOSIT,
description=f"payment via PALLY (TrsId: {TrsId})",
)
await successful_payment_notification(bot, bill.creator_id, amount)
if credited_user:
await notify_deposit(
bot,
user_id=bill.creator_id,
amount=amount,
balance_before=credited_user.balance - amount,
balance_after=credited_user.balance,
bill_id=bill.id,
payment_id=TrsId,
)
# Process referral bonus
user = await deps.user_repository.get_user_by_id(session, bill.creator_id)
referal = user.referal_id if user else None
if referal is not None:
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
await deps.user_repository.increase_user_balance(
session,
referal,
referal_amount,
tx_type=BalanceTxType.REFERRAL_BONUS,
description=f"referral reward for user {bill.creator_id} (TrsId: {TrsId})",
)
await successful_payment_notification(bot, referal, referal_amount)
logger.info(
"Referral bonus processed: referrer_id=%s, amount=%s", referal, referal_amount
)
logger.info("Payment processing completed successfully for TrsId %s", TrsId)
except Exception as e:
logger.exception(
"CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s",
TrsId,
bill.id,
bill.creator_id,
str(e),
)
# Don't return early - still mark as success to prevent retries
# The balance operation might have partially succeeded
# Update bill status to success
await deps.bills_repository.update_bill_status(
session, int(bill.id), status=DepositStatus.SUCCESS
)
logger.info("Bill %s marked as SUCCESS for TrsId %s", bill.id, TrsId)
return "OK"

8
bot/filters/admins.py Normal file
View File

@@ -0,0 +1,8 @@
from aiogram.filters import Filter
from config import settings
class AdminFilter(Filter):
async def __call__(self, event) -> bool:
return event.from_user.id in settings.admins

9
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .admins import routers as admin_routers
from .client import routers as client_routers
from .system import routers as system_routers
__all__ = [
"admin_routers",
"client_routers",
"system_routers",
]

View File

@@ -0,0 +1,4 @@
from .admins import router as general_router
from .ads import router as ads_router
routers = [general_router, ads_router]

View File

@@ -0,0 +1,36 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from bot.filters.admins import AdminFilter
from bot.keyboards.admins import main_menu
from misc.utils import build_adm_main_menu_text
from schemas.di import DependenciesDTO
router = Router()
@router.message(AdminFilter(), Command(commands=["start", "cancel"]))
async def standard_start(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
users = await deps.user_repository.user_count(session)
await msg.answer(
build_adm_main_menu_text(user_count=users, user_id=msg.from_user.id), reply_markup=main_menu
)
@router.callback_query(AdminFilter(), F.data == "menu:main")
async def main_menu_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
users = await deps.user_repository.user_count(session)
await cb.message.edit_text(
build_adm_main_menu_text(user_count=users, user_id=cb.from_user.id), reply_markup=main_menu
)

View File

@@ -0,0 +1,65 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.admins import action_confirmation
from bot.keyboards.common import return_to_menu
from bot.states.admins import AdminStorage
from bot.texts import (
ADM_PROMO_INIT,
ADM_VERIFY_PROMOTION,
CALLBACK_FALLBACK,
GENERAL_PROCESSING,
SUCCESSFULLY_SENT_PROMO,
)
from schemas.di import DependenciesDTO
from schemas.promotions import NewPromotionContext
router = Router()
@router.callback_query(F.data == "promotion")
async def promo_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(ADM_PROMO_INIT, reply_markup=return_to_menu)
ctx = NewPromotionContext(cb.message)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.message(AdminStorage.promotion_msg)
async def promo_text(msg: Message, state: FSMContext):
data = await state.get_data()
await state.clear()
edited_msg = await msg.answer(GENERAL_PROCESSING)
ctx: NewPromotionContext | None = data.get("ctx")
if ctx:
await ctx.msg.delete()
ctx.promotion = msg
await edited_msg.edit_text(ADM_VERIFY_PROMOTION, reply_markup=action_confirmation)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.callback_query(AdminStorage.promotion_msg, F.data.in_(("approve", "decline")))
async def promo_send(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
ctx: NewPromotionContext | None = data.get("ctx")
if not (ctx and ctx.promotion and ctx.msg):
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
return
successful = await deps.user_service.launch_promotion(session, ctx.promotion)
await cb.message.edit_text(
SUCCESSFULLY_SENT_PROMO.format(successful=successful), reply_markup=return_to_menu
)

View File

@@ -1,19 +1,15 @@
from .ads import router as ads_router
from .billing import router as billing_router from .billing import router as billing_router
from .buy import router as buy_router from .buy import router as buy_router
from .common import router as common_router from .deposit import router as deposit_router
from .menus import router as menus_router from .menus import router as menus_router
from .prehandling import router as prehandling_router
from .referals import router as referal_router from .referals import router as referal_router
from .support import router as support_router from .support import router as support_router
routers = [ routers = [
prehandling_router,
referal_router, referal_router,
billing_router, billing_router,
deposit_router,
menus_router, menus_router,
ads_router,
support_router, support_router,
buy_router, buy_router,
common_router,
] ]

View File

@@ -5,11 +5,12 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import pay_url, return_to_menu from bot.keyboards.client import pay_url
from bot.keyboards.common import return_to_menu
from bot.states.buy import BillingStorage
from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
from schemas.billing import BillingContext, BillingProviders from schemas.billing import BillingContext, BillingProviders
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from states.buy import BillingStorage
from texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,8 +30,11 @@ async def pally_init(
await state.clear() await state.clear()
ctx.provider = BillingProviders.PALLY ctx.provider = BillingProviders.PALLY
ctx.amount = int(ctx.amount)
await cb.message.edit_text(BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())) await cb.message.edit_text(
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())
)
bill = await deps.billing_service.pally_initiate_payment( bill = await deps.billing_service.pally_initiate_payment(
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount

View File

@@ -4,32 +4,32 @@ import math
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from bot.keyboards.client import deposit_kb, devices_selector, duration_selector
from keyboards.client import ( from bot.keyboards.common import return_to_menu
devices_selector, from bot.states.buy import SubscriptionStorage
duration_selector, from bot.texts import (
payment_gateways, CALLBACK_FALLBACK,
return_to_menu, CONTEXT_REINITIATED,
INSUFFICIENT_FUNDS,
NOTHING_PLACEHOLDER,
STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
SUCCESSFULLY_CREATED_SUBSCRIPTION,
) )
from config import settings
from db.models.transactions import BalanceTxType
from misc.utils import ( from misc.utils import (
calculate_price, calculate_price,
convert_duration_to_int, convert_duration_to_int,
convert_int_to_duration, convert_int_to_duration,
get_discount, get_discount,
) )
from schemas.billing import BillingContext from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan from schemas.subscriptions import SubscriptionPlan
from states.buy import BillingStorage, SubscriptionStorage from services.admin_notifications import notify_subscription_purchase
from texts import (
CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE,
CONTEXT_REINITIATED,
NOTHING_PLACEHOLDER,
STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
)
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -157,7 +157,9 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") @router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext): async def proceed_to_checkout(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data() data = await state.get_data()
await state.clear() await state.clear()
@@ -166,11 +168,74 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return return
await cb.message.edit_text( amount = calculate_price(ctx)
CHECKOUT_PAYMENT_CHOICE, user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
reply_markup=payment_gateways,
) # FIXME: temp solution that's incredibly stupid
await state.set_state(BillingStorage.pending) credit = await deps.user_service.calculate_plan_credit(
billing_context = BillingContext(amount=calculate_price(ctx)) session, cb.from_user.id, ctx, deps.rw_sdk
await state.set_data({"ctx": billing_context}) )
effective_balance = user.balance + credit
if effective_balance < amount:
await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(
balance=user.balance, amount=amount, diff=abs(amount - user.balance)
),
reply_markup=deposit_kb,
)
return
if not user:
await cb.message.edit_text(CALLBACK_FALLBACK)
return
try:
await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user)
except Exception:
logger.exception(
"buy_subscription caught an exception on user_id=%s, amount=%s. balance was NOT affected, transaction is rejected.",
user.id,
amount,
)
await cb.message.edit_text(CALLBACK_FALLBACK)
return
if credit > 0:
await deps.user_repository.increase_user_balance(
session,
user.id,
credit,
tx_type=BalanceTxType.REFUND,
description="refund for unused subscription days",
)
deducted_user = await deps.user_repository.decrease_user_balance(
session,
user.id,
amount,
BalanceTxType.PURCHASE,
description=f"subscription purchase: devices={ctx.devices}, duration={ctx.duration}, whitelists={ctx.whitelists}",
)
if not deducted_user:
logger.critical(
"RECONCILIATION NEEDED: subscription created for user_id=%s "
"but balance deduction FAILED. amount=%s.",
cb.from_user.id,
amount,
)
if deducted_user:
await notify_subscription_purchase(
cb.bot,
user_id=user.id,
username=cb.from_user.username,
amount=amount,
balance_before=deducted_user.balance + amount,
balance_after=deducted_user.balance,
devices=ctx.devices,
duration_days=convert_duration_to_int(ctx.duration) * 30,
whitelists=ctx.whitelists,
credit=credit,
)
await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)

View File

@@ -0,0 +1,58 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot.keyboards.client import payment_gateways
from bot.keyboards.common import return_to_menu
from bot.states.buy import BillingStorage
from bot.states.deposit import DepositStorage
from bot.texts import (
CHECKOUT_PAYMENT_CHOICE,
DEPOSIT_AMOUNT,
DEPOSIT_AMOUNT_BELOW_MINIMAL,
DEPOSIT_AMOUNT_FALLBACK,
)
from config import settings
from schemas.billing import BillingContext
from schemas.common import GeneralMessageContext
router = Router()
@router.callback_query(F.data == "deposit")
async def deposit_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(DEPOSIT_AMOUNT, reply_markup=return_to_menu)
ctx = GeneralMessageContext(msg=cb.message)
await state.set_state(DepositStorage.amount)
await state.set_data({"ctx": ctx})
@router.message(DepositStorage.amount)
async def deposit_amount(msg: Message, state: FSMContext):
data = await state.get_data()
await state.clear()
await msg.delete()
ctx: GeneralMessageContext | None = data.get("ctx")
if not isinstance(ctx, GeneralMessageContext) or not msg.text.isdigit():
await msg.answer(DEPOSIT_AMOUNT_FALLBACK, reply_markup=return_to_menu)
await state.set_state(DepositStorage.amount)
await state.set_data(data)
return
if int(msg.text) < settings.deposit_threshold:
await ctx.msg.edit_text(
DEPOSIT_AMOUNT_BELOW_MINIMAL.format(threshold=settings.deposit_threshold),
reply_markup=return_to_menu,
)
await state.set_state(DepositStorage.amount)
await state.set_data(data)
return
await ctx.msg.edit_text(CHECKOUT_PAYMENT_CHOICE, reply_markup=payment_gateways)
await state.set_state(BillingStorage.pending)
billing_context = BillingContext(amount=int(msg.text))
await state.set_data({"ctx": billing_context})

View File

@@ -0,0 +1,280 @@
from aiogram import F, Router
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import (
close_kb,
construct_devices_list,
main_menu,
prompt_hwid_deletion_kb,
subscription_controls,
)
from bot.keyboards.common import return_to_menu
from bot.texts import (
CALLBACK_FALLBACK,
FAQ_TEXT,
GENERAL_PROCESSING,
HWID_LIST,
NO_CONNECTIONS,
NO_SUBSCRIPTION,
PROMPT_HWID_DELETION,
STANDARD_FALLBACK,
SUCCESSFUL_HWID_DELETION,
)
from db.models.users import User
from misc import utils
from misc.utils import (
build_main_menu_text,
convert_duration_to_int,
format_subscription_link,
)
from schemas.di import DependenciesDTO
from schemas.subscriptions import (
InternalSquads,
SubscriptionDuration,
SubscriptionPlan,
SubscriptionStatus,
)
from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id
router = Router()
@router.message(CommandStart(deep_link=True, deep_link_encoded=True))
async def fetch_referal(
msg: Message,
command: CommandObject,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
data = command.args.split("_")
if not data:
await standard_start(msg, state, session, deps)
return
referal = int(data[1])
user = await deps.user_service.add_user(
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
)
await msg.answer(
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@router.message(Command(commands=["start", "cancel"]))
async def standard_start(
msg: Message,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
await msg.answer(
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@router.callback_query(F.data == "menu:main")
async def main_menu_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
await cb.message.edit_text(
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@router.callback_query(F.data == "update_link")
async def update_link(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING)
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.answer("")
await cb.message.edit_text(
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@router.callback_query(F.data == "faq")
async def faq(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(FAQ_TEXT, reply_markup=return_to_menu)
@router.callback_query(F.data == "sub_info")
async def sub_info(
cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO, session: AsyncSession
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
user: User | None = await deps.user_service.update_user_link(
session, cb.from_user.id, deps.rw_sdk
)
sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id)
if not rw_user:
await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu)
return
if not user:
await cb.answer(CALLBACK_FALLBACK)
return
dto = SubscriptionPlan(
devices=max(rw_user.hwid_device_limit, 3),
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
)
if rw_user and not sub:
try:
sub = await deps.subscriptions_repository.create_subscription(
session,
user_id=user.id,
end_date=rw_user.expire_at,
devices=dto.devices,
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
whitelists=dto.whitelists,
plan_price_paid=utils.calculate_price(dto),
)
except Exception:
await cb.answer(CALLBACK_FALLBACK)
return
else:
await deps.subscriptions_repository.update_sub(
session,
cb.from_user.id,
status=(
SubscriptionStatus.ACTIVE
if rw_user.status == "ACTIVE"
else SubscriptionStatus.EXPIRED
),
end_date=rw_user.expire_at,
devices=dto.devices,
whitelists=dto.whitelists,
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
plan_price_paid=utils.calculate_price(dto),
autorenew=sub.autorenew
)
await cb.message.edit_text(
deps.user_service.format_subscription_message(
rw_user, link=user.subscription_link, autorenew=sub.autorenew
),
reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
)
@router.callback_query(F.data == "hwid_control")
async def hwid_control(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
if not rw_user:
await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu)
return
devices = await get_hwid_list(deps.rw_sdk, rw_user.uuid)
if not devices:
await cb.message.edit_text(NO_CONNECTIONS, reply_markup=return_to_menu)
return
hwid_limit = max(rw_user.hwid_device_limit, 3)
await cb.message.edit_text(
text=HWID_LIST.format(count=len(devices), limit=hwid_limit),
reply_markup=construct_devices_list(devices),
)
@router.callback_query(F.data.startswith("hwid:"))
async def prompt_delete_hwid(cb: CallbackQuery, state: FSMContext):
await state.clear()
hwid = cb.data.split(":")[1]
await cb.message.edit_text(PROMPT_HWID_DELETION, reply_markup=prompt_hwid_deletion_kb(hwid))
@router.callback_query(F.data.startswith("delete_hwid:"))
async def delete_hwid_bot(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
await state.clear()
hwid = cb.data.split(":")[1]
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
if not rw_user:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
status = await delete_hwid(deps.rw_sdk, rw_user.uuid, hwid)
if status:
await cb.message.edit_text(SUCCESSFUL_HWID_DELETION, reply_markup=return_to_menu)
else:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
@router.callback_query(F.data == "toggle:autorenewal")
async def toggle_autorenewal(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING)
sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id)
if not sub:
await cb.answer(CALLBACK_FALLBACK)
return
new_autorenew = not sub.autorenew
sub = await deps.subscriptions_repository.update_subscription_autorenew(
session, sub.user_id, new_autorenew
)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
user: User | None = await deps.user_service.update_user_link(
session, cb.from_user.id, deps.rw_sdk
)
status_text = "включено" if new_autorenew else "отключено"
await cb.answer(f"Автопродление {status_text}")
await cb.message.edit_text(
deps.user_service.format_subscription_message(
rw_user, link=user.subscription_link, autorenew=sub.autorenew
),
reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
)

View File

@@ -4,12 +4,10 @@ from aiogram.types import CallbackQuery
from aiogram.utils.deep_linking import create_start_link from aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import referals_kb from bot.keyboards.client import referals_kb
from bot.texts import REFERALS_MENU
from misc.utils import format_share_deep_link from misc.utils import format_share_deep_link
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from texts import (
REFERALS_MENU,
)
router = Router() router = Router()

View File

@@ -5,8 +5,8 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from keyboards.client import support_url_kb from bot.keyboards.client import support_url_kb
from texts import SUPPORT_INIT from bot.texts import SUPPORT_INIT
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,4 @@
from .common import router as common_router
from .prehandling import router as prehandling_router
routers = [prehandling_router, common_router]

View File

@@ -2,7 +2,7 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from texts import UNDEFINED_MESSAGE from bot.texts import UNDEFINED_MESSAGE
router = Router() router = Router()

View File

16
bot/keyboards/admins.py Normal file
View File

@@ -0,0 +1,16 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="📢 Рассылка", callback_data="promotion")],
# [InlineKeyboardButton(text="👤 Управление пользователем", callback_data="usermgmt")],
]
).as_markup()
action_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="", callback_data="approve")],
[InlineKeyboardButton(text="", callback_data="decline")],
]
).as_markup()

View File

@@ -1,32 +1,32 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from remnawave.models.hwid import HwidDeviceDto
from config import settings from config import settings
from misc.utils import convert_duration_to_int from misc.utils import convert_duration_to_int, format_hwid_device
from schemas.subscriptions import SubscriptionDuration from schemas.subscriptions import SubscriptionDuration
from .common import _return_to_menu_btn
main_menu = InlineKeyboardBuilder( main_menu = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")], [InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
[ [
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"), InlineKeyboardButton(text="🛒 Купить", callback_data="buy"),
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"), InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit"),
], ],
[ [
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"), InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"), InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
], ],
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")], [
InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link"),
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
],
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")], [InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
] ]
).as_markup() ).as_markup()
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder( payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")], [InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")],
@@ -46,6 +46,30 @@ support_url_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
] ]
).as_markup() ).as_markup()
deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit")], [_return_to_menu_btn]]
).as_markup()
def subscription_controls(
show_autorenew: bool, current_autorenewal_status: bool | None = None
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if show_autorenew:
autorenewal_text = (
"🟢 Включить автопродление"
if not current_autorenewal_status
else "🔴 Отключить автопродление"
)
builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal"))
builder.row(
InlineKeyboardButton(text="💻 Управление устройствами", callback_data="hwid_control")
)
builder.row(_return_to_menu_btn)
return builder.as_markup()
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton: def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data) return InlineKeyboardButton(text=text, callback_data=data)
@@ -123,3 +147,25 @@ def pay_url(url: str) -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="", callback_data="menu:main")], [InlineKeyboardButton(text="", callback_data="menu:main")],
] ]
).as_markup() ).as_markup()
def construct_devices_list(devices: list[HwidDeviceDto]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for device in devices:
builder.row(
InlineKeyboardButton(
text=format_hwid_device(device), callback_data=f"hwid:{device.hwid}"
)
)
builder.row(_return_btn(data="sub_info"))
return builder.as_markup()
def prompt_hwid_deletion_kb(hwid: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"delete_hwid:{hwid}")],
[_return_btn("hwid_control", text="❌ Отмена")],
]
).as_markup()

8
bot/keyboards/common.py Normal file
View File

@@ -0,0 +1,8 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import contextlib
import logging import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
@@ -6,29 +7,34 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from bot.handlers import admin_routers, client_routers, system_routers
from bot.middlewares import DIMiddleware
from bot.scheduler import start_scheduler
from config import settings from config import settings
from db.session import async_session from db.session import async_session
from handlers import routers
from middlewares import DIMiddleware
from misc.pally import PallyClient from misc.pally import PallyClient
from repositories.bills import BillsRepository from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from services.billing_service import BillingService from services.billing_service import BillingService
from services.renewal_service import RenewalService
from services.user_service import UserService from services.user_service import UserService
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
contextlib.suppress(asyncio.CancelledError)
async def main(): async def main():
dp = Dispatcher() dp = Dispatcher()
pally_client = PallyClient(settings.pally_token) pally_client = PallyClient(settings.pally_token, payer_pays_commission=True)
user_repository = UserRepository() user_repository = UserRepository()
user_service = UserService(user_repository)
bills_repository = BillsRepository() bills_repository = BillsRepository()
subscriptions_repository = SubscriptionRepository()
user_service = UserService(user_repository, subscription_repository=subscriptions_repository)
billing_service = BillingService( billing_service = BillingService(
user_repository=user_repository, bills_repository=bills_repository user_repository=user_repository, bills_repository=bills_repository
) )
@@ -38,6 +44,17 @@ async def main():
token=settings.remnawave_token, token=settings.remnawave_token,
) )
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
renewal_service = RenewalService(
subscription_repository=subscriptions_repository,
user_repository=user_repository,
rw_sdk=rw_sdk,
bot=bot,
)
deps = DependenciesDTO( deps = DependenciesDTO(
user_repository=user_repository, user_repository=user_repository,
user_service=user_service, user_service=user_service,
@@ -45,17 +62,25 @@ async def main():
pally_client=pally_client, pally_client=pally_client,
bills_repository=bills_repository, bills_repository=bills_repository,
billing_service=billing_service, billing_service=billing_service,
subscriptions_repository=subscriptions_repository,
renewal_service=renewal_service,
) )
dp.update.middleware.register(DIMiddleware(deps, async_session)) dp.update.middleware.register(DIMiddleware(deps, async_session))
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None for r in admin_routers:
default = DefaultBotProperties(parse_mode="HTML") dp.include_router(r)
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session) for r in client_routers:
dp.include_router(r)
for r in system_routers:
dp.include_router(r)
for router in routers: scheduler_task = asyncio.create_task(start_scheduler(renewal_service, async_session))
dp.include_router(router)
try:
await dp.start_polling(bot) await dp.start_polling(bot)
finally:
scheduler_task.cancel()
await scheduler_task
if __name__ == "__main__": if __name__ == "__main__":

29
bot/scheduler.py Normal file
View File

@@ -0,0 +1,29 @@
import asyncio
import logging
from sqlalchemy.ext.asyncio import async_sessionmaker
from services.renewal_service import RenewalService
logger = logging.getLogger(__name__)
async def run_autorenewal(renewal_service: RenewalService, session_factory: async_sessionmaker):
async with session_factory() as session:
try:
results = await renewal_service.process_autorenewals(session)
logger.info("Autorenewal job completed: %s", results)
except Exception:
logger.exception("Autorenewal job failed")
await session.rollback()
async def start_scheduler(
renewal_service: RenewalService,
session_factory: async_sessionmaker,
interval_hours: int = 24,
):
logger.info("Starting autorenewal scheduler with interval %dh", interval_hours)
while True:
await run_autorenewal(renewal_service, session_factory)
await asyncio.sleep(interval_hours * 3600)

0
bot/states/__init__.py Normal file
View File

5
bot/states/deposit.py Normal file
View File

@@ -0,0 +1,5 @@
from aiogram.fsm.state import State, StatesGroup
class DepositStorage(StatesGroup):
amount = State()

270
bot/texts.py Normal file
View File

@@ -0,0 +1,270 @@
# ============================================================
# PREMIUM EMOJI
# ============================================================
class PremiumEmoji:
def __init__(self, emoji_id: int, emoji: str):
self.emoji_id = emoji_id
self.emoji = emoji
def __str__(self) -> str:
return f'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-emoji>'
# — Иконки бота
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
MALENIA_SHIELD = PremiumEmoji(5444924740596702937, "☀️")
MALENIA_SILHOUETTE = PremiumEmoji(5447117295631506779, "☀️")
MALENIA_CARD = PremiumEmoji(5447145526451543197, "☀️")
MALENIA_QUESTION = PremiumEmoji(5276157346479903928, "❤️")
MALENIA_CHAT = PremiumEmoji(5445391895599554146, "☀️")
MALENIA_CHECK = PremiumEmoji(5447446814112389179, "☀️")
MALENIA_CROSS = PremiumEmoji(5444977405485687021, "☀️")
MALENIA_WARN = PremiumEmoji(5447571325214303934, "☀️")
MALENIA_BANK = PremiumEmoji(5447344860178717542, "☀️")
# ============================================================
# ОБЩИЕ БЛОКИ
# ============================================================
_DIVIDER = "────────────────────────────\n\n"
# ============================================================
# СТАТУСЫ ПОДПИСКИ
# ============================================================
# — Иконки статусов
STATUS_ICON_ACTIVE = "🟢"
STATUS_ICON_DISABLED = ""
STATUS_ICON_EXPIRED = "🔴"
STATUS_ICON_LIMITED = "🟠"
STATUS_ICON_UNKNOWN = ""
# — Тексты статусов
STATUS_ACTIVE = "активна"
STATUS_DISABLED = "отключена"
STATUS_EXPIRED = "истёк"
STATUS_LIMITED = "трафик ограничен"
STATUS_UNKNOWN = "неизвестный статус"
# ============================================================
# ФОРМАТИРОВАНИЕ ДАННЫХ
# ============================================================
# — Дни до истечения подписки
DAYS_LEFT_POSITIVE = "{days} дн."
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
DAYS_LEFT_TODAY = "подходит к концу сегодня"
# — Username
NO_USERNAME = "отсутствует"
USERNAME_DISPLAY = "@{username}"
# — Internal Squads
NO_SQUADS = "отсутствуют"
# — HWID лимит
UNLIMITED_HWID = "без лимитов"
# — OS эмодзи
WINDOWS = "💻"
LINUX = "🐧"
ANDROID = "💚"
APPLE = "🍎"
UNKNOWN_OS = "👤"
# — Трафик
NO_TRAFFIC_LIMIT = "не ограничен"
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
TRAFFIC_NO_LIMIT = "{used}"
# — Прочее
NOTHING_PLACEHOLDER = ""
GENERAL_SUCCESS = ""
# ============================================================
# ГЛАВНОЕ МЕНЮ
# ============================================================
MAIN_MENU = (
str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_CARD} Личный кабинет</i>\n"
+ f"<b>{MALENIA_COINS} Ваш баланс:</b> <code>{{balance}}₽</code>\n"
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
)
# ============================================================
# FAQ
# ============================================================
FAQ_TEXT = (
f"<b>{MALENIA_HEART} Ваши частые вопросы:</b>\n\n"
f"<b>{MALENIA_QUESTION} — Как продлить подписку?</b>\n"
f"{MALENIA_CHAT} — Подписка автоматически продляется <b>из бота</b>. Просто пополните баланс на нужную сумму и мы сами продлим вашу подписку. Вы можете выключить автопродление и продлять вручную, если захотите.\n\n"
f"<b>{MALENIA_QUESTION} — Как изменить количество устройств / опцию Обхода Белых Списков и др.?</b>\n"
f'{MALENIA_CHAT} — Вы можете это сделать в кнопке "Купить". Ваша подписка обновится на новый срок. Подробнее про механику продления вы можете почитать в <a href="https://telegra.ph/FAQ-Pokupka-podpiski-pri-dejstvuyushchej-podpiske-05-24">нашей статье</a>.\n\n'
f"<b>{MALENIA_QUESTION} — Что делать если VPN не работает?</b>\n"
f'{MALENIA_CHAT} — Пишите <a href="https://t.me/maleniasupportbot">в поддержку</a>. <b>При обращении, не забывайте уточнять регион и оператора</b>, т.к. ограничения разнятся от этих параметров.\n\n'
f"<b>{MALENIA_CHAT} Блиц:</b>\n"
"— Информация о подписке в боте.\n"
"— Трафик безлимитен.\n"
'— Тарифы начинаются от 3-х устройств. Меньше нельзя. Хотите больше 12? Вам <a href="https://t.me/maleniasupportbot">в поддержку</a>.\n'
)
# ============================================================
# СОСТОЯНИЯ И ОБРАБОТКА
# ============================================================
CALLBACK_PROCESSING = ""
GENERAL_PROCESSING = "<i>⏳ Подождите...</i>"
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
# ============================================================
# ОШИБКИ И FALLBACK
# ============================================================
STANDARD_FALLBACK = (
f"<b>{MALENIA_CROSS} Ошибка.</b> Прожмите /start. Если не поможет то скоро починим."
)
CALLBACK_FALLBACK = "❌ Что-то пошло не так. Повторите попытку или обратитесь в поддержку."
UNDEFINED_MESSAGE = f"{MALENIA_CROSS} Таких команд у нас нет.\n\n<i>Ищете техподдержку? Вам в /support</i>\n<i>Потерялись? Вам в /start.</i>"
# ============================================================
# ИНФОРМАЦИЯ О ПОДПИСКЕ
# ============================================================
SUBSCRIPTION_INFORMATION = (
"<b>📊 Данные о подписке</b>\n\n"
f"{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code>\n\n"
"{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Истекает:</b> {expire_date}\n"
+ "⏳ <b>Осталось дней:</b> {days_left}\n"
+ "📊 <b>Израсходовано трафика:</b> {used_traffic}\n"
+ "📱 <b>Количество устройств:</b> {hwid_limit}\n"
+ "<b>💸 Автопродление:</b> {autorenew}"
)
NO_SUBSCRIPTION = f"{MALENIA_WARN} <b>Подписка не найдена.</b>"
NO_CONNECTIONS = f"{MALENIA_SHIELD} <b>У вас нет подключённых устройств.</b>"
HWID_LIST = f"{MALENIA_SHIELD} <b>Ваши устройства ({{count}} / {{limit}}):</b>"
PROMPT_HWID_DELETION = f"{MALENIA_WARN} <b>Вы уверены что хотите удалить это устройство?</b>"
SUCCESSFUL_HWID_DELETION = f"{MALENIA_HEART} <b>Устройство успешно удалено.</b>"
# ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
# ============================================================
REFERALS_MENU = (
"<b>👥 Реферальная система</b>\n\n"
"Делитесь с друзьями и получайте деньги на оплату своей подписки.\n"
f"<b>{MALENIA_COINS} На вашем счету:</b> "
"<code>{balance}₽</code>\n"
f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> "
"<code>{link}</code>"
)
# ============================================================
# ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = (
f"<b>{MALENIA_QUESTION} Сколько устройств вы хотите подключить?</b>\n\nЦена: <b>{{price}}₽</b>"
)
SUBSCRIPTION_DURATION_SELECTOR = f"<b>{MALENIA_QUESTION} На какой срок хотите оформить подписку?</b>\n\nИтого: <b>{{price}}₽</b> <i>(скидка: {{discount}}%)</i>"
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = (
f"<b>{MALENIA_BANK} Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
)
# ============================================================
# БИЛЛИНГ
# ============================================================
_BILL_TEMPLATE = (
f"<b>{MALENIA_CHECK} Счёт на {{amount}}₽</b>\n\n"
"<i>‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.</i>\n"
f"<b>{MALENIA_CARD} Способ оплаты:</b> <i>{{provider}}</i>\n\n"
)
_BILL_LINK = f"<b>{MALENIA_LINK} Ссылка на оплату:</b>"
BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
INSUFFICIENT_FUNDS = f"<b>{MALENIA_CROSS} Недостаточно средств для проведения операции.</b>\n\n<b>💰 Текущий баланс:</b> <code>{{balance}}₽</code>\n<b>💸 Требуется:</b> <code>{{amount}}₽</code>\n\n<i>{MALENIA_CARD} Пополните баланс на <code>{{diff}}₽</code></i>"
SUCCESSFULLY_CREATED_SUBSCRIPTION = f"<b>{MALENIA_HEART} Ваша подписка успешно оплачена!</b> Ссылка на добавления доступна в главном меню."
DEPOSIT_AMOUNT = f"<b>{MALENIA_COINS} Укажите сумму пополнения:</b>"
DEPOSIT_AMOUNT_FALLBACK = (
f"<b>{MALENIA_CROSS} Сумма пополнения указана неверно, повторите попытку.</b>"
)
DEPOSIT_AMOUNT_BELOW_MINIMAL = (
f"<b>{MALENIA_CROSS} Сумма пополнения ниже минимальной - <code>{{threshold}}₽</code>.</b>"
)
# ============================================================
# АВТОПРОДЛЕНИЕ
# ============================================================
AUTORENEWAL_INSUFFICIENT_FUNDS = (
f"<b>{MALENIA_WARN} Не удалось продлить подписку автоматически.</b>\n"
"Недостаточно средств на балансе (нужно <code>{price}₽</code>, <code>доступно {balance}₽</code>). Автопродление отключено."
)
AUTORENEWAL_SUCCESS = (
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
"Новая дата окончания: <code>{end_date}</code>."
)
# ============================================================
# ПОДДЕРЖКА
# ============================================================
SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здесь. Кнопка ниже."
# ============================================================
# АДМИН-ПАНЕЛЬ
# ============================================================
ADM_MAIN_MENU = (
f"<b>{MALENIA_SHIELD} Malenia. Админ-панель.</b>\n"
"<i>— {quote}</i>\n\n"
f"<b>{MALENIA_SILHOUETTE} Пользователей в боте:</b> <code>{{user_count}}</code>\n"
f"<b>{MALENIA_CARD} Ваш ID:</b> <code>{{user_id}}</code>\n"
)
ADM_PROMO_INIT = "<b>📢 Введите/перешлите текст рассылки:</b>"
ADM_VERIFY_PROMOTION = (
"<b>❓ Отправить рассылку? <i>Это действие будет невозможно отменить.</i></b>"
)
SUCCESSFULLY_SENT_PROMO = "<b>💚 Успешно отправлена рассылка.</b>\n<i>⚡ Успешных отправок: <code>{successful}</code>.</i>"

View File

@@ -6,24 +6,40 @@ load_dotenv(override=True)
class Settings(BaseSettings): class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field( postgres_url: str = Field(
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL" "postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL"
) )
bot_token: str = Field(alias="BOT_TOKEN")
admins: list[int] = Field(alias="ADMINS")
admin_log_chat_id: int | None = Field(None, alias="ADMIN_LOG_CHAT_ID")
admin_log_deposit_topic_id: int | None = Field(None, alias="ADMIN_LOG_DEPOSIT_TOPIC_ID")
admin_log_subscriptions_topic_id: int | None = Field(
None, alias="ADMIN_LOG_SUBSCRIPTIONS_TOPIC_ID"
)
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN")
referal_bonus: int = Field(10, alias="REFERAL_BONUS")
deposit_threshold: int = Field(50)
proxy: str | None = Field(None, alias="PROXY") proxy: str | None = Field(None, alias="PROXY")
remnawave_url: str = Field(alias="REMNAWAVE_URL") remnawave_url: str = Field(alias="REMNAWAVE_URL")
subscription_url: str = Field(alias="SUBSCRIPTION_URL") subscription_url: str = Field(alias="SUBSCRIPTION_URL")
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN") remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN")
min_devices: int = Field(3, alias="MIN_DEVICES") min_devices: int = Field(3, alias="MIN_DEVICES")
max_devices: int = Field(12, alias="MAX_DEVICES") max_devices: int = Field(12, alias="MAX_DEVICES")
per_device_cost: int = Field(50, alias="PER_DEVICE_COST") per_device_cost: int = Field(50, alias="PER_DEVICE_COST")
whitelist_cost: int = Field(100, alias="WHITELIST_COST") whitelist_cost: int = Field(100, alias="WHITELIST_COST")
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD") whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
general_squad: str = Field("2de0b5ea-49b9-4181-916f-ae67b9c83b80")
whitelist_squad: str = Field("e4435baa-64d3-4b29-a18e-81ad1c60d977")
autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
settings = Settings() # type: ignore settings = Settings() # type: ignore

View File

@@ -1,4 +1,6 @@
from .bills import Bill from .bills import Bill
from .subscriptions import Subscription
from .transactions import BalanceTransaction
from .users import User from .users import User
__all__ = ["Bill", "User"] __all__ = ["BalanceTransaction", "Bill", "Subscription", "User"]

View File

@@ -1,15 +1,22 @@
from enum import StrEnum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base from db.base import Base
from misc.pally import BillStatus
if TYPE_CHECKING: if TYPE_CHECKING:
from db.models.users import User from db.models.users import User
class DepositStatus(StrEnum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
EXPIRED = "expired"
class Bill(Base): class Bill(Base):
__tablename__ = "bills" __tablename__ = "bills"
@@ -18,8 +25,10 @@ class Bill(Base):
) )
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False) creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
amount: Mapped[int] = mapped_column(INTEGER, nullable=False) amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
status: Mapped[BillStatus] = mapped_column( status: Mapped[DepositStatus] = mapped_column(
Enum(BillStatus), nullable=False, default=BillStatus.NEW Enum(DepositStatus, name="depositstatus", values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=DepositStatus.PENDING,
) )
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(

View File

@@ -0,0 +1,35 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import BOOLEAN, INTEGER, Boolean, DateTime, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
from schemas.subscriptions import SubscriptionStatus
if TYPE_CHECKING:
from db.models.users import User
class Subscription(Base):
__tablename__ = "subscriptions"
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
autorenew: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[SubscriptionStatus] = mapped_column(
Enum(
SubscriptionStatus,
name="subscriptionstatus",
values_callable=lambda x: [e.value for e in x],
),
default=SubscriptionStatus.EXPIRED,
)
devices: Mapped[int] = mapped_column(INTEGER, nullable=False, default=3)
whitelists: Mapped[bool] = mapped_column(BOOLEAN, nullable=False, default=False)
plan_price_paid: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)
duration_days: Mapped[int] = mapped_column(INTEGER, nullable=False, default=30)
user: Mapped["User"] = relationship(back_populates="subscription")

42
db/models/transactions.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, TEXT, DateTime, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
if TYPE_CHECKING:
from db.models.users import User
class BalanceTxType(StrEnum):
DEPOSIT = "deposit"
PURCHASE = "purchase"
REFUND = "refund"
REFERRAL_BONUS = "referral"
MANUAL = "manual"
AUTORENEW = "autorenew"
class BalanceTransaction(Base):
__tablename__ = "balance_transactions"
id: Mapped[int] = mapped_column(BIGINT, autoincrement=True, primary_key=True)
user_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False, index=True)
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
tx_type: Mapped[BalanceTxType] = mapped_column(
Enum(BalanceTxType, name="balancetxtype", values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
balance_before: Mapped[int] = mapped_column(INTEGER, nullable=False)
balance_after: Mapped[int] = mapped_column(INTEGER, nullable=False)
description: Mapped[str] = mapped_column(TEXT, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now() # noqa: PLW0108
)
user: Mapped["User"] = relationship("User", back_populates="balance_transactions")

View File

@@ -7,6 +7,8 @@ from db.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from db.models.bills import Bill from db.models.bills import Bill
from db.models.subscriptions import Subscription
from db.models.transactions import BalanceTransaction
class User(Base): class User(Base):
@@ -22,3 +24,9 @@ class User(Base):
back_populates="user", back_populates="user",
cascade="all, delete", cascade="all, delete",
) )
subscription: Mapped["Subscription"] = relationship(
"Subscription", back_populates="user", cascade="all, delete-orphan"
)
balance_transactions: Mapped[list["BalanceTransaction"]] = relationship(
"BalanceTransaction", back_populates="user", cascade="all, delete"
)

View File

@@ -1,15 +1,15 @@
services: services:
postgres: postgres:
env_file: .env
image: postgres:16-alpine image: postgres:16-alpine
container_name: maleniabot_db container_name: maleniabot_db
environment: environment:
TZ: UTC
POSTGRES_USER: ${POSTGRES_USER:-malenia} POSTGRES_USER: ${POSTGRES_USER:-malenia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
POSTGRES_DB: ${POSTGRES_DB:-malenia_db} POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck: healthcheck:
test: test:
[ [
@@ -21,14 +21,17 @@ services:
retries: 5 retries: 5
bot: bot:
env_file: .env
build: build:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: maleniabot_app container_name: maleniabot_bot
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN} BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db} POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-} PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL} REMNAWAVE_URL: ${REMNAWAVE_URL}
@@ -43,5 +46,32 @@ services:
PALLY_SHOP_ID: ${PALLY_SHOP_ID} PALLY_SHOP_ID: ${PALLY_SHOP_ID}
restart: unless-stopped restart: unless-stopped
api:
build:
dockerfile: Dockerfile.api
container_name: maleniabot_api
depends_on:
postgres:
condition: service_healthy
environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL}
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL}
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN}
MIN_DEVICES: ${MIN_DEVICES:-3}
MAX_DEVICES: ${MAX_DEVICES:-12}
PER_DEVICE_COST: ${PER_DEVICE_COST:-50}
WHITELIST_COST: ${WHITELIST_COST:-100}
WHITELIST_DEVICE_THRESHOLD: ${WHITELIST_DEVICE_THRESHOLD:-6}
PALLY_TOKEN: ${PALLY_TOKEN}
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
ports:
- "127.0.0.1:8000:8000"
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:

5
entrypoints/api.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
echo "[+] Starting API server..."
python -m uvicorn api.main:app --host 0.0.0.0 --port 8000

View File

@@ -6,17 +6,17 @@ echo
ruff check --fix ruff check --fix
black . black .
echo "[+] checking psql availability" # echo "[+] checking psql availability"
if ! pg_isready -h localhost -p 5432 ; then # if ! pg_isready -h localhost -p 5432 ; then
echo "psql is down." >&2 # echo "psql is down." >&2
exit 1 # exit 1
fi # fi
echo "[+] psql is available!" # echo "[+] psql is available!"
echo # echo
echo "[+] Running migrations..." echo "[+] Running migrations..."
echo echo
alembic upgrade head alembic upgrade head
echo "[+] Starting bot..." echo "[+] Starting bot..."
python main.py python -m bot.main

2
entrypoints/startup.sh Normal file → Executable file
View File

@@ -6,4 +6,4 @@ echo
alembic upgrade head alembic upgrade head
echo "[+] Starting bot..." echo "[+] Starting bot..."
python main.py python -m bot.main

View File

@@ -1,5 +0,0 @@
from aiogram import Router
router = Router()
# FIXME: Refine newsletter system

View File

@@ -1,107 +0,0 @@
from aiogram import F, Router
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import close_kb, main_menu, return_to_menu
from misc.utils import build_main_menu_text, format_subscription_link
from schemas.di import DependenciesDTO
from services.rw import get_user_by_telegram_id
from texts import FAQ_TEXT, GENERAL_PROCESSING
router = Router()
@router.message(CommandStart(deep_link=True, deep_link_encoded=True))
async def fetch_referal(
msg: Message,
command: CommandObject,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
data = command.args.split("_")
if not data:
await standard_start(msg, command, state)
return
referal = int(data[1])
user = await deps.user_service.add_user(
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
)
await msg.answer(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.message(Command(commands=["start", "cancel"]))
async def standard_start(
msg: Message,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
await msg.answer(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.callback_query(F.data == "menu:main")
async def main_menu_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.callback_query(F.data == "update_link")
async def update_link(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING)
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.answer("")
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),
reply_markup=main_menu,
)
@router.callback_query(F.data == "faq")
async def faq(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(FAQ_TEXT, reply_markup=return_to_menu)
@router.callback_query(F.data == "sub_info")
async def sub_info(
cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO, session: AsyncSession
):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.message.edit_text(
deps.user_service.format_subscription_message(rw_user, link=user.subscription_link),
reply_markup=return_to_menu,
)

View File

@@ -1,16 +0,0 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
promotion_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="", callback_data="approve")],
[InlineKeyboardButton(text="", callback_data="decline")],
]
).as_markup()
ticket_actions: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="🔒", callback_data="close")],
[InlineKeyboardButton(text="💸 Пополнить баланс реферала", callback_data="refpay")],
]
).as_markup()

View File

@@ -220,6 +220,10 @@ class BaseService:
class BillService(BaseService): class BillService(BaseService):
"""Service to handle all Bill-related operations.""" """Service to handle all Bill-related operations."""
def __init__(self, session: aiohttp.ClientSession, payer_pays_commission: bool | None = None):
super().__init__(session)
self._payer_pays_commission = payer_pays_commission
async def create( async def create(
self, self,
amount: float, amount: float,
@@ -229,9 +233,25 @@ class BillService(BaseService):
bill_type: BillType = BillType.NORMAL, bill_type: BillType = BillType.NORMAL,
currency_in: Currency | None = None, currency_in: Currency | None = None,
ttl: int | None = None, ttl: int | None = None,
payer_pays_commission: bool | None = None,
**kwargs: Any, **kwargs: Any,
) -> BillCreateResponse: ) -> BillCreateResponse:
"""Creates a new bill for payment.""" """
Creates a new bill for payment.
Args:
amount: The payment amount.
shop_id: Unique shop identifier.
payer_pays_commission: If True, the payer pays the commission.
Overrides the client-level default.
"""
# Определяем итоговое значение: переданное в метод или значение по умолчанию из сервиса
final_ppc = (
payer_pays_commission
if payer_pays_commission is not None
else self._payer_pays_commission
)
payload = { payload = {
"amount": amount, "amount": amount,
"shop_id": shop_id, "shop_id": shop_id,
@@ -240,6 +260,7 @@ class BillService(BaseService):
"type": bill_type.value, "type": bill_type.value,
"currency_in": currency_in.value if currency_in else None, "currency_in": currency_in.value if currency_in else None,
"ttl": ttl, "ttl": ttl,
"payer_pays_commission": int(final_ppc) if final_ppc is not None else None,
**kwargs, **kwargs,
} }
return await self._post("bill/create", payload, BillCreateResponse) return await self._post("bill/create", payload, BillCreateResponse)
@@ -326,28 +347,34 @@ class PallyClient:
Main asynchronous client for the Pally API. Main asynchronous client for the Pally API.
Usage: Usage:
async with PallyClient(api_token="your_token") as client: async with PallyClient(api_token="your_token", payer_pays_commission=True) as client:
balance = await client.balance.get() bill = await client.bills.create(amount=100.0, shop_id="my_shop")
print(balance) print(bill.link_url)
""" """
def __init__(self, api_token: str, base_url: str = "https://pal24.pro/api/v1/"): def __init__(
self,
api_token: str,
base_url: str = "https://pal24.pro/api/v1/",
payer_pays_commission: bool | None = None,
):
""" """
Initializes the Pally API client. Initializes the Pally API client.
Args: Args:
api_token (str): The bearer token provided by Pally. api_token (str): The bearer token provided by Pally.
base_url (str): The base URL for the API. base_url (str): The base URL for the API.
payer_pays_commission (bool, optional): Global default for whether the payer pays the commission.
""" """
self.api_token = api_token self.api_token = api_token
self.base_url = base_url self.base_url = base_url
self.payer_pays_commission = payer_pays_commission
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
@property @property
def session(self) -> aiohttp.ClientSession: def session(self) -> aiohttp.ClientSession:
""" """
Lazy-loads the aiohttp ClientSession. Lazy-loads the aiohttp ClientSession.
This prevents creating a session outside of the asyncio event loop.
""" """
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"} headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
@@ -357,10 +384,10 @@ class PallyClient:
) )
return self._session return self._session
# Using properties ensures that services always receive the active session context
@property @property
def bills(self) -> BillService: def bills(self) -> BillService:
return BillService(self.session) # Передаем параметр плательщика комиссии в сервис счетов
return BillService(self.session, self.payer_pays_commission)
@property @property
def payments(self) -> PaymentService: def payments(self) -> PaymentService:
@@ -376,8 +403,6 @@ class PallyClient:
await self._session.close() await self._session.close()
async def __aenter__(self) -> "PallyClient": async def __aenter__(self) -> "PallyClient":
# Accessing the property ensures the session is instantiated
# properly within the async context block.
_ = self.session _ = self.session
return self return self

View File

@@ -3,9 +3,12 @@ import urllib.parse
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from random import choice from random import choice
import texts from remnawave.models.hwid import HwidDeviceDto
from bot import texts
from config import settings from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan from schemas.subscriptions import InternalSquads, SubscriptionDuration, SubscriptionPlan
from services.rw import RWUserInfo
QUOTES: list[str] = [ QUOTES: list[str] = [
"Верните себе свободный интернет.", "Верните себе свободный интернет.",
@@ -40,11 +43,33 @@ QUOTES: list[str] = [
"Весь мир на экране. Без лишних вопросов.", "Весь мир на экране. Без лишних вопросов.",
] ]
ADM_QUOTES: list[str] = [
"Они строят стены. Мы пишем для них двери.",
"За каждым коммитом — чьё-то право на выбор.",
"Цензура — это баг. И мы выкатили патч.",
"Обычный день в админке. Глоток свободы для них.",
"Наш труд невидим. Наше влияние безгранично.",
"Мы не пишем код. Мы держим сеть открытой.",
"Пульт управления свободой. Добро пожаловать.",
"Malenia дышит. Благодаря тебе.",
"Пока они запрещают, мы масштабируем.",
"Тихая работа в бэкенде. Громкие результаты в сети.",
"Миллионы терабайт трафика. Одна общая цель.",
"Инструменты создают люди. Свободу создаешь ты.",
"Никакой магии. Только наш код и упорство.",
"Будущее интернета компилируется прямо здесь.",
"Для кого-то — интерфейс. Для нас — линия фронта.",
]
def fetch_quote() -> str: def fetch_quote() -> str:
return choice(QUOTES) return choice(QUOTES)
def adm_fetch_quote() -> str:
return choice(ADM_QUOTES)
def format_bytes(num_bytes: float) -> str: def format_bytes(num_bytes: float) -> str:
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б.""" """Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
gb_capacity = 1_073_741_824 gb_capacity = 1_073_741_824
@@ -231,5 +256,42 @@ def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid return settings.subscription_url + "/" + short_uuid
def build_main_menu_text(link: str) -> str: def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link) return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance)
def build_adm_main_menu_text(user_count: int, user_id: int):
return texts.ADM_MAIN_MENU.format(
quote=adm_fetch_quote(), user_count=user_count, user_id=user_id
)
def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool:
if rw_user.hwid_device_limit != new_plan.devices:
return False
active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads}
current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids
return current_has_whitelist == new_plan.whitelists
def _format_os_emoji(os: str) -> str:
os = os.lower()
if os == "windows":
return texts.WINDOWS
if os == "linux":
return texts.LINUX
if os == "ios":
return texts.APPLE
if os == "android":
return texts.ANDROID
return texts.UNKNOWN_OS
def format_hwid_device(device: HwidDeviceDto) -> str:
os = device.platform
model = device.device_model
client = device.user_agent.split("/")[0]
emoji = _format_os_emoji(os)
return f"{emoji} {os} | {model} | {client}"

View File

@@ -51,7 +51,8 @@ ignore = [
"PLR0913", "PLR0913",
"RUF001", "RUF001",
"RUF002", "RUF002",
"RUF003" "RUF003",
"B008"
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]

View File

@@ -1,7 +1,7 @@
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models.bills import Bill, BillStatus from db.models.bills import Bill, DepositStatus
class BillsRepository: class BillsRepository:
@@ -17,9 +17,15 @@ class BillsRepository:
*, *,
creator_id: int, creator_id: int,
amount: int, amount: int,
status: BillStatus | None = BillStatus.NEW, status: DepositStatus | None = DepositStatus.PENDING,
) -> Bill: ) -> Bill:
bill = Bill(creator_id=creator_id, amount=amount, status=status) bill = Bill(creator_id=creator_id, amount=amount, status=status)
session.add(bill) session.add(bill)
await session.commit() await session.commit()
return bill return bill
async def update_bill_status(self, session: AsyncSession, bill_id: int, status: DepositStatus):
stmt = update(Bill).where(Bill.id == bill_id).values(status=status).returning(Bill)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one_or_none()

View File

@@ -0,0 +1,105 @@
from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.subscriptions import Subscription, SubscriptionStatus
class SubscriptionRepository:
async def get_subscriptions_for_autorenewal(
self, session: AsyncSession, *, days_before_expiry: int = 1
) -> list[Subscription]:
threshold = datetime.now(UTC) + timedelta(days=days_before_expiry)
stmt = (
select(Subscription)
.where(
Subscription.autorenew == True, # noqa: E712
Subscription.end_date <= threshold,
)
.with_for_update()
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_subscription_by_user_id(
self, session: AsyncSession, user_id: int
) -> Subscription | None:
stmt = select(Subscription).where(Subscription.user_id == user_id)
res = await session.execute(stmt)
return res.scalar_one_or_none()
async def create_subscription(
self,
session: AsyncSession,
*,
user_id: int,
end_date: datetime,
devices: int,
duration_days: int,
plan_price_paid: int,
whitelists: bool,
autorenew: bool = False,
status: SubscriptionStatus = SubscriptionStatus.EXPIRED,
) -> Subscription:
subscription = Subscription(
user_id=user_id,
end_date=end_date,
autorenew=autorenew,
status=status,
devices=devices,
whitelists=whitelists,
duration_days=duration_days,
plan_price_paid=plan_price_paid,
)
session.add(subscription)
await session.commit()
return subscription
async def update_sub(
self,
session: AsyncSession,
user_id: int,
*,
end_date: datetime,
devices: int,
whitelists: bool,
duration_days: int,
plan_price_paid: int,
autorenew: bool,
status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
) -> Subscription | None:
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id)
.values(
end_date=end_date,
autorenew=autorenew,
status=status,
devices=devices,
whitelists=whitelists,
duration_days=duration_days,
plan_price_paid=plan_price_paid,
)
.returning(Subscription)
)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one_or_none()
async def update_subscription_autorenew(
self, session: AsyncSession, user_id: int, autorenew: bool
) -> Subscription | None:
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id)
.values(
autorenew=autorenew,
)
.returning(Subscription)
)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one_or_none()

View File

@@ -0,0 +1,41 @@
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.transactions import BalanceTransaction, BalanceTxType
class BalanceTXRepository:
async def create(
self,
session: AsyncSession,
user_id: int,
amount: int,
tx_type: BalanceTxType,
balance_before: int,
balance_after: int,
description: str,
) -> BalanceTransaction:
created_at = datetime.now(UTC)
tx = BalanceTransaction(
user_id=user_id,
amount=amount,
tx_type=tx_type,
balance_before=balance_before,
balance_after=balance_after,
description=description,
created_at=created_at,
)
session.add(tx)
await session.commit()
return tx
async def get_transactions_by_id(
self, session: AsyncSession, user_id: int, limit: int = 50
) -> list[BalanceTransaction]:
stmt = select(BalanceTransaction).where(BalanceTransaction.user_id == user_id).limit(limit)
res = await session.execute(stmt)
return list(res.scalars().all())

View File

@@ -1,7 +1,11 @@
import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import count
from db.models import User from db.models import User
from db.models.transactions import BalanceTransaction, BalanceTxType
from schemas.users import NewUserDTO from schemas.users import NewUserDTO
@@ -61,12 +65,66 @@ class UserRepository:
return user return user
async def increase_user_balance( async def increase_user_balance(
self, session: AsyncSession, user_id: int, amount: int self,
session: AsyncSession,
user_id: int,
amount: int,
tx_type: BalanceTxType,
description: str,
) -> User | None: ) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id) user = await self.get_user_by_id(session, user_id=user_id)
if not user: if not user:
return None return None
created_at = datetime.datetime.now(datetime.UTC)
transaction = BalanceTransaction(
user_id=user_id,
amount=amount,
tx_type=tx_type,
balance_before=user.balance,
balance_after=user.balance + amount,
description=description,
created_at=created_at,
)
user.balance += amount user.balance += amount
session.add(transaction)
await session.commit() await session.commit()
return user return user
async def decrease_user_balance(
self,
session: AsyncSession,
user_id: int,
amount: int,
tx_type: BalanceTxType,
description: str,
) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user or user.balance < amount:
return None
created_at = datetime.datetime.now(datetime.UTC)
transaction = BalanceTransaction(
user_id=user_id,
amount=amount,
tx_type=tx_type,
balance_before=user.balance,
balance_after=user.balance - amount,
description=description,
created_at=created_at,
)
user.balance -= amount
session.add(transaction)
await session.commit()
return user
async def user_count(self, session: AsyncSession) -> int:
stmt = select(count()).select_from(User)
res = await session.execute(stmt)
return int(res.scalar_one_or_none())

View File

@@ -8,3 +8,6 @@ python-dotenv
aiohttp-socks aiohttp-socks
alembic alembic
remnawave>=2.6.1 remnawave>=2.6.1
fastapi>=0.100.0
uvicorn[standard]>=0.24.0
python-multipart

View File

@@ -4,8 +4,10 @@ from remnawave import RemnawaveSDK
from misc.pally import PallyClient from misc.pally import PallyClient
from repositories.bills import BillsRepository from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from services.billing_service import BillingService from services.billing_service import BillingService
from services.renewal_service import RenewalService
from services.user_service import UserService from services.user_service import UserService
@@ -13,7 +15,17 @@ from services.user_service import UserService
class DependenciesDTO: class DependenciesDTO:
user_repository: UserRepository user_repository: UserRepository
user_service: UserService user_service: UserService
rw_sdk: RemnawaveSDK
bills_repository: BillsRepository bills_repository: BillsRepository
billing_service: BillingService billing_service: BillingService
subscriptions_repository: SubscriptionRepository
pally_client: PallyClient pally_client: PallyClient
rw_sdk: RemnawaveSDK
renewal_service: RenewalService
@dataclass
class APIDependenciesDTO:
user_repository: UserRepository
user_service: UserService
bills_repository: BillsRepository
billing_service: BillingService

View File

@@ -5,5 +5,5 @@ from aiogram.types import Message
@dataclass @dataclass
class NewPromotionContext: class NewPromotionContext:
creator_id: int msg: Message
message: Message | None = None promotion: Message | None = None

View File

@@ -1,4 +1,4 @@
from enum import Enum from enum import Enum, StrEnum
from pydantic import BaseModel from pydantic import BaseModel
@@ -17,3 +17,13 @@ class SubscriptionPlan(BaseModel):
duration: SubscriptionDuration = SubscriptionDuration.MONTH duration: SubscriptionDuration = SubscriptionDuration.MONTH
whitelists: bool = False whitelists: bool = False
discount: float = 1 discount: float = 1
class SubscriptionStatus(StrEnum):
EXPIRED = "expired"
ACTIVE = "active"
class InternalSquads(StrEnum):
WHITELISTS = settings.whitelist_squad
DEFAULT = settings.general_squad

View File

@@ -1,8 +1,9 @@
from dataclasses import dataclass from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class NewUserDTO: class NewUserDTO(BaseModel):
id: int id: int
referal: int | None = None referal: int | None = None
link: str | None = None link: str | None = None

View File

@@ -0,0 +1,111 @@
import logging
from html import escape
from aiogram import Bot
from config import settings
logger = logging.getLogger(__name__)
async def _send_admin_log(bot: Bot, topic_id: int | None, text: str) -> None:
if settings.admin_log_chat_id is None or topic_id is None:
return
try:
await bot.send_message(
settings.admin_log_chat_id,
text,
message_thread_id=topic_id,
disable_web_page_preview=True,
)
except Exception:
logger.warning("Failed to send admin log notification", exc_info=True)
def _format_username(username: str | None) -> str:
if not username:
return "-"
return "@" + escape(username)
async def notify_deposit(
bot: Bot,
*,
user_id: int,
amount: int,
balance_before: int,
balance_after: int,
bill_id: int,
payment_id: str,
) -> None:
await _send_admin_log(
bot,
settings.admin_log_deposit_topic_id,
"\n".join(
[
"<b>Пополнение баланса</b>",
f"Пользователь: <code>{user_id}</code>",
f"Сумма: <b>{amount}₽</b>",
f"Баланс: {balance_before}₽ -> {balance_after}",
f"Bill ID: <code>{bill_id}</code>",
f"Payment ID: <code>{escape(payment_id)}</code>",
]
),
)
async def notify_subscription_purchase(
bot: Bot,
*,
user_id: int,
username: str | None,
amount: int,
balance_before: int,
balance_after: int,
devices: int,
duration_days: int,
whitelists: bool,
credit: int,
) -> None:
lines = [
"<b>Покупка подписки</b>",
f"Пользователь: <code>{user_id}</code> ({_format_username(username)})",
f"Стоимость: <b>{amount}₽</b>",
f"Баланс: {balance_before}₽ -> {balance_after}",
f"Устройства: {devices}",
f"Срок: {duration_days} дн.",
f"Whitelist: {'да' if whitelists else 'нет'}",
]
if credit > 0:
lines.append(f"Зачет неиспользованных дней: {credit}")
await _send_admin_log(bot, settings.admin_log_subscriptions_topic_id, "\n".join(lines))
async def notify_subscription_renewal(
bot: Bot,
*,
user_id: int,
amount: int,
balance_before: int,
balance_after: int,
devices: int,
duration_days: int,
whitelists: bool,
end_date: str,
) -> None:
await _send_admin_log(
bot,
settings.admin_log_subscriptions_topic_id,
(
"<b>Продление подписки</b>\n\n"
f"Пользователь: <code>{user_id}</code>\n"
f"Стоимость: <b>{amount}₽</b>\n"
f"Баланс: {balance_before}₽ -> {balance_after}\n"
f"Устройства: {devices}\n"
f"Срок: {duration_days} дн.\n"
f"Whitelist: {'да' if whitelists else 'нет'}\n"
f"Новая дата окончания: {escape(end_date)}"
),
)

195
services/renewal_service.py Normal file
View File

@@ -0,0 +1,195 @@
import datetime
import logging
from aiogram import Bot
from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import AUTORENEWAL_INSUFFICIENT_FUNDS, AUTORENEWAL_SUCCESS
from config import settings
from db.models.subscriptions import Subscription
from db.models.transactions import BalanceTxType
from misc import utils
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
from services import rw as rw_service
from services.admin_notifications import notify_subscription_renewal
logger = logging.getLogger(__name__)
class RenewalServiceError(Exception):
"""Base exception for renewal service errors."""
class InsufficientBalanceError(RenewalServiceError):
"""Raised when user has insufficient balance for renewal."""
class RenewalService:
def __init__(
self,
subscription_repository: SubscriptionRepository,
user_repository: UserRepository,
rw_sdk: RemnawaveSDK,
bot: Bot,
) -> None:
self.subscription_repository = subscription_repository
self.user_repository = user_repository
self.rw_sdk = rw_sdk
self.bot = bot
async def _calculate_renewal_price(self, subscription: Subscription) -> int:
duration = utils.convert_int_to_duration(subscription.duration_days)
plan = SubscriptionPlan(
devices=subscription.devices,
duration=duration,
whitelists=subscription.whitelists,
)
return utils.calculate_price(plan)
async def _renew_subscription_in_rw(
self, subscription: Subscription, expire_at: datetime.datetime
) -> None:
rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id)
if not rw_user:
raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
squads = [InternalSquads.DEFAULT]
if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS)
await rw_service.update_expire_at(self.rw_sdk, rw_user.uuid, expire_at)
await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads)
await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
async def _notify_user(self, user_id: int, message: str) -> None:
try:
await self.bot.send_message(user_id, message)
except Exception:
logger.warning("Failed to notify user %d about renewal", user_id, exc_info=True)
async def _process_single_renewal(
self, session: AsyncSession, subscription: Subscription
) -> bool:
user_id = subscription.user_id
price = await self._calculate_renewal_price(subscription)
user = await self.user_repository.get_user_by_id(session, user_id)
if not user:
logger.error("User %d not found for autorenewal", user_id)
return False
if user.balance < price:
logger.info(
"Insufficient balance for user %d: balance=%d, price=%d",
user_id,
user.balance,
price,
)
await self.subscription_repository.update_subscription_autorenew(
session, user_id, False
)
await self._notify_user(
user_id, AUTORENEWAL_INSUFFICIENT_FUNDS.format(price=price, balance=user.balance)
)
return False
renewed_user = await self.user_repository.decrease_user_balance(
session,
user_id,
price,
tx_type=BalanceTxType.AUTORENEW,
description=f"autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d, whitelists={subscription.whitelists}",
)
if not renewed_user:
logger.error("Failed to decrease balance for user %d", user_id)
return False
now_utc = datetime.datetime.now(datetime.UTC)
if subscription.end_date > now_utc:
new_expire = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
else:
new_expire = now_utc + datetime.timedelta(days=subscription.duration_days)
try:
await self._renew_subscription_in_rw(subscription, new_expire)
except RenewalServiceError:
logger.exception("Failed to renew subscription in RW for user %d", user_id)
await self.user_repository.increase_user_balance(
session,
user_id,
price,
tx_type=BalanceTxType.REFUND,
description=f"refund for failed autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d",
)
return False
await self.subscription_repository.update_sub(
session,
user_id,
end_date=new_expire,
autorenew=True,
status=SubscriptionStatus.ACTIVE,
devices=subscription.devices,
whitelists=subscription.whitelists,
duration_days=subscription.duration_days,
plan_price_paid=price,
)
await self._notify_user(
user_id,
AUTORENEWAL_SUCCESS.format(
price=price,
end_date=new_expire.strftime("%d.%m.%Y"),
),
)
await notify_subscription_renewal(
self.bot,
user_id=user_id,
amount=price,
balance_before=renewed_user.balance + price,
balance_after=renewed_user.balance,
devices=subscription.devices,
duration_days=subscription.duration_days,
whitelists=subscription.whitelists,
end_date=new_expire.strftime("%d.%m.%Y"),
)
logger.info("Successfully renewed subscription for user %d", user_id)
return True
async def process_autorenewals(self, session: AsyncSession) -> dict:
subscriptions = await self.subscription_repository.get_subscriptions_for_autorenewal(
session, days_before_expiry=settings.autorenew_days_before
)
logger.info("Found %d subscriptions for autorenewal", len(subscriptions))
results = {"success": 0, "failed": 0, "insufficient_balance": 0}
for subscription in subscriptions:
try:
success = await self._process_single_renewal(session, subscription)
if success:
results["success"] += 1
else:
user = await self.user_repository.get_user_by_id(session, subscription.user_id)
if user and user.balance < await self._calculate_renewal_price(subscription):
results["insufficient_balance"] += 1
else:
results["failed"] += 1
except Exception:
logger.exception("Error processing autorenewal for user %d", subscription.user_id)
results["failed"] += 1
logger.info(
"Autorenewal completed: success=%d, failed=%d, insufficient_balance=%d",
results["success"],
results["failed"],
results["insufficient_balance"],
)
return results

View File

@@ -4,7 +4,13 @@ from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from remnawave.models import UpdateUserRequestDto from remnawave.models import (
CreateUserRequestDto,
DeleteUserHwidDeviceResponseDto,
HWIDDeleteRequest,
UpdateUserRequestDto,
)
from remnawave.models.hwid import HwidDeviceDto
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -319,3 +325,62 @@ async def update_squads(
exc, exc,
) )
return False return False
async def create_user(
sdk: RemnawaveSDK,
username: str,
telegram_id: int,
expire_at: datetime,
hwid_device_limit: int | None,
squad_uuids: list[str],
) -> RWUserInfo | None:
"""
Создать нового пользователя в Remnawave.
hwid_device_limit=None → без ограничений.
Возвращает RWUserInfo при успехе, None при ошибке.
"""
try:
dto = CreateUserRequestDto(
username=username,
telegram_id=telegram_id,
expire_at=expire_at,
hwid_device_limit=hwid_device_limit,
active_internal_squads=[UUID(s) for s in squad_uuids] if squad_uuids else None,
)
result = await sdk.users.create_user(dto)
if result is None:
return None
return _parse_user(result)
except Exception as exc:
logger.warning("Failed to create user username=%s: %s", username, exc)
return None
async def get_hwid_list(sdk: RemnawaveSDK, user_uuid: str) -> list[HwidDeviceDto] | None:
try:
resp = await sdk.hwid.get_hwid_user(user_uuid)
return resp.devices
except Exception:
logger.exception("failed to fetch hwid list for user=%s", user_uuid)
return
async def delete_hwid(sdk: RemnawaveSDK, user_uuid: str, hwid: str) -> bool:
body = HWIDDeleteRequest(user_uuid=user_uuid, hwid=hwid)
try:
resp = await sdk.hwid.delete_hwid_to_user(body)
return isinstance(resp, DeleteUserHwidDeviceResponseDto)
except Exception:
logger.exception("failed to delete hwid=%s for user=%s", hwid, user_uuid)
return False
async def update_expire_at(sdk: RemnawaveSDK, user_uuid: str, expire_at: datetime):
dto = UpdateUserRequestDto(uuid=user_uuid, expire_at=expire_at) # type: ignore
try:
await sdk.users.update_user(body=dto)
return True
except Exception:
logger.exception("failed to update expire_at=%s for user=%s", str(expire_at), user_uuid)
return False

View File

@@ -1,19 +1,21 @@
import datetime
import logging import logging
import math
from aiogram.types import Message from aiogram.types import Message, User as TelegramUser
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
from config import settings
from db.models import Subscription
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
from repositories.subscriptions import SubscriptionRepository
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
from schemas.users import NewUserDTO from schemas.users import NewUserDTO
from services.rw import RWUserInfo, get_user_by_telegram_id from services import rw
from texts import (
NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,13 +24,15 @@ class UserServiceError(Exception): ...
class UserService: class UserService:
def __init__(self, user_repository: UserRepository) -> None: def __init__(
self, user_repository: UserRepository, subscription_repository: SubscriptionRepository
) -> None:
self.user_repository = user_repository self.user_repository = user_repository
self.subscription_repository = subscription_repository
@staticmethod @staticmethod
def format_subscription_message( def format_subscription_message(
rw_user: RWUserInfo | None, rw_user: rw.RWUserInfo | None, link: str | None = None, autorenew: bool = False
link: str | None = None,
): ):
if rw_user is None: if rw_user is None:
# Пользователь не найден в Remnawave # Пользователь не найден в Remnawave
@@ -40,6 +44,7 @@ class UserService:
used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes) used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes)
hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit) hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit)
link = link or NOTHING_PLACEHOLDER link = link or NOTHING_PLACEHOLDER
autorenew_text = "🟢" if autorenew else "🔴"
return SUBSCRIPTION_INFORMATION.format( return SUBSCRIPTION_INFORMATION.format(
status_icon=status_icon, status_icon=status_icon,
@@ -49,6 +54,7 @@ class UserService:
used_traffic=used_traffic, used_traffic=used_traffic,
hwid_limit=hwid_limit, hwid_limit=hwid_limit,
link=link, link=link,
autorenew=autorenew_text,
) )
async def add_user( async def add_user(
@@ -62,7 +68,7 @@ class UserService:
if user: if user:
return user return user
rw_user = await get_user_by_telegram_id(rw_sdk, user_id) rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
referal = int(referal) if str(referal).isdigit() and user_id != int(referal) else None referal = int(referal) if str(referal).isdigit() and user_id != int(referal) else None
@@ -72,15 +78,11 @@ class UserService:
async def update_user_link( async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> User | None: ) -> User | None:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id) rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
user = await self.user_repository.get_user_by_id(session, user_id) user = await self.user_repository.get_user_by_id(session, user_id)
if not rw_user: link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
return user
link = utils.get_subscription_link(rw_user.short_uuid)
user = await self.user_repository.update_user_link(session, user_id, link) user = await self.user_repository.update_user_link(session, user_id, link)
return user return user
@@ -97,3 +99,155 @@ class UserService:
logger.critical("failed to send a message to user %d.", user.id, exc_info=True) logger.critical("failed to send a message to user %d.", user.id, exc_info=True)
return sent return sent
async def buy_subscription(
self,
session: AsyncSession,
subscription: SubscriptionPlan,
rw_sdk: RemnawaveSDK,
user: TelegramUser,
):
duration = utils.convert_duration_to_timedelta(subscription.duration)
squads = [InternalSquads.DEFAULT]
if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS)
## If subscription exists, add.
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)
if rw_user:
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
session, user.id
)
if db_subscription:
db_subscription = await self.subscription_repository.update_sub(
session,
user.id,
end_date=rw_user.expire_at + duration,
autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
else:
db_subscription = await self.subscription_repository.create_subscription(
session,
user_id=user.id,
end_date=rw_user.expire_at + duration,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
if not utils.is_same_plan(rw_user, subscription):
new_expire = datetime.datetime.now(datetime.UTC) + duration
delta_days = (new_expire - rw_user.expire_at).days
await rw.add_days(rw_sdk, rw_user.uuid, delta_days)
else:
await rw.add_days(rw_sdk, rw_user.uuid, duration.days)
await rw.update_squads(rw_sdk, rw_user.uuid, squads)
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices)
await self.update_user_link(session, user.id, rw_sdk)
return db_subscription
# Create
expire = datetime.datetime.now(datetime.UTC) + duration
username = "tg_" + str(user.username or user.id)
rw_user = await rw.create_user(
rw_sdk,
username=username,
telegram_id=user.id,
expire_at=expire,
hwid_device_limit=subscription.devices,
squad_uuids=squads,
)
if not rw_user:
raise Exception("RW User was NOT created, manual intervention required.")
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
session, user.id
)
if db_subscription:
db_subscription = await self.subscription_repository.update_sub(
session,
user.id,
end_date=expire,
autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
else:
db_subscription = await self.subscription_repository.create_subscription(
session,
user_id=user.id,
end_date=expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
await self.update_user_link(session, user.id, rw_sdk)
return db_subscription
async def calculate_plan_credit(
self,
session: AsyncSession,
user_id: int,
new_plan: SubscriptionPlan,
rw_sdk: RemnawaveSDK,
) -> int:
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
if not rw_user or rw_user.expire_at <= datetime.datetime.now(datetime.UTC):
return 0
if utils.is_same_plan(rw_user, new_plan):
return 0
db_sub = await self.subscription_repository.get_subscription_by_user_id(session, user_id)
if not db_sub or not db_sub.plan_price_paid or not db_sub.duration_days:
return 0
remaining_days = max(0, (rw_user.expire_at - datetime.datetime.now(datetime.UTC)).days)
return math.floor(db_sub.plan_price_paid * remaining_days / db_sub.duration_days)
async def launch_promotion(self, session: AsyncSession, promo_msg: Message) -> int:
users = await self.user_repository.get_users(session)
sent = 0
for user in users:
if user.id in settings.admins:
continue
try:
await promo_msg.copy_to(user.id)
sent += 1
except Exception:
logger.exception("caught an exception while launching promo, skipping user...")
return sent
def validate_db_subscription(self, subscription: Subscription, rw_user: rw.RWUserInfo):
return all(
[
(rw_user.status != "ACTIVE" and subscription.status == SubscriptionStatus.EXPIRED)
or (
rw_user.status == "ACTIVE" and subscription.status == SubscriptionStatus.ACTIVE
),
rw_user.expire_at == subscription.end_date,
]
)

View File

@@ -1,5 +0,0 @@
from aiogram.fsm.state import State, StatesGroup
class SupportStorage(StatesGroup):
prompt = State()

106
tests/send_pally_webhook.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Симулятор вебхука Pally для локального тестирования.
Использование:
python tests/send_test_webhook.py --user-id 123456789 --amount 300 --bill-id 1
Предварительно:
- API должен быть запущен: uvicorn api.main:app --port 8000
- Пользователь с --user-id должен существовать в БД
- Счёт с --bill-id должен существовать в БД и принадлежать этому пользователю
"""
import argparse
import hashlib
import os
import httpx
from dotenv import load_dotenv
load_dotenv()
ENDPOINT = "/pally/result"
def calculate_signature(
out_sum: str,
inv_id: str,
pally_token: str,
) -> str:
raw = f"{out_sum}:{inv_id}:{pally_token}"
return hashlib.md5(raw.encode()).hexdigest().upper()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--user-id", type=int, required=True)
parser.add_argument("--amount", type=int, required=True)
parser.add_argument("--bill-id", type=int, required=True)
parser.add_argument("--host", default="http://127.0.0.1:8000")
parser.add_argument(
"--status",
default="SUCCESS",
choices=["SUCCESS", "FAIL", "UNDERPAID", "OVERPAID"],
help="Статус платежа (по умолчанию SUCCESS)",
)
parser.add_argument(
"--customer-pays-fees",
action="store_true",
help="Симулировать сценарий 'клиент платит комиссию'",
)
args = parser.parse_args()
pally_token = os.environ["PALLY_TOKEN"]
# ИСПРАВЛЕНО: InvId должен содержать ID счета из БД (это order_id при создании счета)
# Именно InvId используется в webhook handler для поиска счета в БД
inv_id = str(args.bill_id) # InvId = bill ID из нашей БД (order_id при создании)
trs_id = f"LXZv3R{args.bill_id}" # TrsId - уникальный идентификатор счета в Pally
currency = "RUB"
custom = None # custom field не используется в production (остается None)
# Симуляция комиссии
if args.customer_pays_fees:
# Симулируем комиссию ~10% (как в вашем продакшене: 50 -> 55.4)
commission_rate = 0.108 # ~10.8%
gross_amount = args.amount * (1 + commission_rate)
commission = gross_amount - args.amount
print("customer pays fee:")
print(f" Сумма заказа: {args.amount} RUB")
print(f" Комиссия: {commission:.2f} RUB")
print(f" Итого к оплате: {gross_amount:.2f} RUB")
print(f" К зачислению: {args.amount} RUB")
else:
gross_amount = args.amount
commission = 0.00
# Сигнатура рассчитывается с использованием InvId (который содержит bill ID)
signature = calculate_signature(f"{gross_amount:.2f}", inv_id, pally_token)
# Используем правильные имена полей согласно документации pally.info
payload = {
"InvId": inv_id, # Содержит ID счета из БД
"OutSum": f"{gross_amount:.2f}", # Сумма, которую заплатил клиент (с комиссией)
"Commission": f"{commission:.2f}",
"TrsId": trs_id,
"Status": args.status,
"CurrencyIn": currency,
"custom": custom, # None в production
"SignatureValue": signature,
}
# Добавляем BalanceAmount только если клиент платит комиссию
if args.customer_pays_fees:
payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии)
payload["BalanceCurrency"] = currency
print(f"{args.host}{ENDPOINT}")
print(f"Отправляю вебхук: {payload}\n")
response = httpx.post(f"{args.host}{ENDPOINT}", data=payload)
print(f"Статус ответа: {response.status_code}")
print(f"Тело ответа: {response.text}")
if __name__ == "__main__":
main()

247
texts.py
View File

@@ -1,247 +0,0 @@
# ============================================================
# PREMIUM EMOJI
# ============================================================
class PremiumEmoji:
def __init__(self, emoji_id: int, emoji: str):
self.emoji_id = emoji_id
self.emoji = emoji
def __str__(self) -> str:
return f'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-emoji>'
# — Иконки бота
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
# ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
# ============================================================
_SUPPORT_USER_DESCRIPTION = (
"👤 <b>Профиль:</b> {full_name}\n"
"🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
"❤️ <b>Referal UID:</b> <code>{referal_id}</code>\n\n"
"💰 <b>Баланс:</b> <code>{balance}</code>"
)
_DIVIDER = "────────────────────────────\n\n"
# ============================================================
# МАППИНГИ
# ============================================================
# ============================================================
# СТАТУСЫ ПОДПИСКИ
# ============================================================
# — Иконки статусов
STATUS_ICON_ACTIVE = "🟢"
STATUS_ICON_DISABLED = ""
STATUS_ICON_EXPIRED = "🔴"
STATUS_ICON_LIMITED = "🟠"
STATUS_ICON_UNKNOWN = ""
# — Тексты статусов
STATUS_ACTIVE = "активна"
STATUS_DISABLED = "отключена"
STATUS_EXPIRED = "истёк"
STATUS_LIMITED = "трафик ограничен"
STATUS_UNKNOWN = "неизвестный статус"
# ============================================================
# ФОРМАТИРОВАНИЕ ДАННЫХ
# ============================================================
# — Дни до истечения подписки
DAYS_LEFT_POSITIVE = "{days} дн."
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
DAYS_LEFT_TODAY = "подходит к концу сегодня"
# — Username
NO_USERNAME = "отсутствует"
USERNAME_DISPLAY = "@{username}"
# — Internal Squads
NO_SQUADS = "отсутствуют"
# — HWID лимит
UNLIMITED_HWID = "без лимитов"
# — Трафик
NO_TRAFFIC_LIMIT = "не ограничен"
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
TRAFFIC_NO_LIMIT = "{used}"
# — Прочее
NOTHING_PLACEHOLDER = ""
# ============================================================
# ПОЛЬЗОВАТЕЛЬСКИЙ ИНТЕРФЕЙС
# ============================================================
MAIN_MENU = (
str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
)
FAQ_TEXT = (
"<b>Ваши частые вопросы:</b>\n\n"
"<b>— Зачем мне это?</b>\n"
"Если вас устраивает, что ваш интернет ограничен парой сайтов и банков, то вам не к нам. Но если хочется свободы и анонимности — добро пожаловать.\n\n"
"<b>— Это безопасно?</b>\n"
"Мы не храним ваши данные, не используем их в коммерческих целях и не передаем третьим лицам,"
"но важно понимать, что полной анонимности не существует. Мы делаем всё, чтобы минимизировать риски, но не продаём иллюзий.\n\n"
"<b>— Почему что-то не работает?</b>\n"
"Фильтры постоянно обновляются и подстраиваются под новые методы обхода. А мы подстраиваемся под них.\n"
"Если что-то перестало работать, скорее всего, это временно, и мы уже работаем над решением. Но обращения в поддержку с описанием проблемы и скриншотами всегда приветствуются, это помогает нам быстрее находить и устранять проблемы.\n\n"
"<b>— Как работает реферальная система?</b>\n"
"Вы получаете уникальную ссылку, по которой могут прийти ваши друзья. За каждого приведённого друга, который оформит подписку,"
"<b>вам будет начисляться процент от их оплаченной подписки</b> на баланс."
)
GENERAL_PROCESSING = "<i>⏳ Подождите...</i>"
STANDARD_FALLBACK = (
"<b>❌ Ошибка.</b> Прожмите /start. Если не поможет — значит, мы где-то не доглядели."
)
CALLBACK_FALLBACK = "❌ Что-то пошло не так. Повторите попытку или обратитесь в поддержку."
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
UNDEFINED_MESSAGE = "❌ Таких команд у нас нет.\n\n<i>Ищете техподдержку? Вам в /support</i>\n<i>Потерялись? Вам в /start.</i>"
SUBSCRIPTION_INFORMATION = (
"<b>📊 Данные о подписке</b>\n\n"
f"{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code>\n\n"
"{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Истекает:</b> {expire_date}\n"
+ "⏳ <b>Осталось дней:</b> {days_left}\n"
+ "📊 <b>Израсходовано трафика:</b> {used_traffic}\n"
+ "📱 <b>Количество устройств:</b> {hwid_limit}\n"
)
NO_SUBSCRIPTION = "⚠️ <b>Подписка не найдена.</b>"
# ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
# ============================================================
REFERALS_MENU = (
"<b>👥 Реферальная система</b>\n\n"
"Делитесь свободой с друзьями и получайте деньги на оплату своей подписки.\n"
f"<b>{MALENIA_COINS} На вашем счету:</b> "
"<code>{balance}₽</code>\n"
f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> "
"<code>{link}</code>"
)
# ============================================================
# ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = "Сколько устройств вы хотите подключить?\n\nЦена: <b>{price}₽</b>"
SUBSCRIPTION_DURATION_SELECTOR = (
"На какой срок хотите оформить подписку?\n\nИтого: <b>{price}₽</b> <i>(скидка: {discount}%)</i>"
)
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
# ============================================================
# BILLING
# ============================================================
_BILL_TEMPLATE = (
"<b>💸 Счёт на {amount}₽</b>\n\n"
"<i>‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.</i>\n"
"<b>🔒 Способ оплаты:</b> <i>{provider}</i>\n\n"
)
_BILL_LINK = f"<b>{MALENIA_LINK} Ссылка на оплату:</b>"
BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
# ============================================================
# ПОДДЕРЖКА
# ============================================================
SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здесь. Кнопка ниже."
SUPPORT_MSG_SENT = "⏳ Приняли ваше сообщение. Мы постараемся ответить на него в ближайшее время.\n<i>Вернуться в меню: /cancel</i>"
SUPPORT_SIGNATURE = "<b>📡 Ответ от Malenia:</b>"
# ============================================================
# ШАБЛОНЫ — ЗАЯВКИ И ТИКЕТЫ
# ============================================================
SUPPORT_SUBSCRIPTION = (
"<b>🎫 ВХОДЯЩИЙ ЗАПРОС:</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ "🗓️ Срок: <b>{duration} мес.</b>\n"
+ "📱 Устройства: <b>{devices}</b>\n"
+ "🏳️ Whitelists: <b>{whitelist_description}</b>\n\n"
+ "💰 К оплате: <b>{price}₽</b>"
)
ADMIN_TICKET_WITH_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ "📊 <b>ТЕХ. ДАННЫЕ (Remnawave)</b>\n\n"
+ "{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Deadline:</b> {expire_date}\n"
+ "⏳ <b>Остаток:</b> {days_left}\n"
+ "📊 <b>Трафик:</b> {used_traffic}\n"
+ "📱 <b>HWID:</b> {hwid_limit}\n"
+ "🎯 <b>Squads:</b> {squads}\n\n"
+ _DIVIDER
+ "🔑 <b>UUID:</b> <code>{remnawave_uuid}</code>"
)
ADMIN_TICKET_WITHOUT_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ NO_SUBSCRIPTION
)
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."
# ============================================================
# АДМИНСКАЯ ХУЙНЯ
# ============================================================
ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки"
ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?"
SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."
SPECIFY_REFERAL_PAYMENT_AMOUNT = "Укажите количество средств для перевода рефералу пользователя."
USER_NO_REFERAL = "У пользователя нет реферала."
INVALID_ARGUMENT_REFPAY = "❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя."
SUCCESSFUL_REFPAY = "<b>💸 Баланс реферала ({referal_id}) пополнен на {amount}₽</b>"