Compare commits
34 Commits
d98383285e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 245741b182 | |||
| 7ab0e71108 | |||
| 3adcc90b74 | |||
| 93f05e46b5 | |||
|
|
edfe1e6874 | ||
|
|
7ab2293fe2 | ||
| 9552813f16 | |||
| 4c5a16a3ca | |||
| 5ec9491e22 | |||
| 18f994aed9 | |||
| 936d1ad260 | |||
| 2a0f5c454e | |||
| 2103e6a882 | |||
| 102a0ebf96 | |||
| 18f4997ca6 | |||
| b5976d0b21 | |||
| 763e041a8b | |||
| f9d3fbf937 | |||
| a02af3b0f0 | |||
| a0c8167da6 | |||
| 717a1549e9 | |||
| 18866fc645 | |||
| 1228ec8b56 | |||
| a9a650f37b | |||
| 56f10d19c5 | |||
| 281a460974 | |||
| acb8662a66 | |||
| e9f84d9377 | |||
| ac61cec78c | |||
| db2dabed58 | |||
| 120e19e667 | |||
| fc656c5944 | |||
| 287f8eebda | |||
| 049f31118d |
54
.env.example
54
.env.example
@@ -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
3
.gitignore
vendored
@@ -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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13.0
|
||||||
41
Dockerfile.api
Normal file
41
Dockerfile.api
Normal 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"]
|
||||||
@@ -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")
|
||||||
@@ -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 ###
|
||||||
36
alembic/versions/b2ecec0ef260_plan_specifications_added.py
Normal file
36
alembic/versions/b2ecec0ef260_plan_specifications_added.py
Normal 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 ###
|
||||||
@@ -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
|
||||||
@@ -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.execute("DROP TYPE billstatus")
|
||||||
op.drop_column('bills', 'status')
|
|
||||||
|
|
||||||
bind = op.get_bind()
|
|
||||||
if bind.dialect.name == 'postgresql':
|
|
||||||
op.execute("DROP TYPE billstatus")
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|||||||
@@ -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 ###
|
||||||
39
alembic/versions/e8da1e4516fa_add_subscription_table.py
Normal file
39
alembic/versions/e8da1e4516fa_add_subscription_table.py
Normal 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")
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -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
18
api/core/deps.py
Normal 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
13
api/core/notifications.py
Normal 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
58
api/main.py
Normal 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
3
api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .pally import router as pally_router
|
||||||
|
|
||||||
|
routers = [pally_router]
|
||||||
237
api/routes/pally.py
Normal file
237
api/routes/pally.py
Normal 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
8
bot/filters/admins.py
Normal 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
9
bot/handlers/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
4
bot/handlers/admins/__init__.py
Normal file
4
bot/handlers/admins/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .admins import router as general_router
|
||||||
|
from .ads import router as ads_router
|
||||||
|
|
||||||
|
routers = [general_router, ads_router]
|
||||||
36
bot/handlers/admins/admins.py
Normal file
36
bot/handlers/admins/admins.py
Normal 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
|
||||||
|
)
|
||||||
65
bot/handlers/admins/ads.py
Normal file
65
bot/handlers/admins/ads.py
Normal 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
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
58
bot/handlers/client/deposit.py
Normal file
58
bot/handlers/client/deposit.py
Normal 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})
|
||||||
280
bot/handlers/client/menus.py
Normal file
280
bot/handlers/client/menus.py
Normal 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
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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__)
|
||||||
4
bot/handlers/system/__init__.py
Normal file
4
bot/handlers/system/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .common import router as common_router
|
||||||
|
from .prehandling import router as prehandling_router
|
||||||
|
|
||||||
|
routers = [prehandling_router, common_router]
|
||||||
@@ -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()
|
||||||
|
|
||||||
0
bot/keyboards/__init__.py
Normal file
0
bot/keyboards/__init__.py
Normal file
16
bot/keyboards/admins.py
Normal file
16
bot/keyboards/admins.py
Normal 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()
|
||||||
@@ -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
8
bot/keyboards/common.py
Normal 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()
|
||||||
@@ -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)
|
|
||||||
|
|
||||||
await dp.start_polling(bot)
|
try:
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
scheduler_task.cancel()
|
||||||
|
await scheduler_task
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
29
bot/scheduler.py
Normal file
29
bot/scheduler.py
Normal 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
0
bot/states/__init__.py
Normal file
5
bot/states/deposit.py
Normal file
5
bot/states/deposit.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class DepositStorage(StatesGroup):
|
||||||
|
amount = State()
|
||||||
270
bot/texts.py
Normal file
270
bot/texts.py
Normal 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>"
|
||||||
24
config.py
24
config.py
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
35
db/models/subscriptions.py
Normal file
35
db/models/subscriptions.py
Normal 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
42
db/models/transactions.py
Normal 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")
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
5
entrypoints/api.sh
Executable 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
|
||||||
@@ -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
2
entrypoints/startup.sh
Normal file → Executable 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
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from aiogram import Router
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
# FIXME: Refine newsletter system
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ ignore = [
|
|||||||
"PLR0913",
|
"PLR0913",
|
||||||
"RUF001",
|
"RUF001",
|
||||||
"RUF002",
|
"RUF002",
|
||||||
"RUF003"
|
"RUF003",
|
||||||
|
"B008"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
105
repositories/subscriptions.py
Normal file
105
repositories/subscriptions.py
Normal 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()
|
||||||
41
repositories/transactions.py
Normal file
41
repositories/transactions.py
Normal 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())
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
111
services/admin_notifications.py
Normal file
111
services/admin_notifications.py
Normal 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
195
services/renewal_service.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
from aiogram.fsm.state import State, StatesGroup
|
|
||||||
|
|
||||||
|
|
||||||
class SupportStorage(StatesGroup):
|
|
||||||
prompt = State()
|
|
||||||
106
tests/send_pally_webhook.py
Normal file
106
tests/send_pally_webhook.py
Normal 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
247
texts.py
@@ -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>"
|
|
||||||
Reference in New Issue
Block a user