Compare commits
46 Commits
1be4770afd
...
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 | |||
| d98383285e | |||
| 8290d18235 | |||
| 76fd6eb9a8 | |||
| e3e204d7af | |||
| 97c187bafb | |||
| 78a0ef9750 | |||
| 3d19d9189b | |||
| 606aa4b89f | |||
| 0ac8ed8d64 | |||
| 0ed0ad2b92 | |||
| 60de871e0c | |||
| 2bb12f2ff8 |
47
.env.example
Normal file
47
.env.example
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Database
|
||||||
|
POSTGRES_USER=
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_DB=
|
||||||
|
POSTGRES_URL=postgresql+asyncpg://user:password@localhost:5432/database_name
|
||||||
|
|
||||||
|
|
||||||
|
# Telegram bot
|
||||||
|
BOT_TOKEN=your_telegram_bot_token
|
||||||
|
|
||||||
|
# Comma-separated list of Telegram user IDs
|
||||||
|
ADMINS=[123456789,987654321]
|
||||||
|
|
||||||
|
# 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
|
||||||
|
MAX_DEVICES=12
|
||||||
|
PER_DEVICE_COST=50
|
||||||
|
WHITELIST_COST=100
|
||||||
|
WHITELIST_DEVICE_THRESHOLD=6
|
||||||
|
|
||||||
|
# Squads
|
||||||
|
GENERAL_SQUAD=2de0b5ea-49b9-4181-916f-ae67b9c83b80
|
||||||
|
WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977
|
||||||
|
|
||||||
|
# Autorenewal
|
||||||
|
AUTORENEW_DAYS_BEFORE=1
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -130,6 +130,8 @@ celerybeat.pid
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
*.env
|
||||||
|
dev.env
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -174,5 +176,4 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
# Static / IMG
|
plans
|
||||||
static/img
|
|
||||||
@@ -1 +1 @@
|
|||||||
3.13
|
3.13.0
|
||||||
|
|||||||
47
Dockerfile
47
Dockerfile
@@ -1,24 +1,41 @@
|
|||||||
FROM python:3.13-slim
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install runtime dependencies required by asyncpg
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
libpq5 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better layer caching
|
# Copy installed Python packages from the builder stage
|
||||||
COPY requirements.txt .
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
# Install Python dependencies
|
# Copy application source code
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY entrypoints/startup.sh /entrypoint.sh
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
# Copy application code (will be overridden by volume mount in development)
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
RUN sed -i 's/\r$//' ./entrypoints/startup.sh && \
|
||||||
|
chmod +x ./entrypoints/startup.sh
|
||||||
|
|
||||||
|
# Ensure the startup script is executable
|
||||||
|
RUN chmod +x ./entrypoints/startup.sh
|
||||||
|
|
||||||
|
# Default command runs linting, migrations, and starts the bot
|
||||||
|
CMD ["sh", "./entrypoints/startup.sh"]
|
||||||
|
|||||||
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 @@
|
|||||||
|
"""user -> +subscription_link optional text.
|
||||||
|
|
||||||
|
Revision ID: 5985b2df0830
|
||||||
|
Revises: 756210f564a5
|
||||||
|
Create Date: 2026-04-09 20:38:15.157762
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "5985b2df0830"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "756210f564a5"
|
||||||
|
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("users", sa.Column("subscription_link", sa.TEXT(), nullable=True))
|
||||||
|
op.create_unique_constraint(None, "users", ["subscription_link"])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, "users", type_="unique")
|
||||||
|
op.drop_column("users", "subscription_link")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -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
|
||||||
43
alembic/versions/d21c5f9364ea_billing.py
Normal file
43
alembic/versions/d21c5f9364ea_billing.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""billing
|
||||||
|
|
||||||
|
Revision ID: d21c5f9364ea
|
||||||
|
Revises: f40cab941564
|
||||||
|
Create Date: 2026-04-23 19:22:45.686671
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd21c5f9364ea'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'f40cab941564'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'bills',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('creator_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('amount', sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'status',
|
||||||
|
sa.Enum(
|
||||||
|
'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:
|
||||||
|
op.drop_table('bills')
|
||||||
|
op.execute("DROP TYPE billstatus")
|
||||||
@@ -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")
|
||||||
|
|
||||||
32
alembic/versions/f40cab941564_user_balance.py
Normal file
32
alembic/versions/f40cab941564_user_balance.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""user -> +balance.
|
||||||
|
|
||||||
|
Revision ID: f40cab941564
|
||||||
|
Revises: 5985b2df0830
|
||||||
|
Create Date: 2026-04-10 21:11:15.209314
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f40cab941564"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "5985b2df0830"
|
||||||
|
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("users", sa.Column("balance", sa.INTEGER(), nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("users", "balance")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -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"
|
||||||
0
bot/__init__.py
Normal file
0
bot/__init__.py
Normal file
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
|
||||||
|
)
|
||||||
15
bot/handlers/client/__init__.py
Normal file
15
bot/handlers/client/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from .billing import router as billing_router
|
||||||
|
from .buy import router as buy_router
|
||||||
|
from .deposit import router as deposit_router
|
||||||
|
from .menus import router as menus_router
|
||||||
|
from .referals import router as referal_router
|
||||||
|
from .support import router as support_router
|
||||||
|
|
||||||
|
routers = [
|
||||||
|
referal_router,
|
||||||
|
billing_router,
|
||||||
|
deposit_router,
|
||||||
|
menus_router,
|
||||||
|
support_router,
|
||||||
|
buy_router,
|
||||||
|
]
|
||||||
51
bot/handlers/client/billing.py
Normal file
51
bot/handlers/client/billing.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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.di import DependenciesDTO
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(BillingStorage.pending, F.data == "billing:pally")
|
||||||
|
async def pally_init(
|
||||||
|
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||||
|
):
|
||||||
|
data = await state.get_data()
|
||||||
|
ctx: BillingContext | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
logger.warning("ctx not found on billing choice")
|
||||||
|
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
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())
|
||||||
|
)
|
||||||
|
|
||||||
|
bill = await deps.billing_service.pally_initiate_payment(
|
||||||
|
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount
|
||||||
|
)
|
||||||
|
|
||||||
|
if not bill:
|
||||||
|
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
|
||||||
|
logger.critical("bill creation prompted, but bill is None")
|
||||||
|
return
|
||||||
|
|
||||||
|
await cb.message.edit_text(
|
||||||
|
BILL_CREATED.format(amount=ctx.amount, provider=ctx.provider.value.title()),
|
||||||
|
reply_markup=pay_url(bill.link_page_url),
|
||||||
|
)
|
||||||
241
bot/handlers/client/buy.py
Normal file
241
bot/handlers/client/buy.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector
|
||||||
|
from bot.keyboards.common import return_to_menu
|
||||||
|
from bot.states.buy import SubscriptionStorage
|
||||||
|
from bot.texts import (
|
||||||
|
CALLBACK_FALLBACK,
|
||||||
|
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 (
|
||||||
|
calculate_price,
|
||||||
|
convert_duration_to_int,
|
||||||
|
convert_int_to_duration,
|
||||||
|
get_discount,
|
||||||
|
)
|
||||||
|
from schemas.di import DependenciesDTO
|
||||||
|
from schemas.subscriptions import SubscriptionPlan
|
||||||
|
from services.admin_notifications import notify_subscription_purchase
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "buy")
|
||||||
|
async def buy_init(cb: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx = SubscriptionPlan()
|
||||||
|
await cb.message.edit_text(
|
||||||
|
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
|
||||||
|
reply_markup=devices_selector(settings.min_devices),
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.set_state(SubscriptionStorage.devices)
|
||||||
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("devices:"))
|
||||||
|
async def device_count_select(cb: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
|
||||||
|
|
||||||
|
cb_data = cb.data.split(":")
|
||||||
|
devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices
|
||||||
|
ctx.devices = devices
|
||||||
|
ctx.whitelists = ctx.whitelists or devices >= settings.whitelist_device_threshold
|
||||||
|
|
||||||
|
await cb.message.edit_text(
|
||||||
|
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
|
||||||
|
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
|
||||||
|
)
|
||||||
|
await state.set_state(SubscriptionStorage.devices)
|
||||||
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(SubscriptionStorage.devices, F.data == "whitelist:toggle")
|
||||||
|
async def toggle_whitelists(cb: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
|
||||||
|
ctx.whitelists = not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
|
||||||
|
|
||||||
|
try:
|
||||||
|
await cb.message.edit_text(
|
||||||
|
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
|
||||||
|
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "message is not modified:" in str(exc):
|
||||||
|
await cb.answer(NOTHING_PLACEHOLDER)
|
||||||
|
else:
|
||||||
|
await cb.answer(CALLBACK_FALLBACK)
|
||||||
|
await state.set_state(SubscriptionStorage.devices)
|
||||||
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(SubscriptionStorage.devices, F.data == "confirm")
|
||||||
|
async def select_duration_init(cb: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx: SubscriptionPlan | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await cb.answer(CALLBACK_FALLBACK)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
discount = get_discount(convert_duration_to_int(ctx.duration))
|
||||||
|
ctx.discount = 1 - discount / 100
|
||||||
|
await cb.message.edit_text(
|
||||||
|
SUBSCRIPTION_DURATION_SELECTOR.format(
|
||||||
|
price=calculate_price(ctx),
|
||||||
|
discount=math.floor(discount),
|
||||||
|
),
|
||||||
|
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "message is not modified:" in str(exc):
|
||||||
|
await cb.answer(NOTHING_PLACEHOLDER)
|
||||||
|
else:
|
||||||
|
await cb.answer(CALLBACK_FALLBACK)
|
||||||
|
raise
|
||||||
|
|
||||||
|
await state.set_state(SubscriptionStorage.duration)
|
||||||
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("duration:"))
|
||||||
|
async def select_duration(cb: CallbackQuery, state: FSMContext):
|
||||||
|
data = await state.get_data()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx: SubscriptionPlan | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await cb.answer(CONTEXT_REINITIATED)
|
||||||
|
ctx = SubscriptionPlan()
|
||||||
|
|
||||||
|
cb_data = cb.data.split(":")
|
||||||
|
duration = int(cb_data[1]) if cb_data[1].isdigit() else 1
|
||||||
|
ctx.duration = convert_int_to_duration(duration)
|
||||||
|
try:
|
||||||
|
discount = get_discount(convert_duration_to_int(ctx.duration))
|
||||||
|
ctx.discount = 1 - discount / 100
|
||||||
|
await cb.message.edit_text(
|
||||||
|
SUBSCRIPTION_DURATION_SELECTOR.format(
|
||||||
|
price=calculate_price(ctx),
|
||||||
|
discount=math.floor(discount),
|
||||||
|
),
|
||||||
|
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if "message is not modified:" in str(exc):
|
||||||
|
await cb.answer(NOTHING_PLACEHOLDER)
|
||||||
|
else:
|
||||||
|
await cb.answer(CALLBACK_FALLBACK)
|
||||||
|
raise
|
||||||
|
|
||||||
|
await state.set_state(SubscriptionStorage.duration)
|
||||||
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
|
||||||
|
async def proceed_to_checkout(
|
||||||
|
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||||
|
):
|
||||||
|
data = await state.get_data()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx: SubscriptionPlan | None = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
amount = calculate_price(ctx)
|
||||||
|
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||||
|
|
||||||
|
credit = await deps.user_service.calculate_plan_credit(
|
||||||
|
session, cb.from_user.id, ctx, deps.rw_sdk
|
||||||
|
)
|
||||||
|
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
|
||||||
|
),
|
||||||
|
)
|
||||||
28
bot/handlers/client/referals.py
Normal file
28
bot/handlers/client/referals.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery
|
||||||
|
from aiogram.utils.deep_linking import create_start_link
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.keyboards.client import referals_kb
|
||||||
|
from bot.texts import REFERALS_MENU
|
||||||
|
from misc.utils import format_share_deep_link
|
||||||
|
from schemas.di import DependenciesDTO
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "referal")
|
||||||
|
async def referal_menu(
|
||||||
|
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)
|
||||||
|
referal_link = await create_start_link(cb.bot, f"ref_{user.id}", encode=True)
|
||||||
|
share_link = format_share_deep_link(referal_link)
|
||||||
|
|
||||||
|
await cb.message.edit_text(
|
||||||
|
REFERALS_MENU.format(balance=user.balance, link=referal_link),
|
||||||
|
reply_markup=referals_kb(share_link),
|
||||||
|
)
|
||||||
27
bot/handlers/client/support.py
Normal file
27
bot/handlers/client/support.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from bot.keyboards.client import support_url_kb
|
||||||
|
from bot.texts import SUPPORT_INIT
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "support")
|
||||||
|
async def support_init(cb: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await cb.message.edit_text(text=SUPPORT_INIT, reply_markup=support_url_kb)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("support"))
|
||||||
|
async def support_init_cmd(msg: Message, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await msg.delete()
|
||||||
|
|
||||||
|
await msg.answer(text=SUPPORT_INIT, reply_markup=support_url_kb)
|
||||||
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]
|
||||||
19
bot/handlers/system/common.py
Normal file
19
bot/handlers/system/common.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from bot.texts import UNDEFINED_MESSAGE
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message()
|
||||||
|
async def undefined_cmd(msg: Message):
|
||||||
|
await msg.delete()
|
||||||
|
await msg.answer(UNDEFINED_MESSAGE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "delete")
|
||||||
|
async def delete_cb(cb: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
await cb.message.delete()
|
||||||
9
bot/handlers/system/prehandling.py
Normal file
9
bot/handlers/system/prehandling.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.chat.type != "private")
|
||||||
|
async def prehandle_non_private(msg: Message):
|
||||||
|
return
|
||||||
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()
|
||||||
171
bot/keyboards/client.py
Normal file
171
bot/keyboards/client.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
from remnawave.models.hwid import HwidDeviceDto
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from misc.utils import convert_duration_to_int, format_hwid_device
|
||||||
|
from schemas.subscriptions import SubscriptionDuration
|
||||||
|
|
||||||
|
from .common import _return_to_menu_btn
|
||||||
|
|
||||||
|
main_menu = InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="🛒 Купить", callback_data="buy"),
|
||||||
|
InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
|
||||||
|
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link"),
|
||||||
|
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
|
||||||
|
],
|
||||||
|
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
|
||||||
|
]
|
||||||
|
).as_markup()
|
||||||
|
|
||||||
|
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")],
|
||||||
|
[InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")],
|
||||||
|
[_return_to_menu_btn],
|
||||||
|
]
|
||||||
|
).as_markup()
|
||||||
|
|
||||||
|
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||||
|
[[InlineKeyboardButton(text="❌", callback_data="delete")]]
|
||||||
|
).as_markup()
|
||||||
|
|
||||||
|
support_url_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")],
|
||||||
|
[_return_to_menu_btn],
|
||||||
|
]
|
||||||
|
).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:
|
||||||
|
return InlineKeyboardButton(text=text, callback_data=data)
|
||||||
|
|
||||||
|
|
||||||
|
def build_return_menu(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardBuilder([[_return_btn(data, text)]]).as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def devices_selector(current: int = settings.min_devices, whitelists: bool = False):
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{'✅ ' if i == current else ''}{i}", callback_data=f"devices:{i}"
|
||||||
|
)
|
||||||
|
for i in range(settings.min_devices, settings.max_devices + 1)
|
||||||
|
]
|
||||||
|
builder.add(*buttons).adjust(6)
|
||||||
|
|
||||||
|
wl_icon = "✅ " if whitelists else ""
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{wl_icon}Обход Белых Списков (+100₽)",
|
||||||
|
callback_data="whitelist:toggle",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.row(InlineKeyboardButton(text="🟢", callback_data="confirm"))
|
||||||
|
builder.row(_return_to_menu_btn)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def duration_selector(
|
||||||
|
current: SubscriptionDuration = SubscriptionDuration.MONTH,
|
||||||
|
back_cb: str = "menu:main",
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
duration_int = convert_duration_to_int(current)
|
||||||
|
|
||||||
|
durations = [
|
||||||
|
(1, "1 Месяц"),
|
||||||
|
(3, "3 Месяца"),
|
||||||
|
(6, "6 Месяцев"),
|
||||||
|
(12, "1 Год"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for val, label in durations:
|
||||||
|
is_selected = val == duration_int
|
||||||
|
|
||||||
|
builder.button(
|
||||||
|
text=f"{'✅ ' if is_selected else ''}{label}",
|
||||||
|
callback_data=f"duration:{val}",
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.row(InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm"))
|
||||||
|
builder.row(_return_btn(back_cb))
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def referals_kb(share_link: str) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton(text="🔗 Поделиться ссылкой", url=share_link)],
|
||||||
|
[_return_to_menu_btn],
|
||||||
|
]
|
||||||
|
).as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def pay_url(url: str) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardBuilder(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton(text="💸 Оплатить", url=url)],
|
||||||
|
[InlineKeyboardButton(text="❌", callback_data="menu:main")],
|
||||||
|
]
|
||||||
|
).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()
|
||||||
87
bot/main.py
Normal file
87
bot/main.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
|
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 db.session import async_session
|
||||||
|
from misc.pally import PallyClient
|
||||||
|
from repositories.bills import BillsRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
|
from repositories.users import UserRepository
|
||||||
|
from schemas.di import DependenciesDTO
|
||||||
|
from services.billing_service import BillingService
|
||||||
|
from services.renewal_service import RenewalService
|
||||||
|
from services.user_service import UserService
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
contextlib.suppress(asyncio.CancelledError)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
dp = Dispatcher()
|
||||||
|
|
||||||
|
pally_client = PallyClient(settings.pally_token, payer_pays_commission=True)
|
||||||
|
|
||||||
|
user_repository = UserRepository()
|
||||||
|
bills_repository = BillsRepository()
|
||||||
|
subscriptions_repository = SubscriptionRepository()
|
||||||
|
|
||||||
|
user_service = UserService(user_repository, subscription_repository=subscriptions_repository)
|
||||||
|
billing_service = BillingService(
|
||||||
|
user_repository=user_repository, bills_repository=bills_repository
|
||||||
|
)
|
||||||
|
|
||||||
|
rw_sdk = RemnawaveSDK(
|
||||||
|
base_url=settings.remnawave_url,
|
||||||
|
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(
|
||||||
|
user_repository=user_repository,
|
||||||
|
user_service=user_service,
|
||||||
|
rw_sdk=rw_sdk,
|
||||||
|
pally_client=pally_client,
|
||||||
|
bills_repository=bills_repository,
|
||||||
|
billing_service=billing_service,
|
||||||
|
subscriptions_repository=subscriptions_repository,
|
||||||
|
renewal_service=renewal_service,
|
||||||
|
)
|
||||||
|
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
||||||
|
|
||||||
|
for r in admin_routers:
|
||||||
|
dp.include_router(r)
|
||||||
|
for r in client_routers:
|
||||||
|
dp.include_router(r)
|
||||||
|
for r in system_routers:
|
||||||
|
dp.include_router(r)
|
||||||
|
|
||||||
|
scheduler_task = asyncio.create_task(start_scheduler(renewal_service, async_session))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
scheduler_task.cancel()
|
||||||
|
await scheduler_task
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
from typing import Callable, Awaitable, Any
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiogram import BaseMiddleware
|
from aiogram import BaseMiddleware
|
||||||
from aiogram.types import TelegramObject
|
from aiogram.types import TelegramObject
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
@@ -7,9 +9,7 @@ from schemas.di import DependenciesDTO
|
|||||||
|
|
||||||
|
|
||||||
class DIMiddleware(BaseMiddleware):
|
class DIMiddleware(BaseMiddleware):
|
||||||
def __init__(
|
def __init__(self, deps: DependenciesDTO, session_factory: async_sessionmaker) -> None:
|
||||||
self, deps: DependenciesDTO, session_factory: async_sessionmaker
|
|
||||||
) -> None:
|
|
||||||
self.deps = deps
|
self.deps = deps
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
|
|
||||||
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
6
bot/states/admins.py
Normal file
6
bot/states/admins.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class AdminStorage(StatesGroup):
|
||||||
|
promotion_msg = State()
|
||||||
|
refpay_amount = State()
|
||||||
12
bot/states/buy.py
Normal file
12
bot/states/buy.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionStorage(StatesGroup):
|
||||||
|
devices = State()
|
||||||
|
duration = State()
|
||||||
|
checkout = State()
|
||||||
|
|
||||||
|
|
||||||
|
class BillingStorage(StatesGroup):
|
||||||
|
pending = State()
|
||||||
|
refresh = State()
|
||||||
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>"
|
||||||
38
config.py
38
config.py
@@ -1,23 +1,45 @@
|
|||||||
from typing import Optional
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from pydantic import Field
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
class settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
postgres_url: str = Field(
|
||||||
|
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL"
|
||||||
|
)
|
||||||
|
|
||||||
bot_token: str = Field(alias="BOT_TOKEN")
|
bot_token: str = Field(alias="BOT_TOKEN")
|
||||||
postgres_url: str = Field(alias="POSTGRES_URL")
|
admins: list[int] = Field(alias="ADMINS")
|
||||||
proxy: Optional[str] = Field(None, alias="PROXY")
|
admin_log_chat_id: int | None = Field(None, alias="ADMIN_LOG_CHAT_ID")
|
||||||
admin_group_id: int = Field(alias="ADMIN_GROUP_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")
|
||||||
|
|
||||||
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
||||||
|
subscription_url: str = Field(alias="SUBSCRIPTION_URL")
|
||||||
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
|
remnawave_token: str = Field(alias="REMNAWAVE_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")
|
||||||
|
|
||||||
settings = settings() # type: ignore
|
autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings() # type: ignore
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
from .bills import Bill
|
||||||
|
from .subscriptions import Subscription
|
||||||
|
from .transactions import BalanceTransaction
|
||||||
from .users import User
|
from .users import User
|
||||||
from .tickets import Ticket
|
|
||||||
|
|
||||||
__all__ = ["User", "Ticket"]
|
__all__ = ["BalanceTransaction", "Bill", "Subscription", "User"]
|
||||||
|
|||||||
37
db/models/bills.py
Normal file
37
db/models/bills.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import BIGINT, INTEGER, 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 DepositStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class Bill(Base):
|
||||||
|
__tablename__ = "bills"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BIGINT, autoincrement=True, unique=True, nullable=False, primary_key=True
|
||||||
|
)
|
||||||
|
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
|
||||||
|
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
|
||||||
|
status: Mapped[DepositStatus] = mapped_column(
|
||||||
|
Enum(DepositStatus, name="depositstatus", values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
default=DepositStatus.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User",
|
||||||
|
back_populates="bills",
|
||||||
|
)
|
||||||
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")
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from enum import Enum as VanillaEnum
|
|
||||||
from sqlalchemy import INTEGER, BIGINT, Enum
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from db.base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class TicketStatus(VanillaEnum):
|
|
||||||
OPEN = "OPEN"
|
|
||||||
CLOSED = "CLOSED"
|
|
||||||
PENDING = "PENDING"
|
|
||||||
|
|
||||||
|
|
||||||
class Ticket(Base):
|
|
||||||
__tablename__ = "tickets"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
INTEGER, autoincrement=True, unique=True, nullable=False, primary_key=True
|
|
||||||
)
|
|
||||||
creator_id: Mapped[int] = mapped_column(BIGINT, nullable=False)
|
|
||||||
status: Mapped[TicketStatus] = mapped_column(
|
|
||||||
Enum(TicketStatus), nullable=False, default=TicketStatus.PENDING
|
|
||||||
)
|
|
||||||
thread_id: Mapped[int] = mapped_column(BIGINT, nullable=True, unique=True)
|
|
||||||
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")
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
from sqlalchemy import BIGINT
|
from typing import TYPE_CHECKING
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
from sqlalchemy import BIGINT, INTEGER, TEXT
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from db.base import Base
|
from db.base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from db.models.bills import Bill
|
||||||
|
from db.models.subscriptions import Subscription
|
||||||
|
from db.models.transactions import BalanceTransaction
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(BIGINT, nullable=False, unique=True, primary_key=True)
|
||||||
BIGINT, nullable=False, unique=True, primary_key=True
|
|
||||||
)
|
|
||||||
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
|
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
|
||||||
|
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)
|
||||||
|
balance: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)
|
||||||
|
|
||||||
|
bills: Mapped[list["Bill"]] = relationship(
|
||||||
|
"Bill",
|
||||||
|
back_populates="user",
|
||||||
|
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,4 +1,4 @@
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,77 @@
|
|||||||
services:
|
services:
|
||||||
bot:
|
postgres:
|
||||||
build:
|
env_file: .env
|
||||||
context: .
|
image: postgres:16-alpine
|
||||||
dockerfile: Dockerfile
|
container_name: maleniabot_db
|
||||||
|
environment:
|
||||||
|
TZ: UTC
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-malenia}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- postgres_data:/var/lib/postgresql/data
|
||||||
env_file:
|
healthcheck:
|
||||||
- .env
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER:-malenia} -d ${POSTGRES_DB:-malenia_db}",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
bot:
|
||||||
|
env_file: .env
|
||||||
|
build:
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: maleniabot_bot
|
||||||
|
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}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Optional: uncomment to run with auto-reload for development
|
|
||||||
# command: python -m watchfiles main.py
|
api:
|
||||||
stdin_open: true
|
build:
|
||||||
tty: true
|
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:
|
||||||
|
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
|
||||||
22
entrypoints/dev.sh
Normal file
22
entrypoints/dev.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[+] Formatting and checking..."
|
||||||
|
echo
|
||||||
|
ruff check --fix
|
||||||
|
black .
|
||||||
|
|
||||||
|
# echo "[+] checking psql availability"
|
||||||
|
# if ! pg_isready -h localhost -p 5432 ; then
|
||||||
|
# echo "psql is down." >&2
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
# echo "[+] psql is available!"
|
||||||
|
# echo
|
||||||
|
|
||||||
|
echo "[+] Running migrations..."
|
||||||
|
echo
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
echo "[+] Starting bot..."
|
||||||
|
python -m bot.main
|
||||||
7
entrypoints/startup.sh
Normal file → Executable file
7
entrypoints/startup.sh
Normal file → Executable file
@@ -1,14 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "[+] Formatting and checking..."
|
|
||||||
echo
|
|
||||||
ruff check --fix
|
|
||||||
black .
|
|
||||||
|
|
||||||
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
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from .menus import router as menus_router
|
|
||||||
from .support import router as support_router
|
|
||||||
from .buy import router as buy_router
|
|
||||||
|
|
||||||
routers = [menus_router, support_router, buy_router]
|
|
||||||
153
handlers/buy.py
153
handlers/buy.py
@@ -1,153 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
from aiogram import Router, F
|
|
||||||
from aiogram.types import CallbackQuery
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
|
|
||||||
from keyboards.client import devices_selector, duration_selector
|
|
||||||
from misc.utils import calculate_price, convert_int_to_duration
|
|
||||||
from schemas.subscriptions import SubscriptionPlan
|
|
||||||
from states.buy import SubscriptionStorage
|
|
||||||
from texts import (
|
|
||||||
CALLBACK_FALLBACK,
|
|
||||||
CONTEXT_REINITIATED,
|
|
||||||
NOTHING_PLACEHOLDER,
|
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
|
||||||
SUBSCRIPTION_DURATION_SELECTOR,
|
|
||||||
)
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "buy")
|
|
||||||
async def buy_init(cb: CallbackQuery, state: FSMContext):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
ctx = SubscriptionPlan()
|
|
||||||
await cb.message.edit_text(
|
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
|
|
||||||
reply_markup=devices_selector(settings.min_devices),
|
|
||||||
)
|
|
||||||
|
|
||||||
await state.set_state(SubscriptionStorage.devices)
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("devices:"))
|
|
||||||
async def device_count_select(cb: CallbackQuery, state: FSMContext):
|
|
||||||
data = await state.get_data()
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
ctx: Optional[SubscriptionPlan] = data.get("ctx", SubscriptionPlan())
|
|
||||||
|
|
||||||
cb_data = cb.data.split(":")
|
|
||||||
devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices
|
|
||||||
ctx.devices = devices
|
|
||||||
ctx.whitelists = ctx.whitelists or devices >= settings.whitelist_device_threshold
|
|
||||||
|
|
||||||
await cb.message.edit_text(
|
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
|
|
||||||
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
|
|
||||||
)
|
|
||||||
await state.set_state(SubscriptionStorage.devices)
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(SubscriptionStorage.devices, F.data == "whitelist:toggle")
|
|
||||||
async def toggle_whitelists(cb: CallbackQuery, state: FSMContext):
|
|
||||||
data = await state.get_data()
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
ctx: Optional[SubscriptionPlan] = data.get("ctx")
|
|
||||||
if not ctx:
|
|
||||||
await state.set_state(SubscriptionPlan())
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
await cb.answer(CALLBACK_FALLBACK)
|
|
||||||
return
|
|
||||||
ctx.whitelists = (
|
|
||||||
not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await cb.message.edit_reply_markup(
|
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
|
|
||||||
reply_markup=devices_selector(
|
|
||||||
current=ctx.devices, whitelists=ctx.whitelists
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
if "message is not modified:" in str(exc):
|
|
||||||
await cb.answer(NOTHING_PLACEHOLDER)
|
|
||||||
else:
|
|
||||||
await cb.answer(CALLBACK_FALLBACK)
|
|
||||||
await state.set_state(SubscriptionStorage.devices)
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(SubscriptionStorage.devices, F.data == "confirm")
|
|
||||||
async def select_duration_init(cb: CallbackQuery, state: FSMContext):
|
|
||||||
data = await state.get_data()
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
ctx: Optional[SubscriptionPlan] = data.get("ctx")
|
|
||||||
if not ctx:
|
|
||||||
await state.set_state(SubscriptionPlan())
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
await cb.answer(CALLBACK_FALLBACK)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await cb.message.edit_text(
|
|
||||||
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
|
|
||||||
reply_markup=duration_selector(
|
|
||||||
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
if "message is not modified:" in str(exc):
|
|
||||||
await cb.answer(NOTHING_PLACEHOLDER)
|
|
||||||
else:
|
|
||||||
await cb.answer(CALLBACK_FALLBACK)
|
|
||||||
raise
|
|
||||||
|
|
||||||
await state.set_state(SubscriptionStorage.duration)
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("duration:"))
|
|
||||||
async def select_duration(cb: CallbackQuery, state: FSMContext):
|
|
||||||
data = await state.get_data()
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
ctx: Optional[SubscriptionPlan] = data.get("ctx")
|
|
||||||
if not ctx:
|
|
||||||
await state.set_state()
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
await cb.answer(CONTEXT_REINITIATED)
|
|
||||||
ctx = SubscriptionPlan()
|
|
||||||
|
|
||||||
cb_data = cb.data.split(":")
|
|
||||||
duration = int(cb_data[1]) if cb_data[1].isdigit() else 1
|
|
||||||
ctx.duration = convert_int_to_duration(duration)
|
|
||||||
try:
|
|
||||||
await cb.message.edit_text(
|
|
||||||
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
|
|
||||||
reply_markup=duration_selector(
|
|
||||||
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
if "message is not modified:" in str(exc):
|
|
||||||
await cb.answer(NOTHING_PLACEHOLDER)
|
|
||||||
else:
|
|
||||||
await cb.answer(CALLBACK_FALLBACK)
|
|
||||||
raise
|
|
||||||
|
|
||||||
await state.set_state(SubscriptionStorage.duration)
|
|
||||||
await state.set_data({"ctx": ctx})
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
|
|
||||||
async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
|
|
||||||
await cb.answer("yay!!!! fuck off.")
|
|
||||||
...
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from aiogram import Router, F
|
|
||||||
from aiogram.filters import CommandStart, CommandObject, Command
|
|
||||||
from aiogram.types import CallbackQuery, Message
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from schemas.di import DependenciesDTO
|
|
||||||
from keyboards.client import main_menu
|
|
||||||
from texts import build_main_menu_text
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(CommandStart(deep_link=True, deep_link_encoded=False))
|
|
||||||
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 = data[0]
|
|
||||||
await deps.user_service.add_user(session, user_id=msg.from_user.id, referal=referal)
|
|
||||||
|
|
||||||
await msg.reply(build_main_menu_text(), reply_markup=main_menu)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command(commands=["start", "cancel"]))
|
|
||||||
async def standard_start(
|
|
||||||
msg: Message,
|
|
||||||
command: CommandObject,
|
|
||||||
state: FSMContext,
|
|
||||||
session: AsyncSession,
|
|
||||||
deps: DependenciesDTO,
|
|
||||||
):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
await deps.user_service.add_user(session, user_id=msg.from_user.id)
|
|
||||||
|
|
||||||
await msg.reply(build_main_menu_text(), reply_markup=main_menu)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "menu:main")
|
|
||||||
async def main_menu_cb(cb: CallbackQuery, state: FSMContext):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
await cb.message.edit_text(build_main_menu_text(), reply_markup=main_menu)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import logging
|
|
||||||
from aiogram import Router, F
|
|
||||||
from aiogram.filters import Command
|
|
||||||
from aiogram.fsm.context import FSMContext
|
|
||||||
from aiogram.types import CallbackQuery, Message
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from schemas.di import DependenciesDTO
|
|
||||||
from services.user_service import UserServiceException
|
|
||||||
from states.support import SupportStorage
|
|
||||||
from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT
|
|
||||||
from keyboards.client import return_to_menu
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "support")
|
|
||||||
async def support_init(cb: CallbackQuery, state: FSMContext):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
await cb.message.edit_text(text=SUPPORT_INIT, reply_markup=return_to_menu)
|
|
||||||
await state.set_state(SupportStorage.prompt)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(SupportStorage.prompt)
|
|
||||||
async def received_message(
|
|
||||||
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
|
||||||
):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
notification_msg = await msg.reply(GENERAL_PROCESSING)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await deps.user_service.support_forward_message(session, msg, deps.rw_sdk)
|
|
||||||
except UserServiceException:
|
|
||||||
await msg.reply(STANDARD_FALLBACK)
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
await msg.reply(STANDARD_FALLBACK)
|
|
||||||
logger.exception("Exception occured while trying to forward a msg to support")
|
|
||||||
raise
|
|
||||||
|
|
||||||
await notification_msg.edit_text(SUPPORT_MSG_SENT, reply_markup=return_to_menu)
|
|
||||||
await state.set_state(SupportStorage.prompt)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(
|
|
||||||
F.chat.id == settings.admin_group_id,
|
|
||||||
F.message_thread_id.is_not(None),
|
|
||||||
F.from_user.is_bot == False, # noqa: E712
|
|
||||||
~Command("close"),
|
|
||||||
)
|
|
||||||
async def handle_admin_message(
|
|
||||||
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
|
||||||
):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
await deps.user_service.support_answer(session, msg)
|
|
||||||
|
|
||||||
|
|
||||||
@router.message(F.chat.id == settings.admin_group_id, Command("close"))
|
|
||||||
async def close_ticket(
|
|
||||||
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
|
||||||
):
|
|
||||||
await state.clear()
|
|
||||||
|
|
||||||
await deps.user_service.close_ticket(session, msg)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from misc.utils import convert_duration_to_int
|
|
||||||
from schemas.subscriptions import SubscriptionDuration
|
|
||||||
|
|
||||||
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
|
||||||
[
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
|
|
||||||
InlineKeyboardButton(text="FAQ", callback_data="faq"),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="Наш Канал", url="https://goo.gle"),
|
|
||||||
InlineKeyboardButton(text="Тех. Поддержка", callback_data="support"),
|
|
||||||
],
|
|
||||||
]
|
|
||||||
).as_markup()
|
|
||||||
|
|
||||||
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
|
|
||||||
text="⬅️ Назад", callback_data="menu:main"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _return_btn(
|
|
||||||
data: str = "menu:main", text: str = "⬅️ Назад"
|
|
||||||
) -> InlineKeyboardButton:
|
|
||||||
return InlineKeyboardButton(text=text, callback_data=data)
|
|
||||||
|
|
||||||
|
|
||||||
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
|
||||||
[[_return_to_menu_btn]]
|
|
||||||
).as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def devices_selector(current: int = settings.min_devices, whitelists: bool = False):
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
|
|
||||||
buttons = [
|
|
||||||
InlineKeyboardButton(
|
|
||||||
text=f"{'✅ ' if i == current else ''}{i}", callback_data=f"devices:{i}"
|
|
||||||
)
|
|
||||||
for i in range(settings.min_devices, settings.max_devices + 1)
|
|
||||||
]
|
|
||||||
builder.add(*buttons).adjust(6)
|
|
||||||
|
|
||||||
wl_icon = "✅ " if whitelists else ""
|
|
||||||
builder.row(
|
|
||||||
InlineKeyboardButton(
|
|
||||||
text=f"{wl_icon}Обход Белых Списков (+100₽)",
|
|
||||||
callback_data="whitelist:toggle",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
builder.row(InlineKeyboardButton(text="🟢", callback_data="confirm"))
|
|
||||||
builder.row(_return_to_menu_btn)
|
|
||||||
|
|
||||||
return builder.as_markup()
|
|
||||||
|
|
||||||
|
|
||||||
def duration_selector(
|
|
||||||
current: SubscriptionDuration = SubscriptionDuration.MONTH,
|
|
||||||
back_cb: str = "menu:main",
|
|
||||||
) -> InlineKeyboardMarkup:
|
|
||||||
builder = InlineKeyboardBuilder()
|
|
||||||
duration_int = convert_duration_to_int(current)
|
|
||||||
|
|
||||||
durations = [
|
|
||||||
(1, "1 Месяц"),
|
|
||||||
(3, "3 Месяца"),
|
|
||||||
(6, "6 Месяцев"),
|
|
||||||
(12, "1 Год"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for val, label in durations:
|
|
||||||
is_selected = val == duration_int
|
|
||||||
|
|
||||||
builder.button(
|
|
||||||
text=f"{'✅ ' if is_selected else ''}{label}",
|
|
||||||
callback_data=f"duration:{val}",
|
|
||||||
)
|
|
||||||
|
|
||||||
builder.row(
|
|
||||||
InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm")
|
|
||||||
)
|
|
||||||
builder.row(_return_btn(back_cb))
|
|
||||||
|
|
||||||
return builder.as_markup()
|
|
||||||
52
main.py
52
main.py
@@ -1,52 +0,0 @@
|
|||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from remnawave import RemnawaveSDK
|
|
||||||
from aiogram import Bot, Dispatcher
|
|
||||||
from aiogram.client.default import DefaultBotProperties
|
|
||||||
from aiogram.client.session.aiohttp import AiohttpSession
|
|
||||||
|
|
||||||
from schemas.di import DependenciesDTO
|
|
||||||
from handlers import routers
|
|
||||||
from middlewares import DIMiddleware
|
|
||||||
from config import settings
|
|
||||||
from repositories.tickets import TicketRepository
|
|
||||||
from repositories.users import UserRepository
|
|
||||||
from services.user_service import UserService
|
|
||||||
from db.session import async_session
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
dp = Dispatcher()
|
|
||||||
|
|
||||||
user_repository = UserRepository()
|
|
||||||
ticket_repository = TicketRepository()
|
|
||||||
user_service = UserService(user_repository, ticket_repository)
|
|
||||||
|
|
||||||
rw_sdk = RemnawaveSDK(
|
|
||||||
base_url=settings.remnawave_url,
|
|
||||||
token=settings.remnawave_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
deps = DependenciesDTO(
|
|
||||||
ticket_repository=ticket_repository,
|
|
||||||
user_repository=user_repository,
|
|
||||||
user_service=user_service,
|
|
||||||
rw_sdk=rw_sdk,
|
|
||||||
)
|
|
||||||
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
|
||||||
|
|
||||||
aiohttp_session = AiohttpSession(proxy=settings.proxy)
|
|
||||||
default = DefaultBotProperties(parse_mode="HTML")
|
|
||||||
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
|
|
||||||
|
|
||||||
for router in routers:
|
|
||||||
dp.include_router(router)
|
|
||||||
|
|
||||||
await dp.start_polling(bot)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
410
misc/pally.py
Normal file
410
misc/pally.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""
|
||||||
|
Pally API Asynchronous SDK
|
||||||
|
|
||||||
|
This module provides a strictly typed, asynchronous client for the Pally API.
|
||||||
|
It is built on top of aiohttp for high-performance async requests and pydantic
|
||||||
|
for robust data validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 1. ENUMS & CONSTANTS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
class Currency(StrEnum):
|
||||||
|
RUB = "RUB"
|
||||||
|
USD = "USD"
|
||||||
|
EUR = "EUR"
|
||||||
|
USDT = "USDT"
|
||||||
|
|
||||||
|
|
||||||
|
class BillType(StrEnum):
|
||||||
|
NORMAL = "normal"
|
||||||
|
MULTI = "multi"
|
||||||
|
|
||||||
|
|
||||||
|
class BillStatus(StrEnum):
|
||||||
|
NEW = "NEW"
|
||||||
|
PROCESS = "PROCESS"
|
||||||
|
UNDERPAID = "UNDERPAID"
|
||||||
|
SUCCESS = "SUCCESS"
|
||||||
|
OVERPAID = "OVERPAID"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentStatus(StrEnum):
|
||||||
|
NEW = "NEW"
|
||||||
|
PROCESS = "PROCESS"
|
||||||
|
UNDERPAID = "UNDERPAID"
|
||||||
|
SUCCESS = "SUCCESS"
|
||||||
|
OVERPAID = "OVERPAID"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
class Locale(StrEnum):
|
||||||
|
EN = "en"
|
||||||
|
RU = "ru"
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 2. EXCEPTIONS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
class PallyError(Exception):
|
||||||
|
"""Base exception for all Pally API errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PallyAPIError(PallyError):
|
||||||
|
"""Raised when the API responds with an error HTTP status code."""
|
||||||
|
|
||||||
|
def __init__(self, status_code: int, message: str):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.message = message
|
||||||
|
super().__init__(f"Pally API Error {status_code}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 3. PYDANTIC MODELS (DATA STRUCTURES)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
class BasePallyModel(BaseModel):
|
||||||
|
"""Base Pydantic model with default configurations."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
# --- General Responses ---
|
||||||
|
class PaginationLinks(BasePallyModel):
|
||||||
|
prev: str | None = None
|
||||||
|
next: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationMeta(BasePallyModel):
|
||||||
|
path: str
|
||||||
|
per_page: int
|
||||||
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bill Models ---
|
||||||
|
class BillCreateResponse(BasePallyModel):
|
||||||
|
success: bool
|
||||||
|
link_url: str
|
||||||
|
link_page_url: str
|
||||||
|
bill_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class Bill(BasePallyModel):
|
||||||
|
id: str
|
||||||
|
order_id: str | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
status: BillStatus
|
||||||
|
amount: float
|
||||||
|
type: BillType
|
||||||
|
created_at: datetime
|
||||||
|
currency_in: Currency
|
||||||
|
ttl: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BillSearchResponse(BasePallyModel):
|
||||||
|
success: bool
|
||||||
|
data: list[Bill]
|
||||||
|
links: PaginationLinks
|
||||||
|
meta: PaginationMeta
|
||||||
|
|
||||||
|
|
||||||
|
# --- Payment Models ---
|
||||||
|
class Payment(BasePallyModel):
|
||||||
|
id: str
|
||||||
|
bill_id: str
|
||||||
|
status: PaymentStatus
|
||||||
|
amount: float
|
||||||
|
commission: float
|
||||||
|
account_amount: float
|
||||||
|
account_currency_code: str
|
||||||
|
refunded_amount: float
|
||||||
|
from_card: str | None = None
|
||||||
|
account_bank: str | None = None
|
||||||
|
currency_in: Currency
|
||||||
|
created_at: datetime
|
||||||
|
payer_phone: str | None = None
|
||||||
|
payer_email: str | None = None
|
||||||
|
payer_name: str | None = None
|
||||||
|
payer_comment: str | None = None
|
||||||
|
error_code: int | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentSearchResponse(BasePallyModel):
|
||||||
|
success: bool
|
||||||
|
data: list[Payment]
|
||||||
|
links: PaginationLinks
|
||||||
|
meta: PaginationMeta
|
||||||
|
|
||||||
|
|
||||||
|
# --- Balance Models ---
|
||||||
|
class Balance(BasePallyModel):
|
||||||
|
currency: Currency
|
||||||
|
balance_available: float
|
||||||
|
balance_locked: float
|
||||||
|
balance_hold: float
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceResponse(BasePallyModel):
|
||||||
|
success: bool
|
||||||
|
balances: list[Balance]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 4. CORE SERVICES
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
class BaseService:
|
||||||
|
"""Base class for all API services handling aiohttp operations."""
|
||||||
|
|
||||||
|
def __init__(self, session: aiohttp.ClientSession):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def _post(self, endpoint: str, data: dict[str, Any], response_model: type[Any]) -> Any:
|
||||||
|
"""Helper for making POST requests with application/x-www-form-urlencoded data."""
|
||||||
|
# Clean None values to avoid sending them in the payload
|
||||||
|
cleaned_data = {k: str(v) for k, v in data.items() if v is not None}
|
||||||
|
|
||||||
|
async with self._session.post(endpoint, data=cleaned_data) as response:
|
||||||
|
await self._handle_errors(response)
|
||||||
|
json_data = await response.json()
|
||||||
|
return response_model.model_validate(json_data)
|
||||||
|
|
||||||
|
async def _get(self, endpoint: str, params: dict[str, Any], response_model: type[Any]) -> Any:
|
||||||
|
"""Helper for making GET requests."""
|
||||||
|
cleaned_params = {k: str(v) for k, v in params.items() if v is not None}
|
||||||
|
|
||||||
|
async with self._session.get(endpoint, params=cleaned_params) as response:
|
||||||
|
await self._handle_errors(response)
|
||||||
|
json_data = await response.json()
|
||||||
|
return response_model.model_validate(json_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _handle_errors(response: aiohttp.ClientResponse) -> None:
|
||||||
|
"""Raises exceptions based on HTTP error codes from aiohttp."""
|
||||||
|
if not response.ok:
|
||||||
|
try:
|
||||||
|
error_data = await response.json()
|
||||||
|
message = (
|
||||||
|
error_data.get("message")
|
||||||
|
or error_data.get("error_key")
|
||||||
|
or await response.text()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
message = await response.text()
|
||||||
|
raise PallyAPIError(status_code=response.status, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
class BillService(BaseService):
|
||||||
|
"""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(
|
||||||
|
self,
|
||||||
|
amount: float,
|
||||||
|
shop_id: str,
|
||||||
|
order_id: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
bill_type: BillType = BillType.NORMAL,
|
||||||
|
currency_in: Currency | None = None,
|
||||||
|
ttl: int | None = None,
|
||||||
|
payer_pays_commission: bool | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> BillCreateResponse:
|
||||||
|
"""
|
||||||
|
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 = {
|
||||||
|
"amount": amount,
|
||||||
|
"shop_id": shop_id,
|
||||||
|
"order_id": order_id,
|
||||||
|
"description": description,
|
||||||
|
"type": bill_type.value,
|
||||||
|
"currency_in": currency_in.value if currency_in else None,
|
||||||
|
"ttl": ttl,
|
||||||
|
"payer_pays_commission": int(final_ppc) if final_ppc is not None else None,
|
||||||
|
**kwargs,
|
||||||
|
}
|
||||||
|
return await self._post("bill/create", payload, BillCreateResponse)
|
||||||
|
|
||||||
|
async def toggle_activity(self, bill_id: str, active: bool) -> Bill:
|
||||||
|
"""Activates or deactivates a bill."""
|
||||||
|
payload = {"id": bill_id, "active": int(active)}
|
||||||
|
return await self._post("bill/toggle_activity", payload, Bill)
|
||||||
|
|
||||||
|
async def get_payments(
|
||||||
|
self, bill_id: str, per_page: int | None = None, cursor: str | None = None
|
||||||
|
) -> PaymentSearchResponse:
|
||||||
|
"""Gets all payments related to a specific bill."""
|
||||||
|
params = {"id": bill_id, "per_page": per_page, "cursor": cursor}
|
||||||
|
return await self._get("bill/payments", params, PaymentSearchResponse)
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
finish_date: datetime | None = None,
|
||||||
|
shop_id: str | None = None,
|
||||||
|
per_page: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> BillSearchResponse:
|
||||||
|
"""Searches for bills based on parameters."""
|
||||||
|
params = {
|
||||||
|
"start_date": start_date.strftime("%Y-%m-%d %H:%M:%S") if start_date else None,
|
||||||
|
"finish_date": finish_date.strftime("%Y-%m-%d %H:%M:%S") if finish_date else None,
|
||||||
|
"shop_id": shop_id,
|
||||||
|
"per_page": per_page,
|
||||||
|
"cursor": cursor,
|
||||||
|
}
|
||||||
|
return await self._get("bill/search", params, BillSearchResponse)
|
||||||
|
|
||||||
|
async def status(self, bill_id: str) -> Bill:
|
||||||
|
"""Gets the status of a specific bill."""
|
||||||
|
return await self._get("bill/status", {"id": bill_id}, Bill)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentService(BaseService):
|
||||||
|
"""Service to handle all Payment-related operations."""
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
finish_date: datetime | None = None,
|
||||||
|
shop_id: str | None = None,
|
||||||
|
per_page: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> PaymentSearchResponse:
|
||||||
|
"""Searches for payments."""
|
||||||
|
params = {
|
||||||
|
"start_date": start_date.strftime("%Y-%m-%d %H:%M:%S") if start_date else None,
|
||||||
|
"finish_date": finish_date.strftime("%Y-%m-%d %H:%M:%S") if finish_date else None,
|
||||||
|
"shop_id": shop_id,
|
||||||
|
"per_page": per_page,
|
||||||
|
"cursor": cursor,
|
||||||
|
}
|
||||||
|
return await self._get("payment/search", params, PaymentSearchResponse)
|
||||||
|
|
||||||
|
async def status(
|
||||||
|
self, payment_id: str, refunds: bool = False, chargeback: bool = False
|
||||||
|
) -> Payment:
|
||||||
|
"""Gets detailed status of a specific payment."""
|
||||||
|
params = {"id": payment_id, "refunds": int(refunds), "chargeback": int(chargeback)}
|
||||||
|
return await self._get("payment/status", params, Payment)
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceService(BaseService):
|
||||||
|
"""Service to handle Balance-related operations."""
|
||||||
|
|
||||||
|
async def get(self) -> BalanceResponse:
|
||||||
|
"""Retrieves merchant balance."""
|
||||||
|
return await self._get("merchant/balance", {}, BalanceResponse)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 5. MAIN CLIENT
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
class PallyClient:
|
||||||
|
"""
|
||||||
|
Main asynchronous client for the Pally API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async with PallyClient(api_token="your_token", payer_pays_commission=True) as client:
|
||||||
|
bill = await client.bills.create(amount=100.0, shop_id="my_shop")
|
||||||
|
print(bill.link_url)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_token (str): The bearer token provided by Pally.
|
||||||
|
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.base_url = base_url
|
||||||
|
self.payer_pays_commission = payer_pays_commission
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self) -> aiohttp.ClientSession:
|
||||||
|
"""
|
||||||
|
Lazy-loads the aiohttp ClientSession.
|
||||||
|
"""
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10.0)
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
base_url=self.base_url, headers=headers, timeout=timeout
|
||||||
|
)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bills(self) -> BillService:
|
||||||
|
# Передаем параметр плательщика комиссии в сервис счетов
|
||||||
|
return BillService(self.session, self.payer_pays_commission)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payments(self) -> PaymentService:
|
||||||
|
return PaymentService(self.session)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance(self) -> BalanceService:
|
||||||
|
return BalanceService(self.session)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Closes the underlying aiohttp client session safely."""
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "PallyClient":
|
||||||
|
_ = self.session
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
await self.close()
|
||||||
186
misc/utils.py
186
misc/utils.py
@@ -1,10 +1,14 @@
|
|||||||
from typing import Optional
|
import math
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from random import choice
|
from random import choice
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
|
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
|
||||||
import texts
|
from services.rw import RWUserInfo
|
||||||
|
|
||||||
QUOTES: list[str] = [
|
QUOTES: list[str] = [
|
||||||
"Верните себе свободный интернет.",
|
"Верните себе свободный интернет.",
|
||||||
@@ -39,19 +43,45 @@ 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:
|
||||||
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
|
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
|
||||||
if num_bytes >= 1_073_741_824: # >= 1 ГБ
|
gb_capacity = 1_073_741_824
|
||||||
return f"{num_bytes / 1_073_741_824:.1f} ГБ"
|
mb_capacity = 1_048_576
|
||||||
if num_bytes >= 1_048_576: # >= 1 МБ
|
kb_capacity = 1_024
|
||||||
return f"{num_bytes / 1_048_576:.1f} МБ"
|
|
||||||
if num_bytes >= 1_024: # >= 1 КБ
|
if num_bytes >= gb_capacity: # >= 1 ГБ
|
||||||
return f"{num_bytes / 1_024:.1f} КБ"
|
return f"{num_bytes / gb_capacity:.1f} ГБ"
|
||||||
|
if num_bytes >= mb_capacity: # >= 1 МБ
|
||||||
|
return f"{num_bytes / mb_capacity:.1f} МБ"
|
||||||
|
if num_bytes >= kb_capacity: # >= 1 КБ
|
||||||
|
return f"{num_bytes / kb_capacity:.1f} КБ"
|
||||||
return f"{num_bytes:.0f} Б"
|
return f"{num_bytes:.0f} Б"
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +98,7 @@ def format_traffic(used: float, limit: int) -> str:
|
|||||||
return texts.TRAFFIC_WITH_LIMIT.format(used=used_str, limit=limit_str)
|
return texts.TRAFFIC_WITH_LIMIT.format(used=used_str, limit=limit_str)
|
||||||
|
|
||||||
|
|
||||||
def format_hwid_limit(limit: Optional[int]) -> str:
|
def format_hwid_limit(limit: int | None) -> str:
|
||||||
"""Форматировать лимит HWID: число или «без ограничений»."""
|
"""Форматировать лимит HWID: число или «без ограничений»."""
|
||||||
if limit is None or limit == 0:
|
if limit is None or limit == 0:
|
||||||
return texts.UNLIMITED_HWID
|
return texts.UNLIMITED_HWID
|
||||||
@@ -89,11 +119,11 @@ def format_days_left(expire_at: datetime) -> str:
|
|||||||
- «истекает сегодня» — если осталось 0 дней
|
- «истекает сегодня» — если осталось 0 дней
|
||||||
- «истекла N дн. назад» — если уже истекла
|
- «истекла N дн. назад» — если уже истекла
|
||||||
"""
|
"""
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=UTC)
|
||||||
|
|
||||||
# Убедимся что expire_at timezone-aware
|
# Убедимся что expire_at timezone-aware
|
||||||
if expire_at.tzinfo is None:
|
if expire_at.tzinfo is None:
|
||||||
expire_at = expire_at.replace(tzinfo=timezone.utc)
|
expire_at = expire_at.replace(tzinfo=UTC)
|
||||||
|
|
||||||
delta = expire_at - now
|
delta = expire_at - now
|
||||||
days = delta.days # целое, может быть отрицательным
|
days = delta.days # целое, может быть отрицательным
|
||||||
@@ -117,18 +147,22 @@ def format_status(status: str) -> tuple[str, str]:
|
|||||||
"LIMITED": (texts.STATUS_ICON_LIMITED, texts.STATUS_LIMITED),
|
"LIMITED": (texts.STATUS_ICON_LIMITED, texts.STATUS_LIMITED),
|
||||||
"ON_HOLD": (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN),
|
"ON_HOLD": (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN),
|
||||||
}
|
}
|
||||||
return mapping.get(
|
return mapping.get(status.upper(), (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN))
|
||||||
status.upper(), (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_username(username: Optional[str]) -> str:
|
def format_username(username: str | None) -> str:
|
||||||
"""Форматировать username: «@user» или «нет username»."""
|
"""Форматировать username: «@user» или «нет username»."""
|
||||||
if username:
|
if username:
|
||||||
return "@" + username
|
return "@" + username
|
||||||
return texts.NO_USERNAME
|
return texts.NO_USERNAME
|
||||||
|
|
||||||
|
|
||||||
|
def format_referal_id(referal_id: int | None) -> str:
|
||||||
|
if referal_id:
|
||||||
|
return str(referal_id)
|
||||||
|
return texts.NOTHING_PLACEHOLDER
|
||||||
|
|
||||||
|
|
||||||
def convert_duration_to_int(duration: SubscriptionDuration) -> int:
|
def convert_duration_to_int(duration: SubscriptionDuration) -> int:
|
||||||
mapping: dict[str, int] = {
|
mapping: dict[str, int] = {
|
||||||
SubscriptionDuration.MONTH.value: 1,
|
SubscriptionDuration.MONTH.value: 1,
|
||||||
@@ -150,22 +184,114 @@ def convert_int_to_duration(value: int) -> SubscriptionDuration:
|
|||||||
|
|
||||||
|
|
||||||
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
|
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
|
||||||
MONTH_DELTA = timedelta(days=30)
|
month_delta = timedelta(days=30)
|
||||||
mapping: dict[str, timedelta] = {
|
mapping: dict[str, timedelta] = {
|
||||||
SubscriptionDuration.MONTH.value: MONTH_DELTA,
|
SubscriptionDuration.MONTH.value: month_delta,
|
||||||
SubscriptionDuration.THREE_MONTHS.value: MONTH_DELTA * 3,
|
SubscriptionDuration.THREE_MONTHS.value: month_delta * 3,
|
||||||
SubscriptionDuration.SIX_MONTHS.value: MONTH_DELTA * 6,
|
SubscriptionDuration.SIX_MONTHS.value: month_delta * 6,
|
||||||
SubscriptionDuration.YEAR.value: MONTH_DELTA * 12,
|
SubscriptionDuration.YEAR.value: month_delta * 12,
|
||||||
}
|
}
|
||||||
return mapping.get(duration.value, MONTH_DELTA)
|
return mapping.get(duration.value, month_delta)
|
||||||
|
|
||||||
|
|
||||||
def calculate_price(subscription: SubscriptionPlan):
|
def calculate_price(subscription: SubscriptionPlan):
|
||||||
return (
|
return math.ceil(
|
||||||
(subscription.devices * settings.per_device_cost)
|
(
|
||||||
+ (
|
(subscription.devices * settings.per_device_cost)
|
||||||
int(subscription.whitelists) * settings.whitelist_cost
|
+ (
|
||||||
if subscription.devices < settings.whitelist_device_threshold
|
int(subscription.whitelists) * settings.whitelist_cost
|
||||||
else 0
|
if subscription.devices < settings.whitelist_device_threshold
|
||||||
|
else 0
|
||||||
|
)
|
||||||
)
|
)
|
||||||
) * convert_duration_to_int(subscription.duration)
|
* convert_duration_to_int(subscription.duration)
|
||||||
|
* subscription.discount
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_discount(months) -> float:
|
||||||
|
if months <= 0:
|
||||||
|
return 0
|
||||||
|
calculated = 5 * (math.log2(months / 3) + 1)
|
||||||
|
return max(0, calculated)
|
||||||
|
|
||||||
|
|
||||||
|
def format_subscription_link(link: str | None = None):
|
||||||
|
return f"<code>{link or texts.NOTHING_PLACEHOLDER}</code>"
|
||||||
|
|
||||||
|
|
||||||
|
def format_share_deep_link(link: str):
|
||||||
|
return f"https://t.me/share/url/?url={urllib.parse.quote_plus(link)}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_support_subscription(
|
||||||
|
subscription: SubscriptionPlan,
|
||||||
|
full_name: str,
|
||||||
|
user_id: int,
|
||||||
|
balance: int,
|
||||||
|
username: str | None = None,
|
||||||
|
referal_id: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
username = format_username(username)
|
||||||
|
whitelist_description = "ВЫКЛ"
|
||||||
|
if subscription.devices >= settings.whitelist_device_threshold:
|
||||||
|
whitelist_description = "ВКЛ (Бесплатно)"
|
||||||
|
elif subscription.whitelists:
|
||||||
|
whitelist_description = "ВКЛ (Платно)"
|
||||||
|
|
||||||
|
return texts.SUPPORT_SUBSCRIPTION.format(
|
||||||
|
full_name=full_name,
|
||||||
|
telegram_id=user_id,
|
||||||
|
username_display=username,
|
||||||
|
duration=convert_duration_to_int(subscription.duration),
|
||||||
|
devices=subscription.devices,
|
||||||
|
whitelist_description=whitelist_description,
|
||||||
|
price=calculate_price(subscription),
|
||||||
|
balance=balance,
|
||||||
|
referal_id=referal_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscription_link(short_uuid: str):
|
||||||
|
return settings.subscription_url + "/" + short_uuid
|
||||||
|
|
||||||
|
|
||||||
|
def build_main_menu_text(link: str, balance: int) -> str:
|
||||||
|
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}"
|
||||||
|
|||||||
1
prob
1
prob
@@ -1 +0,0 @@
|
|||||||
Мне нужно, чтобы ссылка для подключения пользователя была доступна в главном меню, но если при каждом /start я буду делать запрос, каждая обработка будет занимать по году. Как это реализовать?
|
|
||||||
59
pyproject.toml
Normal file
59
pyproject.toml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py313']
|
||||||
|
include = '\.pyi?$'
|
||||||
|
extend-exclude = '''
|
||||||
|
/(
|
||||||
|
# Исключаем стандартные папки
|
||||||
|
\.git
|
||||||
|
| \.venv
|
||||||
|
| build
|
||||||
|
| dist
|
||||||
|
| alembic
|
||||||
|
)/
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Базовые настройки
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
# Исключаем alembic полностью
|
||||||
|
exclude = [
|
||||||
|
".git",
|
||||||
|
".venv",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"alembic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E",
|
||||||
|
"W",
|
||||||
|
"F",
|
||||||
|
"I",
|
||||||
|
"N",
|
||||||
|
"UP",
|
||||||
|
"B",
|
||||||
|
"SIM",
|
||||||
|
"PL",
|
||||||
|
"RUF",
|
||||||
|
"TID",
|
||||||
|
"PT",
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
"E501",
|
||||||
|
"D100",
|
||||||
|
"D104",
|
||||||
|
"G004",
|
||||||
|
"PLR0913",
|
||||||
|
"RUF001",
|
||||||
|
"RUF002",
|
||||||
|
"RUF003",
|
||||||
|
"B008"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
combine-as-imports = true
|
||||||
31
repositories/bills.py
Normal file
31
repositories/bills.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.models.bills import Bill, DepositStatus
|
||||||
|
|
||||||
|
|
||||||
|
class BillsRepository:
|
||||||
|
async def get_bill_by_id(self, session: AsyncSession, bill_id: int) -> Bill | None:
|
||||||
|
stmt = select(Bill).where(Bill.id == bill_id)
|
||||||
|
res = await session.execute(stmt)
|
||||||
|
|
||||||
|
return res.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
creator_id: int,
|
||||||
|
amount: int,
|
||||||
|
status: DepositStatus | None = DepositStatus.PENDING,
|
||||||
|
) -> Bill:
|
||||||
|
bill = Bill(creator_id=creator_id, amount=amount, status=status)
|
||||||
|
session.add(bill)
|
||||||
|
await session.commit()
|
||||||
|
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()
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from db.models.tickets import Ticket, TicketStatus
|
|
||||||
from schemas.tickets import NewTicketDTO
|
|
||||||
|
|
||||||
|
|
||||||
class TicketRepository:
|
|
||||||
async def get_ticket_by_user_id(
|
|
||||||
self, session: AsyncSession, user_id: int, status: Optional[TicketStatus] = None
|
|
||||||
) -> list[Ticket]:
|
|
||||||
stmt = select(Ticket).where(Ticket.creator_id == user_id)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(Ticket.status == status)
|
|
||||||
res = await session.execute(stmt)
|
|
||||||
|
|
||||||
return list(res.scalars().all())
|
|
||||||
|
|
||||||
async def get_available_ticket_by_user_id(
|
|
||||||
self, session: AsyncSession, user_id: int
|
|
||||||
) -> Optional[Ticket]:
|
|
||||||
stmt = (
|
|
||||||
select(Ticket)
|
|
||||||
.where(Ticket.creator_id == user_id)
|
|
||||||
.where(Ticket.status != TicketStatus.CLOSED)
|
|
||||||
)
|
|
||||||
res = await session.execute(stmt)
|
|
||||||
|
|
||||||
tickets = list(res.scalars().all())
|
|
||||||
return tickets[0] if tickets else None
|
|
||||||
|
|
||||||
async def get_ticket_by_thread_id(
|
|
||||||
self, session: AsyncSession, thread_id: int
|
|
||||||
) -> Optional[Ticket]:
|
|
||||||
stmt = select(Ticket).where(Ticket.thread_id == thread_id)
|
|
||||||
res = await session.execute(stmt)
|
|
||||||
|
|
||||||
return res.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def create_ticket(
|
|
||||||
self, session: AsyncSession, ticket_data: NewTicketDTO
|
|
||||||
) -> Ticket:
|
|
||||||
ticket = Ticket(creator_id=ticket_data.creator_id)
|
|
||||||
session.add(ticket)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return ticket
|
|
||||||
|
|
||||||
async def update_ticket_status(
|
|
||||||
self, session: AsyncSession, ticket: Ticket, status: TicketStatus
|
|
||||||
):
|
|
||||||
ticket.status = status
|
|
||||||
await session.commit()
|
|
||||||
return ticket
|
|
||||||
|
|
||||||
async def update_ticket_thread_id(
|
|
||||||
self, session: AsyncSession, ticket: Ticket, thread_id: int
|
|
||||||
):
|
|
||||||
ticket.thread_id = thread_id
|
|
||||||
await session.commit()
|
|
||||||
return ticket
|
|
||||||
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,22 +1,33 @@
|
|||||||
from typing import Optional
|
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
|
||||||
|
|
||||||
|
|
||||||
class UserRepository:
|
class UserRepository:
|
||||||
async def get_user_by_id(
|
async def get_users(self, session: AsyncSession) -> list[User]:
|
||||||
self, session: AsyncSession, user_id: int
|
stmt = select(User)
|
||||||
) -> Optional[User]:
|
res = await session.execute(stmt)
|
||||||
|
|
||||||
|
return list(res.scalars().all())
|
||||||
|
|
||||||
|
async def get_user_by_id(self, session: AsyncSession, user_id: int) -> User | None:
|
||||||
stmt = select(User).where(User.id == user_id)
|
stmt = select(User).where(User.id == user_id)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
|
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def create_user(self, session: AsyncSession, user_data: NewUserDTO) -> User:
|
async def create_user(self, session: AsyncSession, user_data: NewUserDTO) -> User:
|
||||||
user = User(id=user_data.id, referal_id=user_data.referal)
|
user = User(
|
||||||
|
id=user_data.id,
|
||||||
|
referal_id=user_data.referal,
|
||||||
|
subscription_link=user_data.link,
|
||||||
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -24,7 +35,7 @@ class UserRepository:
|
|||||||
|
|
||||||
async def update_user_topic(
|
async def update_user_topic(
|
||||||
self, session: AsyncSession, user_id: int, thread_id: int
|
self, session: AsyncSession, user_id: int, thread_id: int
|
||||||
) -> Optional[User]:
|
) -> 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
|
||||||
@@ -32,3 +43,88 @@ class UserRepository:
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
async def update_user_link(self, session: AsyncSession, user_id: int, link: str) -> User | None:
|
||||||
|
user = await self.get_user_by_id(session, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.subscription_link = link
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def update_user_balance(
|
||||||
|
self, session: AsyncSession, user_id: int, balance: int
|
||||||
|
) -> User | None:
|
||||||
|
user = await self.get_user_by_id(session, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.balance = balance
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def increase_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:
|
||||||
|
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 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
|
||||||
13
schemas/billing.py
Normal file
13
schemas/billing.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class BillingProviders(Enum):
|
||||||
|
PALLY = "pally"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingContext:
|
||||||
|
amount: int
|
||||||
|
bill_id: int | None = None
|
||||||
|
provider: BillingProviders | None = BillingProviders.PALLY
|
||||||
8
schemas/common.py
Normal file
8
schemas/common.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeneralMessageContext:
|
||||||
|
msg: Message
|
||||||
@@ -1,14 +1,31 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from repositories.tickets import TicketRepository
|
|
||||||
from repositories.users import UserRepository
|
|
||||||
from services.user_service import UserService
|
|
||||||
from remnawave import RemnawaveSDK
|
from remnawave import RemnawaveSDK
|
||||||
|
|
||||||
|
from misc.pally import PallyClient
|
||||||
|
from repositories.bills import BillsRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
|
from repositories.users import UserRepository
|
||||||
|
from services.billing_service import BillingService
|
||||||
|
from services.renewal_service import RenewalService
|
||||||
|
from services.user_service import UserService
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DependenciesDTO:
|
class DependenciesDTO:
|
||||||
ticket_repository: TicketRepository
|
|
||||||
user_repository: UserRepository
|
user_repository: UserRepository
|
||||||
user_service: UserService
|
user_service: UserService
|
||||||
|
bills_repository: BillsRepository
|
||||||
|
billing_service: BillingService
|
||||||
|
subscriptions_repository: SubscriptionRepository
|
||||||
|
pally_client: PallyClient
|
||||||
rw_sdk: RemnawaveSDK
|
rw_sdk: RemnawaveSDK
|
||||||
|
renewal_service: RenewalService
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class APIDependenciesDTO:
|
||||||
|
user_repository: UserRepository
|
||||||
|
user_service: UserService
|
||||||
|
bills_repository: BillsRepository
|
||||||
|
billing_service: BillingService
|
||||||
|
|||||||
9
schemas/promotions.py
Normal file
9
schemas/promotions.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NewPromotionContext:
|
||||||
|
msg: Message
|
||||||
|
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
|
||||||
|
|
||||||
@@ -16,3 +16,14 @@ class SubscriptionPlan(BaseModel):
|
|||||||
devices: int = settings.min_devices
|
devices: int = settings.min_devices
|
||||||
duration: SubscriptionDuration = SubscriptionDuration.MONTH
|
duration: SubscriptionDuration = SubscriptionDuration.MONTH
|
||||||
whitelists: bool = False
|
whitelists: bool = False
|
||||||
|
discount: float = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionStatus(StrEnum):
|
||||||
|
EXPIRED = "expired"
|
||||||
|
ACTIVE = "active"
|
||||||
|
|
||||||
|
|
||||||
|
class InternalSquads(StrEnum):
|
||||||
|
WHITELISTS = settings.whitelist_squad
|
||||||
|
DEFAULT = settings.general_squad
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class NewTicketDTO:
|
|
||||||
creator_id: int
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class NewUserDTO(BaseModel):
|
||||||
class NewUserDTO:
|
|
||||||
id: int
|
id: int
|
||||||
referal: int
|
referal: int | None = None
|
||||||
|
link: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TicketCreator:
|
class TicketCreator:
|
||||||
id: int
|
id: int
|
||||||
username: Optional[str] = None
|
username: 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)}"
|
||||||
|
),
|
||||||
|
)
|
||||||
41
services/billing_service.py
Normal file
41
services/billing_service.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from misc.pally import BillCreateResponse, Currency, PallyAPIError, PallyClient
|
||||||
|
from repositories import UserRepository
|
||||||
|
from repositories.bills import BillsRepository
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingService:
|
||||||
|
def __init__(self, user_repository: UserRepository, bills_repository: BillsRepository) -> None:
|
||||||
|
self.user_repository = user_repository
|
||||||
|
self.bills_repository = bills_repository
|
||||||
|
|
||||||
|
async def pally_initiate_payment(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
pally_client: PallyClient,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
amount: int,
|
||||||
|
currency: Currency | None = Currency.RUB,
|
||||||
|
) -> BillCreateResponse | None:
|
||||||
|
db_bill = await self.bills_repository.create(session, creator_id=user_id, amount=amount)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pally_client as pl:
|
||||||
|
bill = await pl.bills.create(
|
||||||
|
db_bill.amount,
|
||||||
|
shop_id=settings.pally_shop_id,
|
||||||
|
order_id=db_bill.id,
|
||||||
|
currency_in=currency,
|
||||||
|
)
|
||||||
|
except PallyAPIError:
|
||||||
|
logger.exception("caught an exception while trying to create a bill:")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return bill
|
||||||
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
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Optional
|
|
||||||
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__)
|
||||||
|
|
||||||
@@ -23,9 +28,10 @@ class RWUserInfo:
|
|||||||
expire_at: datetime
|
expire_at: datetime
|
||||||
used_traffic_bytes: float
|
used_traffic_bytes: float
|
||||||
traffic_limit_bytes: int # 0 = unlimited
|
traffic_limit_bytes: int # 0 = unlimited
|
||||||
hwid_device_limit: Optional[int] # None = unlimited
|
hwid_device_limit: int | None # None = unlimited
|
||||||
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
|
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
|
||||||
description: Optional[str]
|
description: str | None
|
||||||
|
short_uuid: str
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
@@ -61,6 +67,7 @@ def _parse_user(user_dto) -> RWUserInfo:
|
|||||||
hwid_device_limit=user_dto.hwid_device_limit,
|
hwid_device_limit=user_dto.hwid_device_limit,
|
||||||
active_squads=active_squads,
|
active_squads=active_squads,
|
||||||
description=user_dto.description,
|
description=user_dto.description,
|
||||||
|
short_uuid=user_dto.short_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +79,7 @@ def _parse_user(user_dto) -> RWUserInfo:
|
|||||||
async def get_user_by_telegram_id(
|
async def get_user_by_telegram_id(
|
||||||
sdk: RemnawaveSDK,
|
sdk: RemnawaveSDK,
|
||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
) -> Optional[RWUserInfo]:
|
) -> RWUserInfo | None:
|
||||||
"""
|
"""
|
||||||
Получить первого пользователя Remnawave, привязанного к Telegram ID.
|
Получить первого пользователя Remnawave, привязанного к Telegram ID.
|
||||||
Возвращает None, если пользователь не найден или Remnawave недоступен.
|
Возвращает None, если пользователь не найден или Remnawave недоступен.
|
||||||
@@ -113,7 +120,7 @@ async def add_days(
|
|||||||
sdk: RemnawaveSDK,
|
sdk: RemnawaveSDK,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
days: int,
|
days: int,
|
||||||
) -> Optional[datetime]:
|
) -> datetime | None:
|
||||||
"""
|
"""
|
||||||
Добавить N дней к expire_at пользователя.
|
Добавить N дней к expire_at пользователя.
|
||||||
Возвращает новую дату окончания или None при ошибке.
|
Возвращает новую дату окончания или None при ошибке.
|
||||||
@@ -129,7 +136,7 @@ async def add_days(
|
|||||||
# Обеспечиваем timezone-aware datetime
|
# Обеспечиваем timezone-aware datetime
|
||||||
current_expire = user_dto.expire_at
|
current_expire = user_dto.expire_at
|
||||||
if current_expire.tzinfo is None:
|
if current_expire.tzinfo is None:
|
||||||
current_expire = current_expire.replace(tzinfo=timezone.utc)
|
current_expire = current_expire.replace(tzinfo=UTC)
|
||||||
|
|
||||||
new_expire = current_expire + timedelta(days=days)
|
new_expire = current_expire + timedelta(days=days)
|
||||||
|
|
||||||
@@ -144,7 +151,7 @@ async def add_days(
|
|||||||
|
|
||||||
new_dt = result.expire_at
|
new_dt = result.expire_at
|
||||||
if new_dt is not None and new_dt.tzinfo is None:
|
if new_dt is not None and new_dt.tzinfo is None:
|
||||||
new_dt = new_dt.replace(tzinfo=timezone.utc)
|
new_dt = new_dt.replace(tzinfo=UTC)
|
||||||
return new_dt
|
return new_dt
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc)
|
logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc)
|
||||||
@@ -155,7 +162,7 @@ async def remove_days(
|
|||||||
sdk: RemnawaveSDK,
|
sdk: RemnawaveSDK,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
days: int,
|
days: int,
|
||||||
) -> Optional[datetime]:
|
) -> datetime | None:
|
||||||
"""
|
"""
|
||||||
Вычесть N дней из expire_at пользователя.
|
Вычесть N дней из expire_at пользователя.
|
||||||
Возвращает новую дату окончания или None при ошибке.
|
Возвращает новую дату окончания или None при ошибке.
|
||||||
@@ -168,7 +175,7 @@ async def remove_days(
|
|||||||
|
|
||||||
current_expire = user_dto.expire_at
|
current_expire = user_dto.expire_at
|
||||||
if current_expire.tzinfo is None:
|
if current_expire.tzinfo is None:
|
||||||
current_expire = current_expire.replace(tzinfo=timezone.utc)
|
current_expire = current_expire.replace(tzinfo=UTC)
|
||||||
|
|
||||||
new_expire = current_expire - timedelta(days=days)
|
new_expire = current_expire - timedelta(days=days)
|
||||||
|
|
||||||
@@ -183,7 +190,7 @@ async def remove_days(
|
|||||||
|
|
||||||
new_dt = result.expire_at
|
new_dt = result.expire_at
|
||||||
if new_dt is not None and new_dt.tzinfo is None:
|
if new_dt is not None and new_dt.tzinfo is None:
|
||||||
new_dt = new_dt.replace(tzinfo=timezone.utc)
|
new_dt = new_dt.replace(tzinfo=UTC)
|
||||||
return new_dt
|
return new_dt
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc)
|
logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc)
|
||||||
@@ -201,7 +208,7 @@ async def set_hwid_limit(
|
|||||||
Возвращает True при успехе, False при ошибке.
|
Возвращает True при успехе, False при ошибке.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
hwid_value: Optional[int] = None if limit == 0 else limit
|
hwid_value: int | None = None if limit == 0 else limit
|
||||||
await sdk.users.update_user(
|
await sdk.users.update_user(
|
||||||
UpdateUserRequestDto(
|
UpdateUserRequestDto(
|
||||||
uuid=UUID(user_uuid),
|
uuid=UUID(user_uuid),
|
||||||
@@ -318,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,196 +1,253 @@
|
|||||||
from typing import Optional
|
import datetime
|
||||||
from aiogram.types import Message, ReactionTypeEmoji
|
import logging
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
import math
|
||||||
|
|
||||||
|
from aiogram.types import Message, User as TelegramUser
|
||||||
from remnawave import RemnawaveSDK
|
from remnawave import RemnawaveSDK
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
|
||||||
from config import settings
|
from config import settings
|
||||||
from db.models.tickets import TicketStatus
|
from db.models import Subscription
|
||||||
from db.models.users import User
|
from db.models.users import User
|
||||||
from schemas.tickets import NewTicketDTO
|
|
||||||
from schemas.users import NewUserDTO, TicketCreator
|
|
||||||
from repositories import UserRepository
|
|
||||||
from repositories.tickets import TicketRepository
|
|
||||||
from services.rw import RWUserInfo, get_user_by_telegram_id
|
|
||||||
from texts import (
|
|
||||||
ADMIN_STANDARD_FALLBACK,
|
|
||||||
ADMIN_TICKET_WITH_RW,
|
|
||||||
SUPPORT_SIGNATURE,
|
|
||||||
TICKET_STATUS_EMOJIS,
|
|
||||||
ADMIN_TICKET_WITHOUT_RW,
|
|
||||||
)
|
|
||||||
from misc import utils
|
from misc import utils
|
||||||
|
from repositories import UserRepository
|
||||||
|
from repositories.subscriptions import SubscriptionRepository
|
||||||
|
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
|
||||||
|
from schemas.users import NewUserDTO
|
||||||
|
from services import rw
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UserServiceException(Exception): ...
|
class UserServiceError(Exception): ...
|
||||||
|
|
||||||
|
|
||||||
class UserService:
|
class UserService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, user_repository: UserRepository, ticket_repository: TicketRepository
|
self, user_repository: UserRepository, subscription_repository: SubscriptionRepository
|
||||||
) -> None:
|
) -> None:
|
||||||
self.user_repository = user_repository
|
self.user_repository = user_repository
|
||||||
self.ticket_repository = ticket_repository
|
self.subscription_repository = subscription_repository
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_ticket_name(
|
def format_subscription_message(
|
||||||
creator: TicketCreator,
|
rw_user: rw.RWUserInfo | None, link: str | None = None, autorenew: bool = False
|
||||||
status: Optional[TicketStatus] = TicketStatus.OPEN,
|
|
||||||
ticket_id: Optional[int] = None,
|
|
||||||
):
|
):
|
||||||
return (
|
|
||||||
TICKET_STATUS_EMOJIS[status.value]
|
|
||||||
+ (f" T-{ticket_id:0>3}" if ticket_id else "")
|
|
||||||
+ " | "
|
|
||||||
+ (f"@{creator.username}" if creator.username else str(creator.id))
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def format_admin_support_message(
|
|
||||||
ticket_number: int, user: User, rw_user: Optional[RWUserInfo]
|
|
||||||
):
|
|
||||||
username_display = utils.format_username(user.username)
|
|
||||||
|
|
||||||
if rw_user is None:
|
if rw_user is None:
|
||||||
# Пользователь не найден в Remnawave
|
# Пользователь не найден в Remnawave
|
||||||
return ADMIN_TICKET_WITHOUT_RW.format(
|
return NO_SUBSCRIPTION
|
||||||
ticket_number=ticket_number,
|
|
||||||
full_name=user.full_name,
|
|
||||||
username_display=username_display,
|
|
||||||
telegram_id=user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
status_icon, status_text = utils.format_status(rw_user.status)
|
status_icon, status_text = utils.format_status(rw_user.status)
|
||||||
expire_date = rw_user.expire_at.strftime("%d.%m.%Y")
|
expire_date = rw_user.expire_at.strftime("%d.%m.%Y")
|
||||||
days_left = utils.format_days_left(rw_user.expire_at)
|
days_left = utils.format_days_left(rw_user.expire_at)
|
||||||
used_traffic = utils.format_traffic(
|
used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes)
|
||||||
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)
|
||||||
squads = utils.format_squads(rw_user.active_squads)
|
link = link or NOTHING_PLACEHOLDER
|
||||||
|
autorenew_text = "🟢" if autorenew else "🔴"
|
||||||
|
|
||||||
return ADMIN_TICKET_WITH_RW.format(
|
return SUBSCRIPTION_INFORMATION.format(
|
||||||
ticket_number=ticket_number,
|
|
||||||
full_name=user.full_name,
|
|
||||||
username_display=username_display,
|
|
||||||
telegram_id=user.id,
|
|
||||||
status_icon=status_icon,
|
status_icon=status_icon,
|
||||||
status=status_text,
|
status=status_text,
|
||||||
expire_date=expire_date,
|
expire_date=expire_date,
|
||||||
days_left=days_left,
|
days_left=days_left,
|
||||||
used_traffic=used_traffic,
|
used_traffic=used_traffic,
|
||||||
hwid_limit=hwid_limit,
|
hwid_limit=hwid_limit,
|
||||||
squads=squads,
|
link=link,
|
||||||
remnawave_uuid=rw_user.uuid,
|
autorenew=autorenew_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_user(
|
async def add_user(
|
||||||
self, session: AsyncSession, user_id: int, referal: Optional[int] = None
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
rw_sdk: RemnawaveSDK,
|
||||||
|
referal: int | None = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
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 user:
|
if user:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
referal = int(referal) if str(referal).isdigit() else None
|
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
|
||||||
model = NewUserDTO(id=user_id, referal=referal)
|
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
|
||||||
|
|
||||||
|
model = NewUserDTO(id=user_id, referal=referal, link=link)
|
||||||
return await self.user_repository.create_user(session, model)
|
return await self.user_repository.create_user(session, model)
|
||||||
|
|
||||||
async def support_forward_message(
|
async def update_user_link(
|
||||||
self, session: AsyncSession, msg: Message, rw: RemnawaveSDK
|
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
|
||||||
):
|
) -> User | None:
|
||||||
user = await self.user_repository.get_user_by_id(session, msg.from_user.id)
|
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
|
||||||
if not user:
|
|
||||||
raise UserServiceException("User %d is not found.", msg.from_user.id)
|
|
||||||
|
|
||||||
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
|
user = await self.user_repository.get_user_by_id(session, user_id)
|
||||||
|
|
||||||
|
link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
|
||||||
|
user = await self.user_repository.update_user_link(session, user_id, link)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def send_promotion_to_users(self, session: AsyncSession, msg: Message) -> list[User]:
|
||||||
|
users = await self.user_repository.get_users(session)
|
||||||
|
|
||||||
|
sent: list[User] = []
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
await msg.copy_to(user.id)
|
||||||
|
sent.append(user)
|
||||||
|
except Exception:
|
||||||
|
logger.critical("failed to send a message to user %d.", user.id, exc_info=True)
|
||||||
|
|
||||||
|
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
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
if ticket:
|
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:
|
try:
|
||||||
await msg.copy_to(
|
await promo_msg.copy_to(user.id)
|
||||||
chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id
|
sent += 1
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.ticket_repository.update_ticket_status(
|
logger.exception("caught an exception while launching promo, skipping user...")
|
||||||
session, ticket, TicketStatus.CLOSED
|
|
||||||
)
|
|
||||||
|
|
||||||
rw_user = await get_user_by_telegram_id(rw, user.id)
|
return sent
|
||||||
creator = TicketCreator(id=user.id, username=msg.from_user.username)
|
|
||||||
|
|
||||||
ticket_dto = NewTicketDTO(
|
def validate_db_subscription(self, subscription: Subscription, rw_user: rw.RWUserInfo):
|
||||||
creator_id=user.id,
|
return all(
|
||||||
)
|
[
|
||||||
ticket = await self.ticket_repository.create_ticket(
|
(rw_user.status != "ACTIVE" and subscription.status == SubscriptionStatus.EXPIRED)
|
||||||
session, ticket_data=ticket_dto
|
or (
|
||||||
)
|
rw_user.status == "ACTIVE" and subscription.status == SubscriptionStatus.ACTIVE
|
||||||
|
|
||||||
thread = await msg.bot.create_forum_topic(
|
|
||||||
settings.admin_group_id,
|
|
||||||
self.format_ticket_name(
|
|
||||||
creator, ticket_id=ticket.id, status=TicketStatus.PENDING
|
|
||||||
),
|
|
||||||
)
|
|
||||||
ticket = await self.ticket_repository.update_ticket_thread_id(
|
|
||||||
session, ticket, thread.message_thread_id
|
|
||||||
)
|
|
||||||
|
|
||||||
await msg.bot.send_message(
|
|
||||||
settings.admin_group_id,
|
|
||||||
message_thread_id=thread.message_thread_id,
|
|
||||||
text=await self.format_admin_support_message(
|
|
||||||
ticket_number=ticket.id, user=msg.from_user, rw_user=rw_user
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await msg.copy_to(
|
|
||||||
settings.admin_group_id, message_thread_id=thread.message_thread_id
|
|
||||||
)
|
|
||||||
|
|
||||||
async def support_answer(self, session: AsyncSession, msg: Message):
|
|
||||||
thread_id = msg.message_thread_id
|
|
||||||
ticket = await self.ticket_repository.get_ticket_by_thread_id(
|
|
||||||
session, thread_id=thread_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if ticket.status == TicketStatus.PENDING:
|
|
||||||
await self.ticket_repository.update_ticket_status(
|
|
||||||
session, ticket, TicketStatus.OPEN
|
|
||||||
)
|
|
||||||
await msg.bot.edit_forum_topic(
|
|
||||||
settings.admin_group_id,
|
|
||||||
ticket.thread_id,
|
|
||||||
name=self.format_ticket_name(
|
|
||||||
TicketCreator(ticket.creator_id), ticket_id=ticket.id
|
|
||||||
),
|
),
|
||||||
)
|
rw_user.expire_at == subscription.end_date,
|
||||||
|
]
|
||||||
try:
|
|
||||||
await msg.bot.send_message(ticket.creator_id, SUPPORT_SIGNATURE)
|
|
||||||
await msg.copy_to(ticket.creator_id)
|
|
||||||
await msg.react([ReactionTypeEmoji(emoji="⚡")])
|
|
||||||
except Exception:
|
|
||||||
await msg.reply(ADMIN_STANDARD_FALLBACK)
|
|
||||||
|
|
||||||
async def close_ticket(self, session: AsyncSession, msg: Message):
|
|
||||||
thread_id = msg.message_thread_id
|
|
||||||
ticket = await self.ticket_repository.get_ticket_by_thread_id(
|
|
||||||
session, thread_id=thread_id
|
|
||||||
)
|
|
||||||
|
|
||||||
await msg.bot.edit_forum_topic(
|
|
||||||
settings.admin_group_id,
|
|
||||||
ticket.thread_id,
|
|
||||||
name=self.format_ticket_name(
|
|
||||||
TicketCreator(ticket.creator_id),
|
|
||||||
ticket_id=ticket.id,
|
|
||||||
status=TicketStatus.CLOSED,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.ticket_repository.update_ticket_status(
|
|
||||||
session, ticket, TicketStatus.CLOSED
|
|
||||||
)
|
|
||||||
await msg.bot.close_forum_topic(
|
|
||||||
settings.admin_group_id, message_thread_id=thread_id
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
from aiogram.fsm.state import StatesGroup, State
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStorage(StatesGroup):
|
|
||||||
devices = State()
|
|
||||||
duration = State()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from aiogram.fsm.state import StatesGroup, State
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
138
texts.py
138
texts.py
@@ -1,138 +0,0 @@
|
|||||||
from misc.utils import fetch_quote
|
|
||||||
from db.models.tickets import TicketStatus
|
|
||||||
|
|
||||||
|
|
||||||
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>'
|
|
||||||
|
|
||||||
|
|
||||||
## EMOJIS
|
|
||||||
|
|
||||||
|
|
||||||
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
|
|
||||||
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
|
|
||||||
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
|
|
||||||
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
|
|
||||||
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
|
|
||||||
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
|
|
||||||
|
|
||||||
## MAPPING
|
|
||||||
TICKET_STATUS_EMOJIS = {
|
|
||||||
TicketStatus.CLOSED.value: "🔴",
|
|
||||||
TicketStatus.OPEN.value: "🟢",
|
|
||||||
TicketStatus.PENDING.value: "🟡",
|
|
||||||
}
|
|
||||||
|
|
||||||
## TEXTS
|
|
||||||
MAIN_MENU = (
|
|
||||||
str(MALENIA_LOGO)
|
|
||||||
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
|
|
||||||
+ f"<i>{MALENIA_COINS} Личный кабинет</i>"
|
|
||||||
)
|
|
||||||
STANDARD_FALLBACK = (
|
|
||||||
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
|
|
||||||
)
|
|
||||||
|
|
||||||
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
|
|
||||||
CONTEXT_REINITIATED = "🔴 Контекст данного взаимодействия сброшен. Данные могут отличаться от указанных вами."
|
|
||||||
|
|
||||||
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
|
|
||||||
|
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR = (
|
|
||||||
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
|
|
||||||
)
|
|
||||||
WHITELISTS_ALREADY_ON = "уже включены!"
|
|
||||||
SUBSCRIPTION_DURATION_SELECTOR = (
|
|
||||||
"тут короче сколько время. а ещё цена смотри ОПА {price}"
|
|
||||||
)
|
|
||||||
|
|
||||||
SUPPORT_INIT = f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n<i>❌ Для отмены — /cancel</i>"
|
|
||||||
SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время."
|
|
||||||
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
|
|
||||||
ADMIN_TICKET_WITH_RW = (
|
|
||||||
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
|
||||||
"👤 <b>Имя:</b> {full_name}\n"
|
|
||||||
"🔗 <b>Username:</b> {username_display}\n"
|
|
||||||
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
|
|
||||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
|
||||||
"📋 <b>ПОДПИСКА REMNAWAVE</b>\n\n"
|
|
||||||
"{status_icon} <b>Статус:</b> {status}\n"
|
|
||||||
"📅 <b>Истекает:</b> {expire_date}\n"
|
|
||||||
"⏳ <b>Осталось:</b> {days_left}\n"
|
|
||||||
"📊 <b>Трафик:</b> {used_traffic}\n"
|
|
||||||
"📱 <b>HWID лимит:</b> {hwid_limit}\n"
|
|
||||||
"🎯 <b>Internal Squads:</b> {squads}\n\n"
|
|
||||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
|
||||||
"🔑 <b>UUID в Remnawave:</b> <code>{remnawave_uuid}</code>"
|
|
||||||
)
|
|
||||||
ADMIN_TICKET_WITHOUT_RW = (
|
|
||||||
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
|
||||||
"👤 <b>Имя:</b> {full_name}\n"
|
|
||||||
"🔗 <b>Username:</b> {username_display}\n"
|
|
||||||
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
|
|
||||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
|
||||||
"⚠️ <b>Аккаунт в Remnawave не найден</b>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
ADMIN_STANDARD_FALLBACK = (
|
|
||||||
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
## FUNCTIONS
|
|
||||||
def build_main_menu_text() -> str:
|
|
||||||
return MAIN_MENU.format(quote=fetch_quote())
|
|
||||||
|
|
||||||
|
|
||||||
NO_USERNAME = "нет юзернейма"
|
|
||||||
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} дн.
|
|
||||||
DAYS_LEFT_POSITIVE = "{days} дн."
|
|
||||||
|
|
||||||
# Когда подписка уже истекла
|
|
||||||
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
|
|
||||||
|
|
||||||
# Когда подписка истекает сегодня
|
|
||||||
DAYS_LEFT_TODAY = "истекает сегодня"
|
|
||||||
|
|
||||||
# Когда нет username в Telegram
|
|
||||||
NO_USERNAME = "нет username"
|
|
||||||
|
|
||||||
# Формат отображения username (с @)
|
|
||||||
USERNAME_DISPLAY = "@{username}"
|
|
||||||
|
|
||||||
# Когда у пользователя нет Internal Squads
|
|
||||||
NO_SQUADS = "нет"
|
|
||||||
|
|
||||||
# Когда лимит HWID не установлен (0 или None)
|
|
||||||
UNLIMITED_HWID = "без ограничений"
|
|
||||||
|
|
||||||
# Когда лимит трафика не установлен
|
|
||||||
NO_TRAFFIC_LIMIT = "без ограничений"
|
|
||||||
|
|
||||||
# Шаблон для отображения трафика с лимитом: {used} / {limit}
|
|
||||||
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
|
|
||||||
|
|
||||||
# Шаблон для отображения трафика без лимита: {used}
|
|
||||||
TRAFFIC_NO_LIMIT = "{used}"
|
|
||||||
|
|
||||||
|
|
||||||
NOTHING_PLACEHOLDER = "🍃"
|
|
||||||
Reference in New Issue
Block a user