diff options
| author | Tolmachev Igor <me@igorek.dev> | 2026-03-23 18:07:30 +0300 |
|---|---|---|
| committer | Tolmachev Igor <me@igorek.dev> | 2026-03-23 18:07:30 +0300 |
| commit | f7b7e87cffc9dcb2817b070d7a003ac234c96ec3 (patch) | |
| tree | 47f8af52edcac24f16ae58d0501bd2a4a42c2278 | |
| parent | 8e034766bb7e2d23f88c5ff1a254126f11a5f412 (diff) | |
| download | vpn_manager_bot-f7b7e87cffc9dcb2817b070d7a003ac234c96ec3.tar.gz vpn_manager_bot-f7b7e87cffc9dcb2817b070d7a003ac234c96ec3.zip | |
Add new_announcement command
| -rw-r--r-- | handlers/admin/__init__.py | 8 | ||||
| -rw-r--r-- | handlers/admin/new_announcement.py | 108 | ||||
| -rw-r--r-- | libs/__init__.py | 7 | ||||
| -rw-r--r-- | libs/fsm.py | 23 | ||||
| -rw-r--r-- | libs/msg.py | 25 | ||||
| -rw-r--r-- | libs/storage.py | 75 | ||||
| -rw-r--r-- | models/rich_text.py | 38 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | settings.py | 9 | ||||
| -rw-r--r-- | shared.py | 5 | ||||
| -rw-r--r-- | uv.lock | 2 |
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 @@ | |||
| 1 | from aiogram import F, Router | 1 | from aiogram import F, Router |
| 2 | from aiogram.filters import MagicData | 2 | from aiogram.filters import MagicData |
| 3 | 3 | ||
| 4 | # isort: off | ||
| 5 | from . import new_announcement | ||
| 6 | # isort: on | ||
| 7 | |||
| 4 | router = Router(name="admin") | 8 | router = Router(name="admin") |
| 5 | router.message.filter(MagicData(F.user.is_admin())) | 9 | router.message.filter(MagicData(F.user.is_admin())) |
| 6 | router.callback_query.filter(MagicData(F.user.is_admin())) | 10 | router.callback_query.filter(MagicData(F.user.is_admin())) |
| 11 | |||
| 12 | router.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 @@ | |||
| 1 | from datetime import UTC, datetime | ||
| 2 | |||
| 3 | from aiogram import Bot, F, Router | ||
| 4 | from aiogram.exceptions import TelegramAPIError | ||
| 5 | from aiogram.filters import Command | ||
| 6 | from aiogram.fsm.context import FSMContext | ||
| 7 | from aiogram.fsm.state import State, StatesGroup | ||
| 8 | from aiogram.types import ( | ||
| 9 | ContentType, | ||
| 10 | KeyboardButton, | ||
| 11 | Message, | ||
| 12 | ReplyKeyboardMarkup, | ||
| 13 | ReplyKeyboardRemove, | ||
| 14 | ) | ||
| 15 | from pydantic.main import BaseModel | ||
| 16 | from sqlalchemy import select | ||
| 17 | from sqlalchemy.ext.asyncio import AsyncSession | ||
| 18 | |||
| 19 | from libs.fsm import get_data, set_data | ||
| 20 | from libs.msg import publish | ||
| 21 | from models import Announcement, RichText, User | ||
| 22 | |||
| 23 | router = Router(name="new_announcement") | ||
| 24 | |||
| 25 | |||
| 26 | class NewAnnouncementStates(StatesGroup): | ||
| 27 | message = State() | ||
| 28 | |||
| 29 | |||
| 30 | class NewAnnouncementData(BaseModel): | ||
| 31 | rich_text: RichText | None = None | ||
| 32 | |||
| 33 | |||
| 34 | SEND_BUTTON = "Опубликовать" | ||
| 35 | CANCEL_BUTTON = "Отменить создание" | ||
| 36 | |||
| 37 | |||
| 38 | @router.message(Command("new_announcement")) | ||
| 39 | async 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) | ||
| 56 | async 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) | ||
| 90 | async 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) | ||
| 96 | async 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 @@ | |||
| 1 | from . 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 @@ | |||
| 1 | from contextlib import asynccontextmanager | ||
| 2 | |||
| 3 | from aiogram.fsm.context import FSMContext | ||
| 4 | from pydantic.main import BaseModel | ||
| 5 | from typing_extensions import AsyncGenerator | ||
| 6 | |||
| 7 | |||
| 8 | async def set_data(state: FSMContext, model: BaseModel) -> None: | ||
| 9 | await state.set_data(model.model_dump()) | ||
| 10 | |||
| 11 | |||
| 12 | async 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 | ||
| 17 | async 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 @@ | |||
| 1 | import asyncio | ||
| 2 | from typing import AsyncGenerator, Iterable | ||
| 3 | |||
| 4 | from aiogram import Bot | ||
| 5 | from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter | ||
| 6 | |||
| 7 | from models import RichText | ||
| 8 | |||
| 9 | |||
| 10 | async 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 @@ | |||
| 1 | from pathlib import Path | ||
| 2 | from typing import Any, Mapping | ||
| 3 | |||
| 4 | from aiofiles import open as open | ||
| 5 | from aiogram.fsm.state import State | ||
| 6 | from aiogram.fsm.storage.base import ( | ||
| 7 | BaseStorage, | ||
| 8 | DefaultKeyBuilder, | ||
| 9 | KeyBuilder, | ||
| 10 | StateType, | ||
| 11 | StorageKey, | ||
| 12 | ) | ||
| 13 | from pydantic import TypeAdapter | ||
| 14 | from pydantic.main import BaseModel | ||
| 15 | |||
| 16 | |||
| 17 | class Record(BaseModel): | ||
| 18 | data: dict[str, Any] = {} | ||
| 19 | state: str | None = None | ||
| 20 | |||
| 21 | |||
| 22 | class 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 @@ | |||
| 1 | from aiogram.types import MessageEntity | 1 | from typing import Self |
| 2 | |||
| 3 | from aiogram import Bot | ||
| 4 | from aiogram.types import Message, MessageEntity | ||
| 2 | from pydantic import BaseModel | 5 | from pydantic import BaseModel |
| 3 | 6 | ||
| 4 | 7 | ||
| 5 | class RichText(BaseModel): | 8 | class 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" | |||
| 4 | description = "Simple bot for vpn management" | 4 | description = "Simple bot for vpn management" |
| 5 | requires-python = ">=3.14" | 5 | requires-python = ">=3.14" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 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 | ||
| 11 | database_path = Path("./storage/database.db").absolute() | 11 | storage_path = Path("./storage/").absolute() |
| 12 | database_path.parent.mkdir(parents=True, exist_ok=True) | 12 | storage_path.mkdir(parents=True, exist_ok=True) |
| 13 | |||
| 14 | database_path = storage_path / "database.db" | ||
| 13 | database_url = f"sqlite+aiosqlite:///{database_path}" | 15 | database_url = f"sqlite+aiosqlite:///{database_path}" |
| 14 | 16 | ||
| 17 | json_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 | ] |
| @@ -3,7 +3,8 @@ from aiogram.client.default import DefaultBotProperties | |||
| 3 | from aiogram.client.session.aiohttp import AiohttpSession | 3 | from aiogram.client.session.aiohttp import AiohttpSession |
| 4 | from aiogram.enums import ParseMode | 4 | from aiogram.enums import ParseMode |
| 5 | 5 | ||
| 6 | from settings import Env | 6 | from libs.storage import JsonStorage |
| 7 | from settings import Env, json_storage_path | ||
| 7 | 8 | ||
| 8 | env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] | 9 | env = Env() # ty:ignore[missing-argument] # pyright: ignore[reportCallIssue] |
| 9 | bot = Bot( | 10 | bot = 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 | ) |
| 14 | dp = Dispatcher() | 15 | dp = Dispatcher(storage=JsonStorage(json_storage_path)) |
| @@ -516,6 +516,7 @@ name = "vpn-manager-bot" | |||
| 516 | version = "0.0.1" | 516 | version = "0.0.1" |
| 517 | source = { virtual = "." } | 517 | source = { virtual = "." } |
| 518 | dependencies = [ | 518 | dependencies = [ |
| 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] |
| 530 | requires-dist = [ | 531 | requires-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" }, |
