Compare commits

..

46 Commits

Author SHA1 Message Date
245741b182 feat(subscription): improve subscription sync and renewal logic. 2026-06-17 18:10:56 +07:00
7ab0e71108 style(keyboards): add emoji to device management button 2026-06-17 15:46:58 +07:00
3adcc90b74 chore(docker): remove exposed PostgreSQL port from compose 2026-06-17 15:45:01 +07:00
93f05e46b5 feat(bot): implement HWID device list and deletion 2026-06-17 15:40:35 +07:00
agony
edfe1e6874 feat(subs): UNFINISHED hwid control 2026-06-16 16:24:02 +07:00
greendevilll
7ab2293fe2 fix(notifications): multiply duration days by 30 for admin log accuracy 2026-06-06 23:49:41 +07:00
9552813f16 fix(autorenewal): remove duplicated response handling in toggle_autorenewal 2026-06-04 13:44:09 +07:00
4c5a16a3ca feat(admin): add admin notification system for deposits, purchases, and renewals 2026-06-04 12:35:32 +07:00
5ec9491e22 feat(pally): support customer-pays-fees scenario with BalanceAmount validation 2026-05-31 15:23:31 +07:00
18f994aed9 fix(pally): use InvId as primary bill identifier instead of custom field 2026-05-31 15:17:48 +07:00
936d1ad260 fix(api): enhance pally webhook with comprehensive error handling and logging 2026-05-31 15:11:54 +07:00
2a0f5c454e debugging 2026-05-31 14:53:49 +07:00
2103e6a882 Revert to b5976d0b21 2026-05-31 14:47:53 +07:00
102a0ebf96 fix(pally): strip 'payment-' prefix from InvId before database lookup 2026-05-31 14:44:43 +07:00
18f4997ca6 fix(pally): use InvId instead of custom field for order ID lookup 2026-05-31 14:37:34 +07:00
b5976d0b21 fix(pally): align webhook handler with official API spec 2026-05-31 14:25:05 +07:00
763e041a8b fix(api): change pally webhook endpoint from /success to /result 2026-05-31 14:14:17 +07:00
f9d3fbf937 refactor(api): update pally webhook endpoint path 2026-05-31 14:05:27 +07:00
a02af3b0f0 chore(docker): bind pally service port to localhost only 2026-05-31 13:16:04 +07:00
a0c8167da6 chore(docker): use .env instead of prod.env 2026-05-31 12:56:07 +07:00
717a1549e9 feat(ui): enhance text formatting with premium emojis and improve FAQ clarity 2026-05-24 15:43:54 +07:00
18866fc645 chore(texts): reorder constants and remove outdated mapping section 2026-05-23 21:26:20 +07:00
1228ec8b56 refactor(autorenewal): clean up imports and simplify error handling 2026-05-18 17:58:34 +07:00
a9a650f37b feat(config): mask sensitive credentials with SecretStr type 2026-05-18 17:48:34 +07:00
56f10d19c5 feat(autorenewal): implement automatic subscription renewal system 2026-05-18 17:32:04 +07:00
281a460974 refactoring: polished some files, .env.example update 2026-05-18 17:00:17 +07:00
acb8662a66 Refactor bot handlers and keyboards; implement admin features
- Removed unused prehandling, referrals, support, and states files.
- Updated admin and client handlers to improve structure and functionality.
- Introduced admin filters and routers for better access control.
- Added new billing and deposit functionalities for client interactions.
- Enhanced keyboard layouts for admin and client menus.
- Implemented promotion handling for admin users.
- Updated configuration to include admin settings.
- Improved utility functions for quote fetching and menu text building.
- Added common handlers for undefined commands and message deletions.
2026-05-13 10:34:06 +07:00
e9f84d9377 fix: enhance insufficient funds message and update subscription controls logic 2026-05-03 07:03:53 +07:00
ac61cec78c fix: added proxy support for the API telegram bot instance + removed deprecated plans 2026-05-02 13:51:27 +07:00
db2dabed58 feat: implement subscription management and billing enhancements 2026-04-29 20:10:26 +07:00
120e19e667 fix: fixed migration issues 2026-04-27 22:07:35 +07:00
fc656c5944 feat: remove exposed PostgreSQL port from docker-compose 2026-04-27 21:47:25 +07:00
287f8eebda feat: add subscription and transaction repositories
- Implemented SubscriptionRepository with methods to get, create, and update subscriptions.
- Added BalanceTXRepository for creating and retrieving balance transactions.
- Introduced a test script for simulating Pally webhook for local testing.
2026-04-27 21:33:25 +07:00
049f31118d feat: Implement payment notification system and API structure
- Added notifications for successful payments in `api/core/notifications.py`.
- Created main API entry point in `api/main.py` with FastAPI integration.
- Established routing structure in `api/routes/__init__.py` and `api/routes/pally.py` for handling payment callbacks.
- Developed billing handlers in `bot/handlers/billing.py` and `bot/handlers/buy.py` for subscription management.
- Introduced state management for user interactions in `bot/states/`.
- Created user interface elements in `bot/keyboards/` for navigation and payment processing.
- Set up middleware for dependency injection in `bot/middlewares/di.py`.
- Added support for user commands and interactions in `bot/handlers/common.py` and `bot/handlers/support.py`.
- Implemented logging and error handling throughout the bot's functionality.
- Created entry point script for API server in `entrypoints/api.sh`.
2026-04-24 17:41:42 +07:00
d98383285e feat: implement billing system with Pally integration
- Added billing models and repository for handling bills.
- Introduced BillingService to manage payment initiation and bill creation.
- Updated user service to remove ticket-related functionality.
- Refactored settings to include new billing-related fields.
- Removed ticket-related handlers and schemas.
- Added new billing handlers for processing payments.
- Updated database schema with new bills table and status enum.
- Enhanced utility functions to calculate prices considering discounts.
- Introduced prehandling for non-private messages.
- Updated main application to initialize new billing services and repositories.
2026-04-23 19:55:51 +07:00
8290d18235 feat: update support button to link directly to support bot 2026-04-22 20:44:26 +07:00
76fd6eb9a8 feat: update support keyboard with new support URL and replace return to menu button 2026-04-22 18:35:30 +07:00
e3e204d7af feat: enhance subscription management with discount calculations and update support messages 2026-04-22 17:41:44 +07:00
97c187bafb refactor: streamline referral payment message and improve text clarity 2026-04-18 22:47:24 +07:00
78a0ef9750 feat: implement referral payment system and enhance ticket management 2026-04-18 22:34:15 +07:00
3d19d9189b feat: add /support command and improve fallback message
- Add `/support` command handler to initiate support flow via direct command
  (previously only accessible via callback button)
