aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTolmachev Igor <me@igorek.dev>2026-03-23 18:40:40 +0300
committerTolmachev Igor <me@igorek.dev>2026-03-23 18:40:40 +0300
commitd5994e732d7b1dfa469cf400132ba49c8f75315e (patch)
treea091b84babf7829ae6193d78fb8dc090336b0f9b
parentf7b7e87cffc9dcb2817b070d7a003ac234c96ec3 (diff)
downloadvpn_manager_bot-d5994e732d7b1dfa469cf400132ba49c8f75315e.tar.gz
vpn_manager_bot-d5994e732d7b1dfa469cf400132ba49c8f75315e.zip
Add new_invoice command
-rw-r--r--alembic/versions/1627487324fd_init_database.py (renamed from alembic/versions/940afc736a0f_init_database.py)8
-rw-r--r--handlers/admin/__init__.py2
-rw-r--r--handlers/admin/new_announcement.py9
-rw-r--r--handlers/admin/new_invoice.py109
-rw-r--r--libs/msg.py37
-rw-r--r--models/__init__.py2
-rw-r--r--models/callback_data.py5
-rw-r--r--models/invoce.py13
-rw-r--r--models/rich_text.py10
9 files changed, 182 insertions, 13 deletions
diff --git a/alembic/versions/940afc736a0f_init_database.py b/alembic/versions/1627487324fd_init_database.py
index bfaa771..f25277f 100644
--- a/alembic/versions/940afc736a0f_init_database.py
+++ b/alembic/versions/1627487324fd_init_database.py
@@ -1,8 +1,8 @@
1"""init database 1"""init database
2 2
3Revision ID: 940afc736a0f 3Revision ID: 1627487324fd
4Revises: 4Revises:
5Create Date: 2026-03-23 02:21:35.245634 5Create Date: 2026-03-23 18:33:08.493629
6 6
7""" 7"""
8 8
@@ -13,7 +13,7 @@ import sqlalchemy as sa
13from alembic import op 13from alembic import op
14 14
15# revision identifiers, used by Alembic. 15# revision identifiers, used by Alembic.
16revision: str = "940afc736a0f" 16revision: str = "1627487324fd"
17down_revision: Union[str, Sequence[str], None] = None 17down_revision: Union[str, Sequence[str], None] = None
18branch_labels: Union[str, Sequence[str], None] = None 18branch_labels: Union[str, Sequence[str], None] = None
19depends_on: Union[str, Sequence[str], None] = None 19depends_on: Union[str, Sequence[str], None] = None
@@ -32,7 +32,7 @@ def upgrade() -> None:
32 op.create_table( 32 op.create_table(
33 "invoice", 33 "invoice",
34 sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 34 sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
35 sa.Column("amount", sa.Float(), nullable=False), 35 sa.Column("message", sa.JSON(), nullable=False),
36 sa.Column("datetime", sa.DateTime(), nullable=False), 36 sa.Column("datetime", sa.DateTime(), nullable=False),
37 sa.PrimaryKeyConstraint("id", name=op.f("pk_invoice")), 37 sa.PrimaryKeyConstraint("id", name=op.f("pk_invoice")),
38 ) 38 )
diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py
index 2f7c74f..98b127f 100644
--- a/handlers/admin/__init__.py
+++ b/handlers/admin/__init__.py
@@ -3,6 +3,7 @@ from aiogram.filters import MagicData
3 3
4# isort: off 4# isort: off
5from . import new_announcement 5from . import new_announcement
6from . import new_invoice
6# isort: on 7# isort: on
7 8
8router = Router(name="admin") 9router = Router(name="admin")
@@ -11,4 +12,5 @@ router.callback_query.filter(MagicData(F.user.is_admin()))
11 12
12router.include_routers( 13router.include_routers(
13 new_announcement.router, 14 new_announcement.router,
15 new_invoice.router,
14) 16)
diff --git a/handlers/admin/new_announcement.py b/handlers/admin/new_announcement.py
index 79cf8d4..0920c47 100644
--- a/handlers/admin/new_announcement.py
+++ b/handlers/admin/new_announcement.py
@@ -1,6 +1,7 @@
1from datetime import UTC, datetime 1from datetime import UTC, datetime
2 2
3from aiogram import Bot, F, Router 3from aiogram import Bot, F, Router
4from aiogram.enums import ButtonStyle
4from aiogram.exceptions import TelegramAPIError 5from aiogram.exceptions import TelegramAPIError
5from aiogram.filters import Command 6from aiogram.filters import Command
6from aiogram.fsm.context import FSMContext 7from aiogram.fsm.context import FSMContext
@@ -17,7 +18,7 @@ from sqlalchemy import select
17from sqlalchemy.ext.asyncio import AsyncSession 18from sqlalchemy.ext.asyncio import AsyncSession
18 19
19from libs.fsm import get_data, set_data 20from libs.fsm import get_data, set_data
20from libs.msg import publish 21from libs.msg import publish_announcement
21from models import Announcement, RichText, User 22from models import Announcement, RichText, User
22 23
23router = Router(name="new_announcement") 24router = Router(name="new_announcement")
@@ -42,8 +43,8 @@ async def new_announcement_command(msg: Message, state: FSMContext) -> None:
42 reply_markup=ReplyKeyboardMarkup( 43 reply_markup=ReplyKeyboardMarkup(
43 keyboard=[ 44 keyboard=[
44 [ 45 [
45 KeyboardButton(text=SEND_BUTTON), 46 KeyboardButton(text=SEND_BUTTON, style=ButtonStyle.SUCCESS),
46 KeyboardButton(text=CANCEL_BUTTON), 47 KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER),
47 ] 48 ]
48 ], 49 ],
49 resize_keyboard=True, 50 resize_keyboard=True,
@@ -69,7 +70,7 @@ async def announcement_send(
69 status_template = "Публикация анонса...\nОпубликовано: {}" 70 status_template = "Публикация анонса...\nОпубликовано: {}"
70 status_msg = await msg.answer(status_template.format(0)) 71 status_msg = await msg.answer(status_template.format(0))
71 72
72 async for n in publish(bot, users, data.rich_text): 73 async for n in publish_announcement(bot, users, data.rich_text):
73 try: 74 try:
74 await status_msg.edit_text(status_template.format(n)) 75 await status_msg.edit_text(status_template.format(n))
75 except TelegramAPIError: 76 except TelegramAPIError:
diff --git a/handlers/admin/new_invoice.py b/handlers/admin/new_invoice.py
new file mode 100644
index 0000000..7e1a64d
--- /dev/null
+++ b/handlers/admin/new_invoice.py
@@ -0,0 +1,109 @@
1from datetime import UTC, datetime
2
3from aiogram import Bot, F, Router
4from aiogram.enums import ButtonStyle, ContentType
5from aiogram.exceptions import TelegramAPIError
6from aiogram.filters import Command
7from aiogram.fsm.context import FSMContext
8from aiogram.fsm.state import State, StatesGroup
9from aiogram.types import (
10 KeyboardButton,
11 Message,
12 ReplyKeyboardMarkup,
13 ReplyKeyboardRemove,
14)
15from pydantic import BaseModel
16from sqlalchemy import select
17from sqlalchemy.ext.asyncio import AsyncSession
18
19from libs.fsm import get_data, set_data
20from libs.msg import send_invoice
21from models import Invoice, RichText, User
22
23router = Router(name="new_invoice")
24
25
26class NewInvoiceStates(StatesGroup):
27 message = State()
28
29
30class NewInvoiceData(BaseModel):
31 rich_text: RichText | None = None
32
33
34CREATE_BUTTON = "Создать"
35CANCEL_BUTTON = "Отменить создание"
36
37
38@router.message(Command("new_invoice"))
39async def new_invoice_command(msg: Message, state: FSMContext) -> None:
40 await msg.answer(
41 "Укажите сообщение для создания счёта",
42 reply_markup=ReplyKeyboardMarkup(
43 keyboard=[
44 [
45 KeyboardButton(text=CREATE_BUTTON, style=ButtonStyle.SUCCESS),
46 KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER),
47 ]
48 ],
49 resize_keyboard=True,
50 ),
51 )
52 await state.set_state(NewInvoiceStates.message)
53
54
55@router.message(NewInvoiceStates.message, F.text == CREATE_BUTTON)
56async def invoice_send(
57 msg: Message,
58 bot: Bot,
59 state: FSMContext,
60 session: AsyncSession,
61) -> None:
62 users = await session.scalars(select(User.id).where(User.id != msg.chat.id))
63 data = await get_data(state, NewInvoiceData)
64
65 if data.rich_text is None:
66 await msg.answer("Для создания счёта укажите сообщение.")
67 return
68
69 status_template = "Рассылка счёта...\nОтправлено: {}"
70 status_msg = await msg.answer(status_template.format(0))
71
72 invoice = Invoice(message=data, datetime=datetime.now(UTC))
73 session.add(invoice)
74 await session.flush()
75
76 async for n in send_invoice(bot, users, data.rich_text, invoice.id):
77 try:
78 await status_msg.edit_text(status_template.format(n))
79 except TelegramAPIError:
80 pass
81
82 await status_msg.delete()
83 await msg.answer(
84 "Счёт отправлен всем пользователям",
85 # reply_markup=ReplyKeyboardRemove(),
86 )
87 # await state.clear()
88
89
90@router.message(NewInvoiceStates.message, F.text == CANCEL_BUTTON)
91async def invoice_cancel(msg: Message, state: FSMContext) -> None:
92 await msg.answer("Создание счёта отменено", reply_markup=ReplyKeyboardRemove())
93 await state.clear()
94
95
96@router.message(NewInvoiceStates.message)
97async def invoice_message(msg: Message, bot: Bot, state: FSMContext) -> None:
98 if msg.content_type != ContentType.TEXT or msg.text is None:
99 await msg.answer(
100 "Неверный тип сообщения.\n"
101 "Бот поддерживает отправку только текстовых счетов."
102 )
103 return
104
105 rich_text = RichText.from_message(msg)
106 await set_data(state, NewInvoiceData(rich_text=rich_text))
107
108 msg_rich_text = RichText.from_text("Сообщение вашего счёта:\n", rich_text)
109 await msg_rich_text.send(bot, msg.chat.id)
diff --git a/libs/msg.py b/libs/msg.py
index 9bcc52a..05bddfc 100644
--- a/libs/msg.py
+++ b/libs/msg.py
@@ -2,12 +2,15 @@ import asyncio
2from typing import AsyncGenerator, Iterable 2from typing import AsyncGenerator, Iterable
3 3
4from aiogram import Bot 4from aiogram import Bot
5from aiogram.enums import ButtonStyle
5from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter 6from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
7from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
6 8
7from models import RichText 9from models import RichText
10from models.callback_data import PayInvoiceData
8 11
9 12
10async def publish( 13async def publish_announcement(
11 bot: Bot, 14 bot: Bot,
12 users: Iterable[int], 15 users: Iterable[int],
13 rich_text: RichText, 16 rich_text: RichText,
@@ -23,3 +26,35 @@ async def publish(
23 await asyncio.sleep(5) 26 await asyncio.sleep(5)
24 27
25 yield n 28 yield n
29
30
31async def send_invoice(
32 bot: Bot,
33 users: Iterable[int],
34 rich_text: RichText,
35 invoice_id: int,
36) -> AsyncGenerator[int]:
37 callback_data = PayInvoiceData(invoice_id=invoice_id).pack()
38 reply_markup = InlineKeyboardMarkup(
39 inline_keyboard=[
40 [
41 InlineKeyboardButton(
42 text="Оплатить",
43 style=ButtonStyle.PRIMARY,
44 callback_data=callback_data,
45 )
46 ]
47 ]
48 )
49
50 for n, user_id in enumerate(users, start=1):
51 for _ in range(5):
52 try:
53 await rich_text.send(bot, user_id, reply_markup=reply_markup)
54 break
55 except TelegramRetryAfter as e:
56 await asyncio.sleep(e.retry_after + 1)
57 except TelegramAPIError:
58 await asyncio.sleep(5)
59
60 yield n
diff --git a/models/__init__.py b/models/__init__.py
index 9d56b33..0547429 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -5,6 +5,7 @@ from .user import User
5from .invoce import Invoice 5from .invoce import Invoice
6from .payment import Payment 6from .payment import Payment
7from .announcement import Announcement 7from .announcement import Announcement
8from . import callback_data
8# isort: on 9# isort: on
9 10
10__all__ = [ 11__all__ = [
@@ -14,4 +15,5 @@ __all__ = [
14 "Payment", 15 "Payment",
15 "RichText", 16 "RichText",
16 "Announcement", 17 "Announcement",
18 "callback_data",
17] 19]
diff --git a/models/callback_data.py b/models/callback_data.py
new file mode 100644
index 0000000..d3e6d61
--- /dev/null
+++ b/models/callback_data.py
@@ -0,0 +1,5 @@
1from aiogram.filters.callback_data import CallbackData
2
3
4class PayInvoiceData(CallbackData, prefix="pay_invoice"):
5 invoice_id: int
diff --git a/models/invoce.py b/models/invoce.py
index 54a445e..66ff019 100644
--- a/models/invoce.py
+++ b/models/invoce.py
@@ -1,13 +1,22 @@
1from datetime import datetime 1from datetime import datetime
2 2
3from sqlalchemy import JSON
3from sqlalchemy.orm import Mapped, mapped_column 4from sqlalchemy.orm import Mapped, mapped_column
4 5
5from models import BaseTable 6from models import BaseTable, RichText
6 7
7 8
8class Invoice(BaseTable): 9class Invoice(BaseTable):
9 __tablename__ = "invoice" 10 __tablename__ = "invoice"
10 11
11 id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 12 id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
12 amount: Mapped[float] 13 __message: Mapped[str] = mapped_column("message", JSON())
13 datetime: Mapped[datetime] 14 datetime: Mapped[datetime]
15
16 @property
17 def message(self) -> RichText:
18 return RichText.model_validate_json(self.__message)
19
20 @message.setter
21 def message(self, value: RichText) -> None:
22 self.__message = value.model_dump_json()
diff --git a/models/rich_text.py b/models/rich_text.py
index e5441b6..6d1c1cb 100644
--- a/models/rich_text.py
+++ b/models/rich_text.py
@@ -1,7 +1,7 @@
1from typing import Self 1from typing import Self
2 2
3from aiogram import Bot 3from aiogram import Bot
4from aiogram.types import Message, MessageEntity 4from aiogram.types import Message, MessageEntity, ReplyMarkupUnion
5from pydantic import BaseModel 5from pydantic import BaseModel
6 6
7 7
@@ -34,10 +34,16 @@ class RichText(BaseModel):
34 34
35 return result 35 return result
36 36
37 async def send(self, bot: Bot, chat_id: int) -> Message: 37 async def send(
38 self,
39 bot: Bot,
40 chat_id: int,
41 reply_markup: ReplyMarkupUnion | None = None,
42 ) -> Message:
38 return await bot.send_message( 43 return await bot.send_message(
39 chat_id=chat_id, 44 chat_id=chat_id,
40 text=self.text, 45 text=self.text,
41 entities=self.entities, 46 entities=self.entities,
42 parse_mode=None, 47 parse_mode=None,
48 reply_markup=reply_markup,
43 ) 49 )