aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTolmachev Igor <me@igorek.dev>2026-03-23 18:07:30 +0300
committerTolmachev Igor <me@igorek.dev>2026-03-23 18:07:30 +0300
commitf7b7e87cffc9dcb2817b070d7a003ac234c96ec3 (patch)
tree47f8af52edcac24f16ae58d0501bd2a4a42c2278
parent8e034766bb7e2d23f88c5ff1a254126f11a5f412 (diff)
downloadvpn_manager_bot-f7b7e87cffc9dcb2817b070d7a003ac234c96ec3.tar.gz
vpn_manager_bot-f7b7e87cffc9dcb2817b070d7a003ac234c96ec3.zip
Add new_announcement command
-rw-r--r--handlers/admin/__init__.py8
-rw-r--r--handlers/admin/new_announcement.py108
-rw-r--r--libs/__init__.py7
-rw-r--r--libs/fsm.py23
-rw-r--r--libs/msg.py25
-rw-r--r--libs/storage.py75
-rw-r--r--models/rich_text.py38
-rw-r--r--pyproject.toml1
-rw-r--r--settings.py9
-rw-r--r--shared.py5
-rw-r--r--uv.lock2
11 files changed, 296 insertions, 5 deletions
diff --git a/handlers/admin/__init__.py b/handlers/admin/__init__.py
index d0a5587..2f7c74f 100644
--- a/handlers/admin/__init__.py
+++ b/handlers/admin/__init__.py
@@ -1,6 +1,14 @@
1from aiogram import F, Router 1from aiogram import F, Router
2from aiogram.filters import MagicData 2from aiogram.filters import MagicData
3 3
4# isort: off
5from . import new_announcement
6# isort: on
7
4router = Router(name="admin") 8router = Router(name="admin")
5router.message.filter(MagicData(F.user.is_admin())) 9router.message.filter(MagicData(F.user.is_admin()))
6router.callback_query.filter(MagicData(F.user.is_admin())) 10router.callback_query.filter(MagicData(F.user.is_admin()))
11
12router.include_routers(
13 new_announcement.router,
14)
diff --git a/handlers/admin/new_announcement.py b/handlers/admin/new_announcement.py
new file mode 100644
index 0000000..79cf8d4
--- /dev/null
+++ b/handlers/admin/new_announcement.py
@@ -0,0 +1,108 @@
1from datetime import UTC, datetime
2
3from aiogram import Bot, F, Router
4from aiogram.exceptions import TelegramAPIError
5from aiogram.filters import Command
6from aiogram.fsm.context import FSMContext
7from aiogram.fsm.state import State, StatesGroup
8from aiogram.types import (
9 ContentType,
10 KeyboardButton,
11 Message,
12 ReplyKeyboardMarkup,
13 ReplyKeyboardRemove,
14)
15from pydantic.main 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 publish
21from models import Announcement, RichText, User
22
23router = Router(name="new_announcement")
24
25
26class NewAnnouncementStates(StatesGroup):
27 message = State()
28
29
30class NewAnnouncementData(BaseModel):
31 rich_text: RichText | None = None
32
33
34SEND_BUTTON = "Опубликовать"
35CANCEL_BUTTON = "Отменить создание"
36
37
38@router.message(Command("new_announcement"))
39async def new_announcement_command(msg: Message, state: FSMContext) -> None:
40 await msg.answer(
41 "Укажите сообщение для анонса.",
42 reply_markup=ReplyKeyboardMarkup(
43 keyboard=[
44 [
45 KeyboardButton(text=SEND_BUTTON),
46 KeyboardButton(text=CANCEL_BUTTON),
47 ]
48 ],
49 resize_keyboard=True,
50 ),
51 )
52 await state.set_state(NewAnnouncementStates.message)
53
54
55@router.message(NewAnnouncementStates.message, F.text == SEND_BUTTON)
56async def announcement_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, NewAnnouncementData)
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 async for n in publish(bot, users, data.rich_text):
73 try:
74 await status_msg.edit_text(status_template.format(n))
75 except TelegramAPIError:
76 pass
77
78 announcement = Announcement(message=data, datetime=datetime.now(UTC))
79 session.add(announcement)
80
81 await status_msg.delete()
82 await msg.answer(
83 "Анонс отправлен всем пользователям",
84 reply_markup=ReplyKeyboardRemove(),
85 )
86 await state.clear()
87
88
89@router.message(NewAnnouncementStates.message, F.text == CANCEL_BUTTON)
90async def announcement_cancel(msg: Message, state: FSMContext) -> None:
91 await msg.answer("Создание анонса отменено", reply_markup=ReplyKeyboardRemove())
92 await state.clear()
93
94
95@router.message(NewAnnouncementStates.message)
96async def announcement_message(msg: Message, bot: Bot, state: FSMContext) -> None:
97 if msg.content_type != ContentType.TEXT or msg.text is None:
98 await msg.answer(
99 "Неверный тип сообщения.\n"
100 "Бот поддерживает отправку только текстовых анонсов."
101 )
102 return
103
104 rich_text = RichText.from_message(msg)
105 await set_data(state, NewAnnouncementData(rich_text=rich_text))
106
107 msg_rich_text = RichText.from_text("Сообщение вашего анонса:\n", rich_text)
108 await msg_rich_text.send(bot, msg.chat.id)
diff --git a/libs/__init__.py b/libs/__init__.py
new file mode 100644
index 0000000..d8ed122
--- /dev/null
+++ b/libs/__init__.py
@@ -0,0 +1,7 @@
1from . import fsm, msg, storage
2
3__all__ = [
4 "storage",
5 "fsm",
6 "msg",
7]
diff --git a/libs/fsm.py b/libs/fsm.py
new file mode 100644
index 0000000..00ccacb
--- /dev/null
+++ b/libs/fsm.py
@@ -0,0 +1,23 @@
1from contextlib import asynccontextmanager
2
3from aiogram.fsm.context import FSMContext
4from pydantic.main import BaseModel
5from typing_extensions import AsyncGenerator
6
7
8async def set_data(state: FSMContext, model: BaseModel) -> None:
9 await state.set_data(model.model_dump())
10
11
12async def get_data[T: BaseModel](state: FSMContext, model_type: type[T]) -> T:
13 return model_type.model_validate(await state.get_data())
14
15
16@asynccontextmanager
17async def edit_data[T: BaseModel](
18 state: FSMContext,
19 model_type: type[T],
20) -> AsyncGenerator[T]:
21 model = await get_data(state, model_type)
22 yield model
23 await set_data(state, model)
diff --git a/libs/msg.py b/libs/msg.py
new file mode 100644
index 0000000..9bcc52a
--- /dev/null
+++ b/libs/msg.py
@@ -0,0 +1,25 @@
1import asyncio
2from typing import AsyncGenerator, Iterable
3
4from aiogram import Bot
5from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
6
7from models import RichText
8
9
10async def publish(
11 bot: Bot,
12 users: Iterable[int],
13 rich_text: RichText,
14) -> AsyncGenerator[int]:
15 for n, user_id in enumerate(users, start=1):
16 for _ in range(5):
17 try:
18 await rich_text.send(bot, user_id)
19 break
20 except TelegramRetryAfter as e:
21 await asyncio.sleep(e.retry_after + 1)
22 except TelegramAPIError:
23 await asyncio.sleep(5)
24
25 yield n
diff --git a/libs/storage.py b/libs/storage.py
new file mode 100644
index 0000000..6220cfc
--- /dev/null
+++ b/libs/storage.py
@@ -0,0 +1,75 @@
1from pathlib import Path
2from typing import Any, Mapping
3
4from aiofiles import open as open
5from aiogram.fsm.state import State
6from aiogram.fsm.storage.base import (
7 BaseStorage,
8 DefaultKeyBuilder,
9 KeyBuilder,
10 StateType,
11 StorageKey,
12)
13from pydantic import TypeAdapter
14from pydantic.main import BaseModel
15
16
17class Record(BaseModel):
18 data: dict[str, Any] = {}
19 state: str | None = None
20
21
22class JsonStorage(BaseStorage):
23 file_path: Path
24 records: dict[str, Record]
25 records_adapter: TypeAdapter
26 key_builder: KeyBuilder
27
28 def __init__(self, file_path: Path, key_builder: KeyBuilder | None = None) -> None:
29 self.file_path = file_path
30 self.records = {}
31 self.records_adapter = TypeAdapter(dict[str, Record])
32 self.key_builder = DefaultKeyBuilder() if key_builder is None else key_builder
33
34 async def read(self) -> None:
35 async with open(self.file_path, "rb") as file:
36 json = await file.read()
37 self.records = self.records_adapter.validate_json(json)
38
39 async def flush(self) -> None:
40 async with open(self.file_path, "wb") as file:
41 json = self.records_adapter.dump_json(self.records)
42 await file.write(json)
43
44 async def get_record(self, key: StorageKey) -> Record:
45 await self.read()
46 record_key = self.key_builder.build(key)
47 if record_key not in self.records:
48 self.records[record_key] = Record()
49 return self.records[record_key]
50
51 async def set_state(self, key: StorageKey, state: StateType = None) -> None:
52 record = await self.get_record(key)
53 record.state = state.state if isinstance(state, State) else state
54 await self.flush()
55
56 async def get_state(self, key: StorageKey) -> str | None:
57 record = await self.get_record(key)
58 return record.state
59
60 async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None:
61 if not isinstance(data, dict):
62 raise TypeError(
63 f"Data must be a dict or dict-like object, got {type(data).__name__}",
64 data,
65 )
66 record = await self.get_record(key)
67 record.data = data.copy()
68 await self.flush()
69
70 async def get_data(self, key: StorageKey) -> dict[str, Any]:
71 record = await self.get_record(key)
72 return record.data
73
74 async def close(self) -> None:
75 await self.flush()
diff --git a/models/rich_text.py b/models/rich_text.py
index 2b433ec..e5441b6 100644
--- a/models/rich_text.py
+++ b/models/rich_text.py
@@ -1,7 +1,43 @@
1from aiogram.types import MessageEntity 1from typing import Self
2
3from aiogram import Bot
4from aiogram.types import Message, MessageEntity
2from pydantic import BaseModel 5from pydantic import BaseModel
3 6
4 7
5class RichText(BaseModel): 8class RichText(BaseModel):
6 text: str 9 text: str
7 entities: list[MessageEntity] = [] 10 entities: list[MessageEntity] = []
11
12 @classmethod
13 def from_message(cls, msg: Message) -> Self:
14 assert msg.text is not None
15 return cls(
16 text=msg.text,
17 entities=[] if msg.entities is None else msg.entities,
18 )
19
20 @classmethod
21 def from_text(cls, *text: RichText | str) -> Self:
22 result = cls(text="", entities=[])
23
24 for t in text:
25 if isinstance(t, RichText):
26 entities = t.entities.copy()
27 for e in entities:
28 e.offset += len(result.text)
29 result.entities += entities
30
31 result.text += t.text
32 else:
33 result.text += t
34
35 return result
36
37 async def send(self, bot: Bot, chat_id: int) -> Message:
38 return await bot.send_message(
39 chat_id=chat_id,
40 text=self.text,
41 entities=self.entities,
42 parse_mode=None,
43 )
diff --git a/pyproject.toml b/pyproject.toml
index 71a565e..29f1c60 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,6 +4,7 @@ version = "0.0.1"
4description = "Simple bot for vpn management" 4description = "Simple bot for vpn management"
5requires-python = ">=3.14" 5requires-python = ">=3.14"
6dependencies = [ 6dependencies = [
7 "aiofiles>=25.1.0",
7 "aiogram>=3.26.0", 8 "aiogram>=3.26.0",
8 "aiohttp-socks>=0.11.0", 9 "aiohttp-socks>=0.11.0",
9 "aiosqlite>=0.22.1", 10 "aiosqlite>=0.22.1",
diff --git a/settings.py b/settings.py
index 2daff90..04ba431 100644
--- a/settings.py
+++ b/settings.py
@@ -8,12 +8,17 @@ class Env(BaseSettings):
8 proxy: str | None = None 8 proxy: str | None = None
9 9
10 10
11database_path = Path("./storage/database.db").absolute() 11storage_path = Path("./storage/").absolute()
12database_path.parent.mkdir(parents=True, exist_ok=True) 12storage_path.mkdir(parents=True, exist_ok=True)
13
14database_path = storage_path / "database.db"
13database_url = f"sqlite+aiosqlite:///{database_path}" 15database_url = f"sqlite+aiosqlite:///{database_path}"
14 16
17json_storage_path = storage_path / "storage.json"
18
15__all__ = [ 19__all__ = [
16 "database_path", 20 "database_path",
17 "database_url", 21 "database_url",
22 "json_storage_path",
18 "Env", 23 "Env",
19] 24]
diff --git a/shared.py b/shared.py
index 5c7528c..63d00c3 100644
--- a/shared.py
+++ b/shared.py
@@ -3,7 +3,8 @@ from aiogram.client.default import DefaultBotProperties
3from aiogram.client.session.aiohttp import AiohttpSession 3from aiogram.client.session.aiohttp import AiohttpSession
4from aiogram.enums import ParseMode 4from aiogram.enums import ParseMode
5 5
6from settings import Env 6from libs.storage import JsonStorage
7from settings import Env, json_storage_path
7 8
8env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] 9env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue]
9bot = Bot( 10bot = Bot(
@@ -11,4 +12,4 @@ bot = Bot(
11 session=AiohttpSession(proxy=env.proxy), 12 session=AiohttpSession(proxy=env.proxy),
12 default=DefaultBotProperties(parse_mode=ParseMode.HTML), 13 default=DefaultBotProperties(parse_mode=ParseMode.HTML),
13) 14)
14dp = Dispatcher() 15dp = Dispatcher(storage=JsonStorage(json_storage_path))
diff --git a/uv.lock b/uv.lock
index a9b8849..3d84e96 100644
--- a/uv.lock
+++ b/uv.lock
@@ -516,6 +516,7 @@ name = "vpn-manager-bot"
516version = "0.0.1" 516version = "0.0.1"
517source = { virtual = "." } 517source = { virtual = "." }
518dependencies = [ 518dependencies = [
519 { name = "aiofiles" },
519 { name = "aiogram" }, 520 { name = "aiogram" },
520 { name = "aiohttp-socks" }, 521 { name = "aiohttp-socks" },
521 { name = "aiosqlite" }, 522 { name = "aiosqlite" },
@@ -528,6 +529,7 @@ dependencies = [
528 529
529[package.metadata] 530[package.metadata]
530requires-dist = [ 531requires-dist = [
532 { name = "aiofiles", specifier = ">=25.1.0" },
531 { name = "aiogram", specifier = ">=3.26.0" }, 533 { name = "aiogram", specifier = ">=3.26.0" },
532 { name = "aiohttp-socks", specifier = ">=0.11.0" }, 534 { name = "aiohttp-socks", specifier = ">=0.11.0" },
533 { name = "aiosqlite", specifier = ">=0.22.1" }, 535 { name = "aiosqlite", specifier = ">=0.22.1" },