- Update `UNDEFINED_MESSAGE` to guide users toward `/support` and `/start`
  when they use undefined commands
2026-04-15 20:36:49 +07:00
606aa4b89f feat: add admin promotion broadcast system and psql health check
- Add `/push` command for admins to broadcast messages to all users
  with approve/decline confirmation flow (handlers/ads.py)
- Add common handler for undefined commands and delete callback
- Add `UserRepository.get_users()` to fetch all users
- Add `UserService.send_promotion_to_users()` with error logging
- Add promotion confirmation keyboard and FSM state
- Add PostgreSQL availability check in startup.sh before migrations
- Add new text constants for promotion flow
2026-04-15 20:19:07 +07:00
0ac8ed8d64 Update project configuration and codebase 2026-04-12 12:19:53 +07:00
0ed0ad2b92 Add referral system and user balance tracking
Introduce balance field to User model with Alembic migration. Add
referral menu, admin /refpay command, and deep link sharing. Update
Dockerfile to multi-stage build and add Postgres service to compose.
Fix deep link parsing logic and conditional proxy initialization.
2026-04-10 22:20:53 +07:00
60de871e0c Add subscription link field to user model
- Add subscription_link column to users table (nullable, unique)
- Add SUBSCRIPTION_URL to config settings
- Update user creation to include subscription link from Remnawave
- Add support subscription ticket creation with formatted message
- Add "update link" button to main menu
- Refactor support subscription formatting to include user details
2026-04-09 21:09:29 +07:00
2bb12f2ff8 somethjijngs 2026-04-08 20:33:18 +07:00
95 changed files with 4380 additions and 924 deletions

47
.env.example Normal file
View 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
View File

@@ -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

View File

@@ -1 +1 @@
3.13 3.13.0

View File

@@ -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
View File

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

View File

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

View File

@@ -0,0 +1,34 @@
"""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 ###

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 ###

View File

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

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

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

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

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

58
api/main.py Normal file
View File

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

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

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

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

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

0
bot/__init__.py Normal file
View File

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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,
]

View 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
View 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)

View File

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

View File

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

View File

@@ -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),
)

View 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)

View File

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

View File

@@ -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()

View 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

View File

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

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

171
bot/keyboards/client.py Normal file
View 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
View File

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

87
bot/main.py Normal file
View 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())

View File

@@ -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
View File

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

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

6
bot/states/admins.py Normal file
View 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
View 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
View File

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

270
bot/texts.py Normal file
View File

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

View File

@@ -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

View File

@@ -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
View 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",
)

View File

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

View File

@@ -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
View File

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

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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
View File

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

22
entrypoints/dev.sh Normal file
View 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
View 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

View File

@@ -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]

View File

@@ -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.")
...

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
View File

@@ -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
View 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()

View File

@@ -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 int(subscription.whitelists) * settings.whitelist_cost
if subscription.devices < settings.whitelist_device_threshold if subscription.devices < settings.whitelist_device_threshold
else 0 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
View File

@@ -1 +0,0 @@
Мне нужно, чтобы ссылка для подключения пользователя была доступна в главном меню, но если при каждом /start я буду делать запрос, каждая обработка будет занимать по году. Как это реализовать?

59
pyproject.toml Normal file
View 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
View 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()

View File

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

View File

@@ -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

View File

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

View File

@@ -1,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())

View File

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

13
schemas/billing.py Normal file
View 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
View File

@@ -0,0 +1,8 @@
from dataclasses import dataclass
from aiogram.types import Message
@dataclass
class GeneralMessageContext:
msg: Message

View File

@@ -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
View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass
from aiogram.types import Message
@dataclass
class NewPromotionContext:
msg: Message
promotion: Message | None = None

View File

@@ -1,4 +1,4 @@
from enum import Enum from enum import Enum, StrEnum
from pydantic import BaseModel from pydantic import BaseModel
@@ -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

View File

@@ -1,6 +0,0 @@
from dataclasses import dataclass
@dataclass
class NewTicketDTO:
creator_id: int

View File

@@ -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

View File

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

View 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
View File

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

View File

@@ -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

View File

@@ -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 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 ticket: if not utils.is_same_plan(rw_user, subscription):
new_expire = datetime.datetime.now(datetime.UTC) + duration
delta_days = (new_expire - rw_user.expire_at).days
await rw.add_days(rw_sdk, rw_user.uuid, delta_days)
else:
await rw.add_days(rw_sdk, rw_user.uuid, duration.days)
await rw.update_squads(rw_sdk, rw_user.uuid, squads)
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices)
await self.update_user_link(session, user.id, rw_sdk)
return db_subscription
# Create
expire = datetime.datetime.now(datetime.UTC) + duration
username = "tg_" + str(user.username or user.id)
rw_user = await rw.create_user(
rw_sdk,
username=username,
telegram_id=user.id,
expire_at=expire,
hwid_device_limit=subscription.devices,
squad_uuids=squads,
)
if not rw_user:
raise Exception("RW User was NOT created, manual intervention required.")
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
session, user.id
)
if db_subscription:
db_subscription = await self.subscription_repository.update_sub(
session,
user.id,
end_date=expire,
autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
else:
db_subscription = await self.subscription_repository.create_subscription(
session,
user_id=user.id,
end_date=expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
await self.update_user_link(session, user.id, rw_sdk)
return db_subscription
async def calculate_plan_credit(
self,
session: AsyncSession,
user_id: int,
new_plan: SubscriptionPlan,
rw_sdk: RemnawaveSDK,
) -> int:
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
if not rw_user or rw_user.expire_at <= datetime.datetime.now(datetime.UTC):
return 0
if utils.is_same_plan(rw_user, new_plan):
return 0
db_sub = await self.subscription_repository.get_subscription_by_user_id(session, user_id)
if not db_sub or not db_sub.plan_price_paid or not db_sub.duration_days:
return 0
remaining_days = max(0, (rw_user.expire_at - datetime.datetime.now(datetime.UTC)).days)
return math.floor(db_sub.plan_price_paid * remaining_days / db_sub.duration_days)
async def launch_promotion(self, session: AsyncSession, promo_msg: Message) -> int:
users = await self.user_repository.get_users(session)
sent = 0
for user in users:
if user.id in settings.admins:
continue
try: 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
), ),
) rw_user.expire_at == subscription.end_date,
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
),
)
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
) )

View File

@@ -1,6 +0,0 @@
from aiogram.fsm.state import StatesGroup, State
class SubscriptionStorage(StatesGroup):
devices = State()
duration = State()

View File

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

106
tests/send_pally_webhook.py Normal file
View File

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

138
texts.py
View File

@@ -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 = "🍃"